compat: SemVer version windows between app and server
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- GET /v1/profile is never gated. It is where a refused client learns which
version it needs; gating it leaves the user with a network error instead of
an answer, which is precisely the confusion this exists to remove.
- An unparseable or absent version is "unknown", and is allowed. Development
builds report "dev", and a client too old to send the header cannot be
identified anyway.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -0,0 +1,113 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user