package api import ( "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/config" serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/store" ) func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) { server := testServer(config.Config{}) server.updatePolicy.set(appupdate.Policy{ Enabled: true, LatestVersion: "0.3.0", MinimumVersion: "0.2.9", DownloadURL: "/updates/memby-0.3.0.apk?token=signed", }) server.maintenance.set(store.Maintenance{Enabled: true, Message: "Deploying"}) req := httptest.NewRequest(http.MethodGet, "/v1/update", nil) req.Header.Set("X-Memby-Version", "0.2.8") rec := httptest.NewRecorder() // Deliberately no Authorization header: update policy must precede login. server.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) } var decision appupdate.Decision if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil { t.Fatalf("decode decision: %v", err) } if decision.Status != appupdate.StatusMandatory { t.Fatalf("status = %q, want mandatory", decision.Status) } } func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) { logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole) server := New(config.Config{}, Deps{Log: logger, Events: events}) server.updatePolicy.set(appupdate.Policy{ Enabled: true, LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk", }) req := httptest.NewRequest(http.MethodGet, "/v1/update", nil) req.Header.Set("X-Memby-Version", "0.2.38") req, _ = withRequestIdentity(req) identify(req.Context(), store.Session{Username: "matt", DeviceName: "Living room"}) server.handleUpdate(httptest.NewRecorder(), req) page := events.Events(0, 10) if len(page.Events) != 1 { t.Fatalf("events = %d, want 1", len(page.Events)) } event := page.Events[0] if event.Message != "update offered" || event.Attributes["user"] != "matt" { t.Fatalf("update event was not attributed to the viewer: %+v", event) } } func TestConfiguredFloorForcesClientsBelow0244(t *testing.T) { server := testServer(config.Config{}) server.updatePolicy.set(appupdate.Policy{ Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk?token=signed", }) for version, want := range map[string]string{ "0.2.43": appupdate.StatusMandatory, "0.2.44": appupdate.StatusNone, "0.2.45": appupdate.StatusNone, } { req := httptest.NewRequest(http.MethodGet, "/v1/update", nil) req.Header.Set("X-Memby-Version", version) rec := httptest.NewRecorder() server.handleUpdate(rec, req) var decision appupdate.Decision if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil { t.Fatalf("%s: decode decision: %v", version, err) } if decision.Status != want { t.Errorf("%s: status = %q, want %q", version, decision.Status, want) } } } func TestConfiguredFloorWaitsForAnActionableRelease(t *testing.T) { for name, policy := range map[string]appupdate.Policy{ "disabled": { LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk", }, "missing download": { Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", }, "release too old": { Enabled: true, LatestVersion: "0.2.43", RetireBelowVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.43.apk", }, } { t.Run(name, func(t *testing.T) { decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43") if mustRetireForUpdate(decision, "0.2.43", destructiveUpdateFloor(policy)) { t.Fatal("client would be retired without an actionable release at the configured floor") } }) } } func TestSelectedDestructiveFloorRetiresClientsBelowRelease(t *testing.T) { policy := appupdate.Policy{ Enabled: true, LatestVersion: "0.3.0", RetireBelowVersion: "0.3.0", DownloadURL: "/updates/memby-0.3.0.apk?token=signed", } for version, want := range map[string]bool{ "0.2.99": true, "0.3.0": false, "0.3.1": false, } { decision := appupdate.Decide(effectiveUpdatePolicy(policy), version) if got := mustRetireForUpdate(decision, version, destructiveUpdateFloor(policy)); got != want { t.Errorf("%s: retired = %t, want %t", version, got, want) } } } func TestRequiredUpdateDoesNotRetireSupportedClient(t *testing.T) { policy := appupdate.Policy{ Enabled: true, LatestVersion: "0.3.0", MinimumVersion: "0.3.0", DownloadURL: "/updates/memby-0.3.0.apk?token=signed", } decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.44") if decision.Status != appupdate.StatusMandatory { t.Fatalf("status = %q, want mandatory", decision.Status) } if mustRetireForUpdate(decision, "0.2.44", destructiveUpdateFloor(policy)) { t.Fatal("ordinary required update retired a supported client") } } func TestRetiredClientCannotSignBackIn(t *testing.T) { server := testServer(config.Config{}) server.updatePolicy.set(appupdate.Policy{ Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk?token=signed", }) reached := false handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) { reached = true }) req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil) req.Header.Set("X-Memby-Version", "0.2.43") rec := httptest.NewRecorder() handler(rec, req) if reached { t.Fatal("retired client reached the login handler") } if rec.Code != http.StatusUpgradeRequired { t.Fatalf("status = %d, want 426", rec.Code) } if rec.Header().Get("X-Memby-Update-Required") != "0.2.44" { t.Fatalf("required update header = %q", rec.Header().Get("X-Memby-Update-Required")) } }