Server-controlled app updates, optional or forced

The gateway decides whether a TV may keep running its current build.
Clients send X-Memby-Version on every request and ask GET /v1/update on
each launch; the verdict is none, optional or mandatory.

- internal/appupdate holds the decision as pure, tested logic: below
  minimumVersion is mandatory, below latestVersion is optional.
- Admin page gains an App updates section — latest version, APK URL,
  notes, and a "Require this update" toggle that sets the forced floor.
- Client shows a dismissable prompt for optional, and a full-screen
  panel that swallows Back for mandatory. Instructions say what the
  system installer will ask before it asks.

Two safeguards: a client that cannot report its version is never forced,
and the client ignores a verdict with no download URL, so a
half-configured policy cannot produce an unblockable screen with a dead
button. An unreachable gateway shows nothing.

The verdict is deliberately not part of /v1/home: that payload is cached
per user, while this answer depends on the requesting client's version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:34:04 +12:00
co-authored by Claude Opus 5
parent 08360b75e4
commit 62f6345a40
19 changed files with 1016 additions and 11 deletions
+118
View File
@@ -0,0 +1,118 @@
package appupdate
import "testing"
func policy() Policy {
return Policy{
Enabled: true,
LatestVersion: "0.1.54",
MinimumVersion: "0.1.50",
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
Notes: "Faster home screen",
}
}
func TestUpToDateClientIsLeftAlone(t *testing.T) {
for _, version := range []string{"0.1.54", "0.1.55", "0.2.0", "1.0.0"} {
if got := Decide(policy(), version).Status; got != StatusNone {
t.Fatalf("client %s: got %s, want none", version, got)
}
}
}
func TestBehindLatestIsOptional(t *testing.T) {
decision := Decide(policy(), "0.1.53")
if decision.Status != StatusOptional {
t.Fatalf("got %s, want optional", decision.Status)
}
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
}
}
func TestBelowMinimumIsMandatory(t *testing.T) {
if got := Decide(policy(), "0.1.49").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}
// Setting minimum = latest is how an operator forces everyone onto the current build.
func TestMinimumEqualToLatestForcesEveryOldClient(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
if got := Decide(p, "0.1.53").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
if got := Decide(p, "0.1.54").Status; got != StatusNone {
t.Fatalf("a current client should still be left alone, got %s", got)
}
}
func TestBlankMinimumKeepsUpdatesOptional(t *testing.T) {
p := policy()
p.MinimumVersion = ""
if got := Decide(p, "0.0.1").Status; got != StatusOptional {
t.Fatalf("got %s, want optional", got)
}
}
func TestDisabledPolicySaysNothing(t *testing.T) {
p := policy()
p.Enabled = false
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
func TestPolicyWithNoLatestVersionSaysNothing(t *testing.T) {
p := policy()
p.LatestVersion = " "
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
// A client that cannot report its version must not be hard-blocked: it would be stuck
// behind a prompt it may have no way to satisfy.
func TestUnknownClientVersionIsNeverForced(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
for _, version := range []string{"", " ", "?", "unknown"} {
if got := Decide(p, version).Status; got != StatusNone {
t.Fatalf("client %q: got %s, want none", version, got)
}
}
}
func TestVersionParsingIsForgiving(t *testing.T) {
p := policy()
// Leading "v", pre-release suffixes and short versions all compare sensibly.
if got := Decide(p, "v0.1.53").Status; got != StatusOptional {
t.Fatalf("v-prefixed: got %s, want optional", got)
}
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
}
p.LatestVersion = "0.2"
if got := Decide(p, "0.2.0").Status; got != StatusNone {
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
}
}
func TestMandatoryOutranksOptional(t *testing.T) {
p := policy()
p.MinimumVersion = "0.1.53"
// Below both thresholds: the stronger verdict must win.
if got := Decide(p, "0.1.52").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}