package main import ( "encoding/json" "net/http" "net/url" "os" "path/filepath" "strings" "testing" ) func TestReleaseOfAndChannelChoices(t *testing.T) { if got := releaseOf("26.11.20261027.31ecbac"); got != "26.11" { t.Fatalf("releaseOf: %q", got) } if got := releaseOf("26.11pre-git"); got != "26.11" { t.Fatalf("releaseOf: %q", got) } values := func(cs []channelChoice) string { var v []string for _, c := range cs { v = append(v, c.Value) } return strings.Join(v, ",") } if got := values(channelChoices("")); got != ",unstable,26.05" { t.Fatalf("choices: %q", got) } // A configured channel that is not offered (a release that is gone) is not lost. if got := values(channelChoices("25.11")); got != ",unstable,26.05,25.11" { t.Fatalf("choices: %q", got) } if got := values(channelChoices("unstable")); got != ",unstable,26.05" { t.Fatalf("choices: %q", got) } } func TestSetChannelKeepsTheRest(t *testing.T) { a, sys := newTestApp(t) os.WriteFile(sys.confirmedPath(), []byte(sample), 0o600) if err := a.sys.setChannel("26.11"); err != nil { t.Fatal(err) } for _, path := range []string{sys.settingsPath(), sys.confirmedPath()} { s, err := loadSettings(path) if err != nil || s.channel() != "26.11" { t.Fatalf("%s: %v %q", path, err, s.channel()) } if str(s, "router", "wan", "interface") == "" { t.Fatalf("%s lost its other settings", path) } } if st := a.sys.Status(); st.Unapplied { t.Fatal("the channel shows up as an unapplied change") } if err := a.sys.setChannel(""); err != nil { t.Fatal(err) } s, _ := loadSettings(sys.settingsPath()) if _, ok := obj(s, "router")["update"]; ok { t.Fatalf("an empty update object is left behind: %v", s["router"]) } } func TestUpgradePage(t *testing.T) { a, sys := newTestApp(t) a.sys.VersionFile = filepath.Join(t.TempDir(), "nixos-version") os.WriteFile(a.sys.VersionFile, []byte("26.11.20261027.31ecbac\n"), 0o600) var started int orig := startUnit startUnit = func(unit string) error { if unit != "router-upgrade.service" { t.Errorf("started %s", unit) } started++ return nil } defer func() { startUnit = orig }() h := a.routes() cookie := login(t, h) page := func() string { return do(h, "GET", "/upgrade", nil, cookie).Body.String() } post := func(path string, form url.Values) *http.Response { form.Set("csrf", csrfFrom(t, page())) return do(h, "POST", path, form, cookie).Result() } request := func() map[string]any { var v map[string]any data, err := os.ReadFile(filepath.Join(a.sys.upgradeDir(), "request.json")) if err != nil { return nil } json.Unmarshal(data, &v) os.Remove(filepath.Join(a.sys.upgradeDir(), "request.json")) return v } body := page() if !strings.Contains(body, "26.11.20261027.31ecbac") || !strings.Contains(body, "Choose a channel") { t.Fatalf("no channel yet:\n%s", body) } if rec := do(h, "GET", "/upgrade", nil, nil); rec.Code != http.StatusSeeOther { t.Fatalf("without a session: %d", rec.Code) } t.Run("steps need a channel", func(t *testing.T) { resp := post("/upgrade/check", url.Values{}) if !strings.Contains(resp.Header.Get("Location"), "Choose+a+channel") || started != 0 { t.Fatalf("%s started %d", resp.Header.Get("Location"), started) } }) t.Run("the channel is validated and saved", func(t *testing.T) { resp := post("/upgrade/channel", url.Values{"channel": {"nixos-26.11; rm -rf /"}}) if !strings.Contains(resp.Header.Get("Location"), "Not+saved") { t.Fatalf("%s", resp.Header.Get("Location")) } // No field at all must not clear it either. if resp := post("/upgrade/channel", url.Values{}); !strings.Contains(resp.Header.Get("Location"), "no+channel") { t.Fatalf("%s", resp.Header.Get("Location")) } post("/upgrade/channel", url.Values{"channel": {"26.05"}}) post("/upgrade/channel", url.Values{}) s, _ := loadSettings(sys.settingsPath()) if s.channel() != "26.05" { t.Fatalf("channel %q", s.channel()) } if !strings.Contains(page(), `value="26.05" selected`) { t.Fatal("the saved channel is not selected") } }) t.Run("a step is requested and an unknown one refused", func(t *testing.T) { post("/upgrade/check", url.Values{}) if r := request(); r["action"] != "check" || started != 1 { t.Fatalf("%v started %d", r, started) } post("/upgrade/preview", url.Values{"force": {"on"}}) if r := request(); r["action"] != "preview" || r["force"] != true { t.Fatalf("%v", r) } if resp := post("/upgrade/format-disk", url.Values{}); resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown step: %d", resp.StatusCode) } if request() != nil { t.Fatal("an unknown step left a request") } }) t.Run("download and apply wait for a preview", func(t *testing.T) { before := started for _, step := range []string{"download", "apply"} { resp := post("/upgrade/"+step, url.Values{}) if !strings.Contains(resp.Header.Get("Location"), "Preview+first") || request() != nil { t.Fatalf("%s: %s", step, resp.Header.Get("Location")) } } if started != before { t.Fatal("the unit was started without a preview") } os.MkdirAll(a.sys.upgradeDir(), 0o700) os.WriteFile(filepath.Join(a.sys.upgradeDir(), "preview.json"), []byte(`{"channel":"26.05","rev":"0123456789abcdef0123456789abcdef01234567","version":"26.11.20261110.0123456","time":"2026-11-10T10:00:00Z","download":"(350.5 MiB download, 1.2 GiB unpacked)","builds":3,"rebootNeeded":true,"changes":[{"name":"frr","from":"10.1","to":"10.2"},{"name":"linux","from":"6.12.5","to":"6.12.8"}]}`), 0o600) body := page() for _, want := range []string{"frr", "10.1", "10.2", "linux", "350.5 MiB", "3 derivations", "after the next reboot", "Upgrade to 26.11.20261110.0123456"} { if !strings.Contains(body, want) { t.Errorf("the preview does not show %q", want) } } post("/upgrade/apply", url.Values{}) if r := request(); r["action"] != "apply" { t.Fatalf("%v", r) } }) t.Run("the last run's output is shown", func(t *testing.T) { os.WriteFile(filepath.Join(a.sys.upgradeDir(), "last.log"), []byte("channel 26.11 (nixos-26.11)\nup to date\n"), 0o600) if body := page(); !strings.Contains(body, "Last run") || !strings.Contains(body, "nixos-26.11)") { t.Fatalf("no log:\n%s", body) } if got := tail("a\nbb\nccc\n", 6); got != "ccc\n" { t.Fatalf("tail: %q", got) } }) t.Run("a preview of another channel is not shown", func(t *testing.T) { post("/upgrade/channel", url.Values{"channel": {"unstable"}}) if strings.Contains(page(), "linux") { t.Fatal("the preview of 26.05 is shown for unstable") } }) t.Run("a check from before the last upgrade is not shown", func(t *testing.T) { post("/upgrade/channel", url.Values{"channel": {"26.05"}}) check := `{"channel":"26.05","branch":"nixos-26.05","time":"2026-11-09T10:00:00Z","running":{"rev":"aaa","version":"%s"},"newest":{"rev":"0123456789abcdef"},"upToDate":false}` os.WriteFile(filepath.Join(a.sys.upgradeDir(), "check.json"), []byte(strings.Replace(check, "%s", "26.11.20261001.old", 1)), 0o600) if strings.Contains(page(), "A newer revision is available") { t.Fatal("an outdated check is shown") } os.WriteFile(filepath.Join(a.sys.upgradeDir(), "check.json"), []byte(strings.Replace(check, "%s", "26.11.20261027.31ecbac", 1)), 0o600) if body := page(); !strings.Contains(body, "A newer revision is available") || !strings.Contains(body, "0123456") { t.Fatalf("the current check is not shown:\n%s", body) } }) }