| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | "github.com/carlosframework/platform/internal/seeds" |
| 12 | ) |
| 13 | |
| 14 | // The import gate (seeded-instances spec §5.4). Neutral fixtures: app shop, |
| 15 | // hosts under example.com. |
| 16 | const ( |
| 17 | gateHost = "old.example.com" |
| 18 | gateAliasHost = "www.example.com" |
| 19 | gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 20 | gateRefusal = "this instance is being imported. Try again in a few seconds.\n" |
| 21 | ) |
| 22 | |
| 23 | // gateBackend is an upstream that counts its requests and keeps the verify |
| 24 | // header of the last one, so a test can say whether the app was reached and |
| 25 | // whether the token reached it. |
| 26 | type gateBackend struct { |
| 27 | mu sync.Mutex |
| 28 | n int |
| 29 | sawVerify []string |
| 30 | } |
| 31 | |
| 32 | func newGateBackend(t *testing.T, body, cacheControl string) (*httptest.Server, *gateBackend) { |
| 33 | t.Helper() |
| 34 | b := &gateBackend{} |
| 35 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 36 | b.mu.Lock() |
| 37 | b.n++ |
| 38 | b.sawVerify = append(b.sawVerify, r.Header.Values(ImportVerifyHeader)...) |
| 39 | b.mu.Unlock() |
| 40 | if cacheControl != "" { |
| 41 | w.Header().Set("Cache-Control", cacheControl) |
| 42 | } |
| 43 | w.Write([]byte(body)) |
| 44 | })) |
| 45 | t.Cleanup(srv.Close) |
| 46 | return srv, b |
| 47 | } |
| 48 | |
| 49 | func (b *gateBackend) seen() (int, []string) { |
| 50 | b.mu.Lock() |
| 51 | defer b.mu.Unlock() |
| 52 | return b.n, append([]string(nil), b.sawVerify...) |
| 53 | } |
| 54 | |
| 55 | // gateForImport swaps host's stored row for a gated import row backed by |
| 56 | // addr, the way the swap does (one journal transaction), and returns the |
| 57 | // row it wrote. The import row keeps the stored row's account and app. |
| 58 | func gateForImport(t *testing.T, reg *registry.Registry, host, addr, gate string, hibernate bool) registry.Route { |
| 59 | t.Helper() |
| 60 | stored, err := reg.Get(host) |
| 61 | if err != nil || stored == nil { |
| 62 | t.Fatalf("get %s: %v %v", host, stored, err) |
| 63 | } |
| 64 | next := registry.Route{Host: host, Kind: "instance", Addr: addr, Status: "ready", App: stored.App, |
| 65 | Account: stored.Account, Hibernate: hibernate, Provisioned: true, ImportGate: gate, |
| 66 | SeedSource: `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}`} |
| 67 | j := registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1, |
| 68 | RecordKey: "apps/bbw/shop/instances/" + host + ".json"} |
| 69 | if err := reg.SwapForImport(j, next); err != nil { |
| 70 | t.Fatalf("swap %s: %v", host, err) |
| 71 | } |
| 72 | return next |
| 73 | } |
| 74 | |
| 75 | // gateScene is an edge with a legacy row at gateHost (and an alias of it), |
| 76 | // swapped for a gated import row whose instance hibernates behind a fake |
| 77 | // activator. |
| 78 | type gateScene struct { |
| 79 | reg *registry.Registry |
| 80 | e *Edge |
| 81 | act *fakeActivator |
| 82 | legacy *gateBackend |
| 83 | imported *gateBackend |
| 84 | token string |
| 85 | } |
| 86 | |
| 87 | func newGateScene(t *testing.T, importCacheControl string) *gateScene { |
| 88 | t.Helper() |
| 89 | legacySrv, legacy := newGateBackend(t, "legacy", "public, max-age=60") |
| 90 | importSrv, imported := newGateBackend(t, "imported", importCacheControl) |
| 91 | reg := openReg(t) |
| 92 | if err := reg.Upsert(registry.Route{Host: gateHost, Kind: "service", Addr: legacySrv.Listener.Addr().String(), |
| 93 | Status: "ready", App: "shop", Account: "bbw"}); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | upsertAlias(t, reg, gateAliasHost, gateHost) |
| 97 | token, hash, err := seeds.NewVerifyToken() |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | act := &fakeActivator{} |
| 102 | e, err := New(reg, act) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | t.Cleanup(e.probers.Stop) |
| 107 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 108 | // Before the swap the legacy app answers and its response is cached. |
| 109 | if rec := gateDo(e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" { |
| 110 | t.Fatalf("before the swap: %d %q", rec.Code, rec.Body.String()) |
| 111 | } |
| 112 | gateForImport(t, reg, gateHost, importSrv.Listener.Addr().String(), hash, true) |
| 113 | if err := e.Reload(); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | return &gateScene{reg: reg, e: e, act: act, legacy: legacy, imported: imported, token: token} |
| 117 | } |
| 118 | |
| 119 | func gateDo(h http.Handler, method, host, target, token string) *httptest.ResponseRecorder { |
| 120 | req := httptest.NewRequest(method, "http://"+host+target, nil) |
| 121 | req.Host = host |
| 122 | if token != "" { |
| 123 | req.Header.Set(ImportVerifyHeader, token) |
| 124 | } |
| 125 | rec := httptest.NewRecorder() |
| 126 | h.ServeHTTP(rec, req) |
| 127 | return rec |
| 128 | } |
| 129 | |
| 130 | // wantGateRefusal is the gate's answer: 503, Retry-After 5, no-store, the |
| 131 | // deck's sentence, and nothing of the token. |
| 132 | func wantGateRefusal(t *testing.T, rec *httptest.ResponseRecorder, token, what string) { |
| 133 | t.Helper() |
| 134 | if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" || |
| 135 | rec.Header().Get("Cache-Control") != "no-store" || rec.Body.String() != gateRefusal { |
| 136 | t.Fatalf("%s: %d retry %q cache %q body %q, want the gate's 503", what, rec.Code, |
| 137 | rec.Header().Get("Retry-After"), rec.Header().Get("Cache-Control"), rec.Body.String()) |
| 138 | } |
| 139 | if token != "" && strings.Contains(rec.Body.String()+strings.Join(headerValues(rec.Header()), " "), token) { |
| 140 | t.Fatalf("%s: the refusal echoed the token", what) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func headerValues(h http.Header) []string { |
| 145 | var out []string |
| 146 | for k, vs := range h { |
| 147 | out = append(out, k) |
| 148 | out = append(out, vs...) |
| 149 | } |
| 150 | return out |
| 151 | } |
| 152 | |
| 153 | // Every ordinary request to a gated row is refused before the cache and the |
| 154 | // activator; only a GET carrying the right token reaches the instance, and |
| 155 | // the app never sees the header. A closed gate refuses even the token, and |
| 156 | // commit opens the row to everyone. |
| 157 | func TestImportGateAtTheEdge(t *testing.T) { |
| 158 | s := newGateScene(t, "") |
| 159 | wrong, _, err := seeds.NewVerifyToken() |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | refused := []struct { |
| 164 | name, method, token string |
| 165 | }{ |
| 166 | {"no token", http.MethodGet, ""}, |
| 167 | {"a wrong token", http.MethodGet, wrong}, |
| 168 | {"the hash as the token", http.MethodGet, seeds.HashToken(s.token)}, |
| 169 | {"a POST with the token", http.MethodPost, s.token}, |
| 170 | {"a HEAD with the token", http.MethodHead, s.token}, |
| 171 | {"a PUT with the token", http.MethodPut, s.token}, |
| 172 | } |
| 173 | for _, c := range refused { |
| 174 | rec := gateDo(s.e, c.method, gateHost, "/up", c.token) |
| 175 | if c.method == http.MethodHead { |
| 176 | // A HEAD answer has no body to compare. |
| 177 | if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" { |
| 178 | t.Fatalf("%s: %d, want the gate's 503", c.name, rec.Code) |
| 179 | } |
| 180 | continue |
| 181 | } |
| 182 | wantGateRefusal(t, rec, s.token, c.name) |
| 183 | } |
| 184 | if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { |
| 185 | t.Fatalf("the gate let an ordinary request through: app %d, wakes %d", n, s.act.served) |
| 186 | } |
| 187 | if n, _ := s.legacy.seen(); n != 1 { |
| 188 | t.Fatalf("the legacy app was reached %d times, want only the request before the swap", n) |
| 189 | } |
| 190 | |
| 191 | // The verify request: GET with the token, on any path of the host. The |
| 192 | // legacy response cached before the swap is not what it gets. |
| 193 | rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token) |
| 194 | if rec.Code != http.StatusOK || rec.Body.String() != "imported" { |
| 195 | t.Fatalf("verify = %d %q, want the imported app", rec.Code, rec.Body.String()) |
| 196 | } |
| 197 | if _, saw := s.imported.seen(); len(saw) != 0 { |
| 198 | t.Fatalf("the app saw the verify header: %q", saw) |
| 199 | } |
| 200 | if s.act.served != 1 { |
| 201 | t.Fatalf("the verify request woke the instance %d times, want 1", s.act.served) |
| 202 | } |
| 203 | |
| 204 | // A rollback closes the gate: even the token is refused. |
| 205 | if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if err := s.e.Reload(); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, "/up", s.token), s.token, "a closed gate") |
| 212 | if n, _ := s.imported.seen(); n != 1 { |
| 213 | t.Fatalf("a closed gate let the token through: app reached %d times", n) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // Commit clears the gate: ordinary requests reach the instance, and a stray |
| 218 | // verify header is still stripped before the app. |
| 219 | func TestImportGateOpensOnCommit(t *testing.T) { |
| 220 | s := newGateScene(t, "") |
| 221 | if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if err := s.e.Reload(); err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | if rec := gateDo(s.e, http.MethodPost, gateHost, "/orders", ""); rec.Code != http.StatusOK || rec.Body.String() != "imported" { |
| 228 | t.Fatalf("after commit = %d %q, want the imported app", rec.Code, rec.Body.String()) |
| 229 | } |
| 230 | if rec := gateDo(s.e, http.MethodGet, gateHost, "/orders", s.token); rec.Code != http.StatusOK { |
| 231 | t.Fatalf("after commit with a stray token = %d", rec.Code) |
| 232 | } |
| 233 | if _, saw := s.imported.seen(); len(saw) != 0 { |
| 234 | t.Fatalf("the app saw the verify header after commit: %q", saw) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // An alias of a gated host is the gated row under another name: refused |
| 239 | // without the token, and the token admits a GET. |
| 240 | func TestImportGateCoversAnAlias(t *testing.T) { |
| 241 | s := newGateScene(t, "") |
| 242 | wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/up", ""), s.token, "an alias with no token") |
| 243 | if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { |
| 244 | t.Fatalf("an alias reached a gated row: app %d, wakes %d", n, s.act.served) |
| 245 | } |
| 246 | if rec := gateDo(s.e, http.MethodGet, gateAliasHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { |
| 247 | t.Fatalf("verify through an alias = %d %q", rec.Code, rec.Body.String()) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // A path route under a gated host is gated too, and the token does not |
| 252 | // open it: while the host is gated nothing but the verify request reaches |
| 253 | // anything, and the verify request reaches only the gated row. The other |
| 254 | // direction holds as well: an ungated host's prefix that selects a gated |
| 255 | // row is refused, token or not, because a verify names the import's host. |
| 256 | func TestImportGateCoversPathRoutes(t *testing.T) { |
| 257 | s := newGateScene(t, "") |
| 258 | targetSrv, target := newGateBackend(t, "prefix target", "") |
| 259 | frontSrv, front := newGateBackend(t, "front", "") |
| 260 | for _, rt := range []registry.Route{ |
| 261 | {Host: "next.example.com", Kind: "service", Addr: targetSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"}, |
| 262 | {Host: "front.example.com", Kind: "service", Addr: frontSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"}, |
| 263 | } { |
| 264 | if err := s.reg.Upsert(rt); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | } |
| 268 | upsertPath(t, s.reg, gateHost, "shop", "next.example.com") |
| 269 | upsertPath(t, s.reg, "front.example.com", "old", gateHost) |
| 270 | if err := s.e.Reload(); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | |
| 274 | for _, tok := range []string{"", s.token} { |
| 275 | for _, p := range []string{"/shop", "/shop/cart", "//shop/cart", "/x/../shop"} { |
| 276 | wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, p, tok), s.token, "the gated host's prefix "+p) |
| 277 | } |
| 278 | wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/shop", tok), s.token, "the prefix through the alias") |
| 279 | wantGateRefusal(t, gateDo(s.e, http.MethodGet, "front.example.com", "/old/up", tok), s.token, "a prefix selecting the gated row") |
| 280 | } |
| 281 | if n, _ := target.seen(); n != 0 { |
| 282 | t.Fatalf("a path route under a gated host was reached %d times", n) |
| 283 | } |
| 284 | if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { |
| 285 | t.Fatalf("the gated row was reached through a prefix: app %d, wakes %d", n, s.act.served) |
| 286 | } |
| 287 | |
| 288 | // The ungated front's own route is not the import's business. |
| 289 | if rec := gateDo(s.e, http.MethodGet, "front.example.com", "/home", ""); rec.Code != http.StatusOK || rec.Body.String() != "front" { |
| 290 | t.Fatalf("the ungated front = %d %q", rec.Code, rec.Body.String()) |
| 291 | } |
| 292 | if n, _ := front.seen(); n != 1 { |
| 293 | t.Fatalf("front reached %d times", n) |
| 294 | } |
| 295 | // A verify on a path no prefix claims reaches the gated row. |
| 296 | if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { |
| 297 | t.Fatalf("verify beside a prefix = %d %q", rec.Code, rec.Body.String()) |
| 298 | } |
| 299 | |
| 300 | // After commit the prefix routes as before. |
| 301 | if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | if err := s.e.Reload(); err != nil { |
| 305 | t.Fatal(err) |
| 306 | } |
| 307 | if rec := gateDo(s.e, http.MethodGet, gateHost, "/shop/cart", ""); rec.Code != http.StatusOK || rec.Body.String() != "prefix target" { |
| 308 | t.Fatalf("the prefix after commit = %d %q", rec.Code, rec.Body.String()) |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // The verify request never touches the shared cache: it does not read the |
| 313 | // legacy app's cached answer, and its own answer is not stored, so a |
| 314 | // rollback that puts the legacy route back never replays the import's |
| 315 | // response to visitors. |
| 316 | func TestImportGateVerifyBypassesTheCache(t *testing.T) { |
| 317 | s := newGateScene(t, "public, max-age=60") |
| 318 | for i := 1; i <= 2; i++ { |
| 319 | if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { |
| 320 | t.Fatalf("verify %d = %d %q", i, rec.Code, rec.Body.String()) |
| 321 | } |
| 322 | if n, _ := s.imported.seen(); n != i { |
| 323 | t.Fatalf("verify %d reached the app %d times: served from the cache", i, n) |
| 324 | } |
| 325 | } |
| 326 | // The rollback restores the legacy route. |
| 327 | if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | if err := s.reg.RestoreImportRoute(gateHost, gateSeedID); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | if err := s.e.Reload(); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" { |
| 337 | t.Fatalf("after the rollback = %d %q, want the legacy answer", rec.Code, rec.Body.String()) |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // A gated host homed on another box is gated by that box's edge: the gate |
| 342 | // column travels with the row, and the sending edge has no row for the host, |
| 343 | // so it backhauls the request untouched and the home edge decides. Without |
| 344 | // the token the client gets the home edge's 503; with it, the import answers |
| 345 | // and never sees the header. |
| 346 | func TestImportGateHoldsAcrossABackhaul(t *testing.T) { |
| 347 | s := newBackhaulScene(t) |
| 348 | s.converge(t) |
| 349 | importSrv, imported := newGateBackend(t, "imported", "") |
| 350 | token, hash, err := seeds.NewVerifyToken() |
| 351 | if err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | gateForImport(t, s.a.edge.reg, e2eHomedHost, importSrv.Listener.Addr().String(), hash, false) |
| 355 | if err := s.a.edge.Reload(); err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok { |
| 359 | t.Fatal("test bug: the sending edge must not route the gated host") |
| 360 | } |
| 361 | url := "https://" + e2eHomedHost + "/up" |
| 362 | home := s.a.calls.Load() |
| 363 | resp, body := s.get(t, s.b, url, nil, true) |
| 364 | if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "5" || body != gateRefusal { |
| 365 | t.Fatalf("backhauled with no token = %d retry %q body %q, want the home edge's gate", resp.StatusCode, resp.Header.Get("Retry-After"), body) |
| 366 | } |
| 367 | if got := s.a.calls.Load() - home; got != 1 { |
| 368 | t.Fatalf("the home edge saw %d request(s), want the one it refused", got) |
| 369 | } |
| 370 | if n, _ := imported.seen(); n != 0 { |
| 371 | t.Fatalf("a backhauled request with no token reached the import %d times", n) |
| 372 | } |
| 373 | resp, body = s.get(t, s.b, url, map[string]string{ImportVerifyHeader: token}, true) |
| 374 | if resp.StatusCode != http.StatusOK || body != "imported" { |
| 375 | t.Fatalf("backhauled verify = %d %q, want the import", resp.StatusCode, body) |
| 376 | } |
| 377 | if n, saw := imported.seen(); n != 1 || len(saw) != 0 { |
| 378 | t.Fatalf("backhauled verify: import reached %d times, header at the app %q", n, saw) |
| 379 | } |
| 380 | } |
| 381 | |