// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package control import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/store" ) func rangeOrDie(t *testing.T, min, max string) compat.Range { t.Helper() r, err := compat.ParseRange(min, max) if err != nil { t.Fatal(err) } return r } func TestGateRefusesOutOfRangeApps(t *testing.T) { s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")} reached := false h := s.requireCompatibleApp(func(w http.ResponseWriter, _ *http.Request) { reached = true w.WriteHeader(http.StatusOK) }) for _, tc := range []struct { version string wantCode int wantThru bool }{ {"0.2.0", http.StatusOK, true}, // exactly the minimum is in range {"0.9.9", http.StatusOK, true}, {"0.1.9", http.StatusUpgradeRequired, false}, // too old {"1.0.0", http.StatusUpgradeRequired, false}, // maximum is exclusive {"2.0.0", http.StatusUpgradeRequired, false}, // too new {"", http.StatusOK, true}, // unknown: allowed, see below {"dev", http.StatusOK, true}, // development build } { reached = false req := httptest.NewRequest("GET", "/v1/sessions", nil) if tc.version != "" { req.Header.Set(AppVersionHeader, tc.version) } rec := httptest.NewRecorder() h(rec, req) if rec.Code != tc.wantCode || reached != tc.wantThru { t.Errorf("app %q: got code=%d reached=%v, want code=%d reached=%v", tc.version, rec.Code, reached, tc.wantCode, tc.wantThru) } } } // A refusal that does not say what version to install is only marginally better than a timeout. func TestRefusalNamesTheAcceptedWindow(t *testing.T) { s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")} rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/v1/runs", nil) req.Header.Set(AppVersionHeader, "0.1.0") s.requireCompatibleApp(func(http.ResponseWriter, *http.Request) {})(rec, req) var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("refusal body is not JSON: %v", err) } for _, key := range []string{"error", "app_version", "accepts_app", "protocol_version"} { if body[key] == nil || body[key] == "" { t.Errorf("refusal omits %q, so the client cannot explain itself: %v", key, body) } } if !strings.Contains(body["accepts_app"].(string), "0.2.0") { t.Errorf("accepts_app should state the minimum: %v", body["accepts_app"]) } } // The profile is how a refused client learns which version it needs. Gating it would leave the // user with a network error instead of an answer, which defeats the whole check. func TestProfileIsReachableRegardlessOfVersion(t *testing.T) { st, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } s := &Server{ AppRange: rangeOrDie(t, "9.0.0", ""), // nothing current could satisfy this Store: st, } req := httptest.NewRequest("GET", "/v1/profile", nil) req.Header.Set(AppVersionHeader, "0.1.0") rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) // The request carries no credential, so the handler answers 401 — the point is that it is // the *handler* answering, not the version gate turning it into 426. if rec.Code == http.StatusUpgradeRequired { t.Fatal("the profile endpoint must never be gated on app version") } } func TestZeroValueRangeFallsBackToTheBuiltInDefault(t *testing.T) { s := &Server{} // nothing configured got := s.appRange() want := DefaultAppRange() if got.Min != want.Min || got.HasMax != want.HasMax || got.Max != want.Max { t.Fatalf("appRange() = %s, want the built-in default %s", got, want) } }