package edge import ( "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/seeds" ) // The import gate (seeded-instances spec ยง5.4). Neutral fixtures: app shop, // hosts under example.com. const ( gateHost = "old.example.com" gateAliasHost = "www.example.com" gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" gateRefusal = "this instance is being imported. Try again in a few seconds.\n" ) // gateBackend is an upstream that counts its requests and keeps the verify // header of the last one, so a test can say whether the app was reached and // whether the token reached it. type gateBackend struct { mu sync.Mutex n int sawVerify []string } func newGateBackend(t *testing.T, body, cacheControl string) (*httptest.Server, *gateBackend) { t.Helper() b := &gateBackend{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b.mu.Lock() b.n++ b.sawVerify = append(b.sawVerify, r.Header.Values(ImportVerifyHeader)...) b.mu.Unlock() if cacheControl != "" { w.Header().Set("Cache-Control", cacheControl) } w.Write([]byte(body)) })) t.Cleanup(srv.Close) return srv, b } func (b *gateBackend) seen() (int, []string) { b.mu.Lock() defer b.mu.Unlock() return b.n, append([]string(nil), b.sawVerify...) } // gateForImport swaps host's stored row for a gated import row backed by // addr, the way the swap does (one journal transaction), and returns the // row it wrote. The import row keeps the stored row's account and app. func gateForImport(t *testing.T, reg *registry.Registry, host, addr, gate string, hibernate bool) registry.Route { t.Helper() stored, err := reg.Get(host) if err != nil || stored == nil { t.Fatalf("get %s: %v %v", host, stored, err) } next := registry.Route{Host: host, Kind: "instance", Addr: addr, Status: "ready", App: stored.App, Account: stored.Account, Hibernate: hibernate, Provisioned: true, ImportGate: gate, SeedSource: `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}`} j := registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1, RecordKey: "apps/bbw/shop/instances/" + host + ".json"} if err := reg.SwapForImport(j, next); err != nil { t.Fatalf("swap %s: %v", host, err) } return next } // gateScene is an edge with a legacy row at gateHost (and an alias of it), // swapped for a gated import row whose instance hibernates behind a fake // activator. type gateScene struct { reg *registry.Registry e *Edge act *fakeActivator legacy *gateBackend imported *gateBackend token string } func newGateScene(t *testing.T, importCacheControl string) *gateScene { t.Helper() legacySrv, legacy := newGateBackend(t, "legacy", "public, max-age=60") importSrv, imported := newGateBackend(t, "imported", importCacheControl) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: gateHost, Kind: "service", Addr: legacySrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"}); err != nil { t.Fatal(err) } upsertAlias(t, reg, gateAliasHost, gateHost) token, hash, err := seeds.NewVerifyToken() if err != nil { t.Fatal(err) } act := &fakeActivator{} e, err := New(reg, act) if err != nil { t.Fatal(err) } t.Cleanup(e.probers.Stop) e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) // Before the swap the legacy app answers and its response is cached. if rec := gateDo(e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" { t.Fatalf("before the swap: %d %q", rec.Code, rec.Body.String()) } gateForImport(t, reg, gateHost, importSrv.Listener.Addr().String(), hash, true) if err := e.Reload(); err != nil { t.Fatal(err) } return &gateScene{reg: reg, e: e, act: act, legacy: legacy, imported: imported, token: token} } func gateDo(h http.Handler, method, host, target, token string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, "http://"+host+target, nil) req.Host = host if token != "" { req.Header.Set(ImportVerifyHeader, token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // wantGateRefusal is the gate's answer: 503, Retry-After 5, no-store, the // deck's sentence, and nothing of the token. func wantGateRefusal(t *testing.T, rec *httptest.ResponseRecorder, token, what string) { t.Helper() if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" || rec.Header().Get("Cache-Control") != "no-store" || rec.Body.String() != gateRefusal { t.Fatalf("%s: %d retry %q cache %q body %q, want the gate's 503", what, rec.Code, rec.Header().Get("Retry-After"), rec.Header().Get("Cache-Control"), rec.Body.String()) } if token != "" && strings.Contains(rec.Body.String()+strings.Join(headerValues(rec.Header()), " "), token) { t.Fatalf("%s: the refusal echoed the token", what) } } func headerValues(h http.Header) []string { var out []string for k, vs := range h { out = append(out, k) out = append(out, vs...) } return out } // Every ordinary request to a gated row is refused before the cache and the // activator; only a GET carrying the right token reaches the instance, and // the app never sees the header. A closed gate refuses even the token, and // commit opens the row to everyone. func TestImportGateAtTheEdge(t *testing.T) { s := newGateScene(t, "") wrong, _, err := seeds.NewVerifyToken() if err != nil { t.Fatal(err) } refused := []struct { name, method, token string }{ {"no token", http.MethodGet, ""}, {"a wrong token", http.MethodGet, wrong}, {"the hash as the token", http.MethodGet, seeds.HashToken(s.token)}, {"a POST with the token", http.MethodPost, s.token}, {"a HEAD with the token", http.MethodHead, s.token}, {"a PUT with the token", http.MethodPut, s.token}, } for _, c := range refused { rec := gateDo(s.e, c.method, gateHost, "/up", c.token) if c.method == http.MethodHead { // A HEAD answer has no body to compare. if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" { t.Fatalf("%s: %d, want the gate's 503", c.name, rec.Code) } continue } wantGateRefusal(t, rec, s.token, c.name) } if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { t.Fatalf("the gate let an ordinary request through: app %d, wakes %d", n, s.act.served) } if n, _ := s.legacy.seen(); n != 1 { t.Fatalf("the legacy app was reached %d times, want only the request before the swap", n) } // The verify request: GET with the token, on any path of the host. The // legacy response cached before the swap is not what it gets. rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token) if rec.Code != http.StatusOK || rec.Body.String() != "imported" { t.Fatalf("verify = %d %q, want the imported app", rec.Code, rec.Body.String()) } if _, saw := s.imported.seen(); len(saw) != 0 { t.Fatalf("the app saw the verify header: %q", saw) } if s.act.served != 1 { t.Fatalf("the verify request woke the instance %d times, want 1", s.act.served) } // A rollback closes the gate: even the token is refused. if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil { t.Fatal(err) } if err := s.e.Reload(); err != nil { t.Fatal(err) } wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, "/up", s.token), s.token, "a closed gate") if n, _ := s.imported.seen(); n != 1 { t.Fatalf("a closed gate let the token through: app reached %d times", n) } } // Commit clears the gate: ordinary requests reach the instance, and a stray // verify header is still stripped before the app. func TestImportGateOpensOnCommit(t *testing.T) { s := newGateScene(t, "") if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil { t.Fatal(err) } if err := s.e.Reload(); err != nil { t.Fatal(err) } if rec := gateDo(s.e, http.MethodPost, gateHost, "/orders", ""); rec.Code != http.StatusOK || rec.Body.String() != "imported" { t.Fatalf("after commit = %d %q, want the imported app", rec.Code, rec.Body.String()) } if rec := gateDo(s.e, http.MethodGet, gateHost, "/orders", s.token); rec.Code != http.StatusOK { t.Fatalf("after commit with a stray token = %d", rec.Code) } if _, saw := s.imported.seen(); len(saw) != 0 { t.Fatalf("the app saw the verify header after commit: %q", saw) } } // An alias of a gated host is the gated row under another name: refused // without the token, and the token admits a GET. func TestImportGateCoversAnAlias(t *testing.T) { s := newGateScene(t, "") wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/up", ""), s.token, "an alias with no token") if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { t.Fatalf("an alias reached a gated row: app %d, wakes %d", n, s.act.served) } if rec := gateDo(s.e, http.MethodGet, gateAliasHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { t.Fatalf("verify through an alias = %d %q", rec.Code, rec.Body.String()) } } // A path route under a gated host is gated too, and the token does not // open it: while the host is gated nothing but the verify request reaches // anything, and the verify request reaches only the gated row. The other // direction holds as well: an ungated host's prefix that selects a gated // row is refused, token or not, because a verify names the import's host. func TestImportGateCoversPathRoutes(t *testing.T) { s := newGateScene(t, "") targetSrv, target := newGateBackend(t, "prefix target", "") frontSrv, front := newGateBackend(t, "front", "") for _, rt := range []registry.Route{ {Host: "next.example.com", Kind: "service", Addr: targetSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"}, {Host: "front.example.com", Kind: "service", Addr: frontSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"}, } { if err := s.reg.Upsert(rt); err != nil { t.Fatal(err) } } upsertPath(t, s.reg, gateHost, "shop", "next.example.com") upsertPath(t, s.reg, "front.example.com", "old", gateHost) if err := s.e.Reload(); err != nil { t.Fatal(err) } for _, tok := range []string{"", s.token} { for _, p := range []string{"/shop", "/shop/cart", "//shop/cart", "/x/../shop"} { wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, p, tok), s.token, "the gated host's prefix "+p) } wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/shop", tok), s.token, "the prefix through the alias") wantGateRefusal(t, gateDo(s.e, http.MethodGet, "front.example.com", "/old/up", tok), s.token, "a prefix selecting the gated row") } if n, _ := target.seen(); n != 0 { t.Fatalf("a path route under a gated host was reached %d times", n) } if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 { t.Fatalf("the gated row was reached through a prefix: app %d, wakes %d", n, s.act.served) } // The ungated front's own route is not the import's business. if rec := gateDo(s.e, http.MethodGet, "front.example.com", "/home", ""); rec.Code != http.StatusOK || rec.Body.String() != "front" { t.Fatalf("the ungated front = %d %q", rec.Code, rec.Body.String()) } if n, _ := front.seen(); n != 1 { t.Fatalf("front reached %d times", n) } // A verify on a path no prefix claims reaches the gated row. if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { t.Fatalf("verify beside a prefix = %d %q", rec.Code, rec.Body.String()) } // After commit the prefix routes as before. if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil { t.Fatal(err) } if err := s.e.Reload(); err != nil { t.Fatal(err) } if rec := gateDo(s.e, http.MethodGet, gateHost, "/shop/cart", ""); rec.Code != http.StatusOK || rec.Body.String() != "prefix target" { t.Fatalf("the prefix after commit = %d %q", rec.Code, rec.Body.String()) } } // The verify request never touches the shared cache: it does not read the // legacy app's cached answer, and its own answer is not stored, so a // rollback that puts the legacy route back never replays the import's // response to visitors. func TestImportGateVerifyBypassesTheCache(t *testing.T) { s := newGateScene(t, "public, max-age=60") for i := 1; i <= 2; i++ { if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" { t.Fatalf("verify %d = %d %q", i, rec.Code, rec.Body.String()) } if n, _ := s.imported.seen(); n != i { t.Fatalf("verify %d reached the app %d times: served from the cache", i, n) } } // The rollback restores the legacy route. if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil { t.Fatal(err) } if err := s.reg.RestoreImportRoute(gateHost, gateSeedID); err != nil { t.Fatal(err) } if err := s.e.Reload(); err != nil { t.Fatal(err) } if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" { t.Fatalf("after the rollback = %d %q, want the legacy answer", rec.Code, rec.Body.String()) } } // A gated host homed on another box is gated by that box's edge: the gate // column travels with the row, and the sending edge has no row for the host, // so it backhauls the request untouched and the home edge decides. Without // the token the client gets the home edge's 503; with it, the import answers // and never sees the header. func TestImportGateHoldsAcrossABackhaul(t *testing.T) { s := newBackhaulScene(t) s.converge(t) importSrv, imported := newGateBackend(t, "imported", "") token, hash, err := seeds.NewVerifyToken() if err != nil { t.Fatal(err) } gateForImport(t, s.a.edge.reg, e2eHomedHost, importSrv.Listener.Addr().String(), hash, false) if err := s.a.edge.Reload(); err != nil { t.Fatal(err) } if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok { t.Fatal("test bug: the sending edge must not route the gated host") } url := "https://" + e2eHomedHost + "/up" home := s.a.calls.Load() resp, body := s.get(t, s.b, url, nil, true) if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "5" || body != gateRefusal { 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) } if got := s.a.calls.Load() - home; got != 1 { t.Fatalf("the home edge saw %d request(s), want the one it refused", got) } if n, _ := imported.seen(); n != 0 { t.Fatalf("a backhauled request with no token reached the import %d times", n) } resp, body = s.get(t, s.b, url, map[string]string{ImportVerifyHeader: token}, true) if resp.StatusCode != http.StatusOK || body != "imported" { t.Fatalf("backhauled verify = %d %q, want the import", resp.StatusCode, body) } if n, saw := imported.seen(); n != 1 || len(saw) != 0 { t.Fatalf("backhauled verify: import reached %d times, header at the app %q", n, saw) } }