package main import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestUpdatePinSettings(t *testing.T) { ts, _ := newTestServer(t) // A pin must be a plausible version id (it becomes a store dir name). if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_pin":"../evil"}`); resp.StatusCode != http.StatusBadRequest { t.Fatalf("bad pin: want 400, got %d", resp.StatusCode) } if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_pin":"abc1234"}`); resp.StatusCode != http.StatusOK { t.Fatalf("set pin: %d", resp.StatusCode) } var prefs struct { Channel string `json:"channel"` Pin string `json:"pin"` } json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) if prefs.Pin != "abc1234" { t.Fatalf("prefs pin = %q", prefs.Pin) } var settings struct { UpdatePin string `json:"update_pin"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) if settings.UpdatePin != "abc1234" { t.Fatalf("settings pin = %q", settings.UpdatePin) } // The admin UI resumes updates by picking a channel — one combined write // that sets the channel and clears the pin. if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_channel":"beta","update_pin":""}`); resp.StatusCode != http.StatusOK { t.Fatalf("clear pin: %d", resp.StatusCode) } json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) if prefs.Pin != "" || prefs.Channel != "beta" { t.Fatalf("after clear = %+v, want no pin on beta", prefs) } } func TestUpdatePrefsDefaultsAndSettings(t *testing.T) { ts, _ := newTestServer(t) // Defaults: auto-update on, auto-download on, stable channel — un-authed, // this is what the box updater reads over the instance socket. var prefs struct { AutoUpdate bool `json:"auto_update"` AutoDownload bool `json:"auto_download"` Channel string `json:"channel"` } json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) if !prefs.AutoUpdate || !prefs.AutoDownload || prefs.Channel != "stable" { t.Fatalf("defaults = %+v, want on/on/stable", prefs) } // An unknown channel is rejected, and rejected atomically — a combined // request must not half-apply its valid fields before the 400. if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true,"update_channel":"nightly"}`); resp.StatusCode != http.StatusBadRequest { t.Fatalf("bad channel: want 400, got %d", resp.StatusCode) } var check struct { UserInvites bool `json:"user_invites"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&check) if check.UserInvites { t.Fatal("rejected request still flipped user_invites (partial write)") } // Partial update: flip auto-update off + move to edge; download untouched. if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_auto":false,"update_channel":"edge"}`); resp.StatusCode != http.StatusOK { t.Fatalf("set: %d", resp.StatusCode) } json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) if prefs.AutoUpdate || !prefs.AutoDownload || prefs.Channel != "edge" { t.Fatalf("after set = %+v, want off/on/edge", prefs) } // The admin console reads the same values (plus the availability block). var settings struct { UpdateAuto bool `json:"update_auto"` UpdateDownload bool `json:"update_download"` UpdateChannel string `json:"update_channel"` Update map[string]any `json:"update"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) if settings.UpdateAuto || !settings.UpdateDownload || settings.UpdateChannel != "edge" { t.Fatalf("settings = %+v", settings) } if settings.Update["build"] != buildVersion { t.Fatalf("update.build = %v", settings.Update["build"]) } if _, ok := settings.Update["available_version"]; ok { t.Fatal("available_version present before any manifest check") } } func TestUpdateCheckerSurfacesAvailableVersion(t *testing.T) { ts, srv := newTestServer(t) // A signed manifest on a local server, trusted via a swapped-in test key. pub, priv, _ := ed25519.GenerateKey(rand.Reader) wrapper, err := signReleaseManifest(priv, &releaseManifest{ Channels: map[string]releaseEntry{ "stable": {Version: "new1234", URL: "https://example.com/lchat", SHA256: strings.Repeat("ab", 32), Notes: "a better build"}, }, }) if err != nil { t.Fatal(err) } feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(wrapper) })) defer feed.Close() oldKey, oldURL := releasePubKeyB64, releaseManifestURL releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub) releaseManifestURL = feed.URL defer func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL }() if err := srv.checkForUpdate(); err != nil { t.Fatalf("checkForUpdate: %v", err) } var settings struct { Update struct { AvailableVersion string `json:"available_version"` AvailableNotes string `json:"available_notes"` CheckedAt int64 `json:"checked_at"` } `json:"update"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) if settings.Update.AvailableVersion != "new1234" || settings.Update.AvailableNotes != "a better build" { t.Fatalf("update block = %+v", settings.Update) } if settings.Update.CheckedAt == 0 || time.Since(time.Unix(settings.Update.CheckedAt, 0)) > time.Minute { t.Fatalf("checked_at = %d", settings.Update.CheckedAt) } // Switching channels reflects immediately from the cached manifest — and a // channel with no entry (edge here) just shows nothing available. adminPost(t, ts.URL+"/api/admin/settings", `{"update_channel":"edge"}`) var after struct { Update map[string]any `json:"update"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&after) if _, ok := after.Update["available_version"]; ok { t.Fatal("edge has no entry; available_version should be absent") } // A tampered feed must not replace the cached good manifest. wrapper[len(wrapper)-10]++ if err := srv.checkForUpdate(); err == nil { t.Fatal("tampered manifest accepted") } }