package instanceupdates import ( "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) func serve(t *testing.T, token string) *httptest.Server { t.Helper() mux := http.NewServeMux() Mount(mux, "a.example.com", func() (string, error) { return token, nil }, func(host string) (Report, error) { return Report{Host: host, Settings: release.UpdateSettings{Updates: release.UpdatesManual}, MayChange: []string{"pin"}, Installed: "v1"}, nil }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func get(t *testing.T, url, bearer string) (*http.Response, Report) { t.Helper() req, _ := http.NewRequest(http.MethodGet, url, nil) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() var rep Report _ = json.NewDecoder(res.Body).Decode(&rep) return res, rep } func TestUpdatesNeedsTheBearer(t *testing.T) { srv := serve(t, "secret") if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized { t.Fatalf("no bearer: %d", res.StatusCode) } if res, _ := get(t, srv.URL+"/updates", "wrong"); res.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong bearer: %d", res.StatusCode) } res, rep := get(t, srv.URL+"/updates", "secret") if res.StatusCode != http.StatusOK || rep.Host != "a.example.com" || rep.Settings.Updates != release.UpdatesManual { t.Fatalf("GET: %d %+v", res.StatusCode, rep) } } func TestUpdatesIsReadOnlyAndFixedHost(t *testing.T) { srv := serve(t, "secret") req, _ := http.NewRequest(http.MethodPut, srv.URL+"/updates", nil) req.Header.Set("Authorization", "Bearer secret") res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("PUT: %d, want 405", res.StatusCode) } if _, rep := get(t, srv.URL+"/updates?host=other.example.com", "secret"); rep.Host != "a.example.com" { t.Fatal("the host came from the request") } } func TestAnEmptyTokenRefusesEverything(t *testing.T) { srv := serve(t, "") if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized { t.Fatalf("empty token: %d", res.StatusCode) } } // The token is read on every call: a rewritten env file takes effect on the // next request with no restart. func TestTheTokenIsReadOnEveryCall(t *testing.T) { cur := "one" mux := http.NewServeMux() Mount(mux, "a.example.com", func() (string, error) { return cur, nil }, func(string) (Report, error) { return Report{}, nil }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusOK { t.Fatalf("first token: %d", res.StatusCode) } cur = "two" if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusUnauthorized { t.Fatalf("old token after rewrite: %d", res.StatusCode) } if res, _ := get(t, srv.URL+"/updates", "two"); res.StatusCode != http.StatusOK { t.Fatalf("new token: %d", res.StatusCode) } } func TestATokenErrorOrAReaderErrorIsRefused(t *testing.T) { mux := http.NewServeMux() Mount(mux, "a.example.com", func() (string, error) { return "", errors.New("no env") }, func(string) (Report, error) { return Report{}, nil }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) if res, _ := get(t, srv.URL+"/updates", "x"); res.StatusCode != http.StatusUnauthorized { t.Fatalf("token error: %d", res.StatusCode) } mux2 := http.NewServeMux() Mount(mux2, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, errors.New("boom") }) srv2 := httptest.NewServer(mux2) t.Cleanup(srv2.Close) if res, _ := get(t, srv2.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("reader error: %d, want 503", res.StatusCode) } } func TestMayChangeIsAnArrayNeverNull(t *testing.T) { mux := http.NewServeMux() Mount(mux, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, nil }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) req, _ := http.NewRequest(http.MethodGet, srv.URL+"/updates", nil) req.Header.Set("Authorization", "Bearer s") res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() var raw map[string]json.RawMessage if err := json.NewDecoder(res.Body).Decode(&raw); err != nil { t.Fatal(err) } if string(raw["may_change"]) != "[]" { t.Fatalf("may_change = %s, want []", raw["may_change"]) } } type fakeReg struct { rt *registry.Route err error } func (f fakeReg) Get(string) (*registry.Route, error) { return f.rt, f.err } func TestRegistryReaderFillsFromTheRoute(t *testing.T) { rt := ®istry.Route{ Host: "a.example.com", Version: "v1.2.0", ServingVersion: "v1.1.0", UpdateSettings: `{"updates":"manual","channel":"stable","pin":"v1.1.0","control":["pin"]}`, } rep, err := RegistryReader(fakeReg{rt: rt})("a.example.com") if err != nil { t.Fatal(err) } if rep.Installed != "v1.2.0" || rep.Serving != "v1.1.0" || rep.Settings.Updates != release.UpdatesManual || rep.Settings.Pin != "v1.1.0" || len(rep.MayChange) != 1 || rep.MayChange[0] != "pin" { t.Fatalf("report: %+v", rep) } if rep.Desired != "" || rep.Reason != "" { t.Fatalf("desired/reason must stay empty until plan 4: %+v", rep) } } // An empty column is an instance with default settings (Column() writes "" // for them), and its channel is the route's: the report says which channel // the instance follows, and the stored column stays "". func TestRegistryReaderReadsAnEmptyColumnAsAuto(t *testing.T) { rt := ®istry.Route{Host: "a.example.com", Version: "v1", Channel: "beta"} rep, err := RegistryReader(fakeReg{rt: rt})("a.example.com") if err != nil || rep.Settings.Updates != release.UpdatesAuto || rep.Installed != "v1" { t.Fatalf("%+v, %v", rep, err) } if rep.Settings.Channel != "beta" { t.Fatalf("channel of an empty column = %q, want the route's %q", rep.Settings.Channel, "beta") } if rt.UpdateSettings != "" { t.Fatalf("the reader wrote the column: %q", rt.UpdateSettings) } } // A set column is reconcile's effective copy and names its own channel, so // it wins over the route's; a set column with no channel falls back to the // route's. func TestRegistryReaderChannelPrefersTheColumn(t *testing.T) { rep, err := RegistryReader(fakeReg{rt: ®istry.Route{Host: "a.example.com", Channel: "stable", UpdateSettings: `{"updates":"manual","channel":"beta"}`}})("a.example.com") if err != nil || rep.Settings.Channel != "beta" { t.Fatalf("column beta, route stable: %+v, %v", rep, err) } rep, err = RegistryReader(fakeReg{rt: ®istry.Route{Host: "a.example.com", Channel: "stable", UpdateSettings: `{"updates":"manual"}`}})("a.example.com") if err != nil || rep.Settings.Channel != "stable" { t.Fatalf("column with no channel, route stable: %+v, %v", rep, err) } } // A host the registry does not know, or cannot read, is an error: the verb // answers 503, never a 200 with an empty report. func TestRegistryReaderErrorsForNoRouteOrAReadError(t *testing.T) { if _, err := RegistryReader(fakeReg{})("gone.example.com"); err == nil { t.Fatal("nil route, nil error: want an error") } if _, err := RegistryReader(fakeReg{err: errors.New("db")})("a.example.com"); err == nil { t.Fatal("read error: want an error") } if _, err := RegistryReader(fakeReg{rt: ®istry.Route{UpdateSettings: `{"updates":"bogus"}`}})("a.example.com"); err == nil { t.Fatal("corrupt column: want an error") } mux := http.NewServeMux() Mount(mux, "gone.example.com", func() (string, error) { return "s", nil }, RegistryReader(fakeReg{})) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) if res, _ := get(t, srv.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("unknown host over the wire: %d, want 503", res.StatusCode) } }