// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package updater import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func TestCompareVersions(t *testing.T) { cases := []struct { a, b string want int }{ {"0.6.0", "0.6.0", 0}, {"0.7.0", "0.6.0", 1}, {"0.6.0", "0.7.0", -1}, {"1.0.0", "0.9.9", 1}, {"0.6.1", "0.6", 1}, {"0.6", "0.6.0", 0}, {"0.10.0", "0.9.0", 1}, {"1.2.3-rc1", "1.2.3", 0}, // suffix ignored per part } for _, tc := range cases { if got := CompareVersions(tc.a, tc.b); got != tc.want { t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) } } } func TestCheckLatestParsesTag(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.2.3"}) })) defer srv.Close() old := ReleaseBase ReleaseBase = srv.URL defer func() { ReleaseBase = old }() latest, err := CheckLatest() if err != nil || latest != "1.2.3" { t.Fatalf("CheckLatest = %q, %v", latest, err) } if got := UpdateAvailable("1.2.2"); got != "1.2.3" { t.Fatalf("UpdateAvailable = %q", got) } if got := UpdateAvailable("1.2.3"); got != "" { t.Fatalf("UpdateAvailable(current) = %q", got) } if got := UpdateAvailable("2.0.0"); got != "" { t.Fatalf("UpdateAvailable(newer local) = %q", got) } } func TestCheckLatestErrors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() old := ReleaseBase ReleaseBase = srv.URL defer func() { ReleaseBase = old }() if _, err := CheckLatest(); err == nil { t.Fatal("want error on HTTP 500") } } func TestSelfUpdateURLsIncludeRepoPath(t *testing.T) { var paths []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) switch { case strings.HasSuffix(r.URL.Path, "/releases/latest"): _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"}) case strings.HasSuffix(r.URL.Path, "checksums.txt"): _, _ = w.Write([]byte("0000000000000000000000000000000000000000000000000000000000000000 volumen-9.9.9-" + goos() + "-" + goarch() + "\n")) default: _, _ = w.Write([]byte("binary")) } })) defer srv.Close() old := ReleaseBase ReleaseBase = srv.URL defer func() { ReleaseBase = old }() // SelfUpdate downloads and then fails on the checksum; the point of // this test is the request paths, not the outcome. _, _ = SelfUpdate("0.0.1") want := RepoSlug + "/releases/download/v9.9.9/" downloads := 0 for _, path := range paths { if !strings.Contains(path, "/releases/download/") { continue // the /api/... release check uses its own path shape } downloads++ if !strings.Contains(path, want) { t.Fatalf("download path %q does not contain %q", path, want) } } if downloads < 2 { t.Fatalf("expected checksums and binary downloads, saw %d download requests", downloads) } } // The self-update tail replaces the running binary, so it is exercised // through the two seams rather than by actually exec-ing anything. func TestSelfUpdateStagesAndExecs(t *testing.T) { binary := []byte("#!/bin/sh\necho replaced\n") sum := sha256.Sum256(binary) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/releases/latest"): _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"}) case strings.HasSuffix(r.URL.Path, "checksums.txt"): fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", hex.EncodeToString(sum[:]), goos(), goarch()) default: _, _ = w.Write(binary) } })) defer srv.Close() oldBase := ReleaseBase ReleaseBase = srv.URL defer func() { ReleaseBase = oldBase }() dir := t.TempDir() exe := filepath.Join(dir, "volumen") if err := os.WriteFile(exe, []byte("old binary"), 0o755); err != nil { t.Fatalf("write: %v", err) } oldExecutable, oldExec := executable, syscallExec executable = func() (string, error) { return exe, nil } var gotPath string var gotArgv []string syscallExec = func(path string, argv []string) error { gotPath, gotArgv = path, argv return errors.New("stop here") } t.Cleanup(func() { executable, syscallExec = oldExecutable, oldExec }) _, err := SelfUpdate("0.0.1") if err == nil || !strings.Contains(err.Error(), "stop here") { t.Fatalf("SelfUpdate = %v, want the seam's error", err) } if gotPath != exe { t.Fatalf("exec path = %q, want %q", gotPath, exe) } if len(gotArgv) == 0 || gotArgv[0] != exe { t.Fatalf("argv = %v", gotArgv) } staged, err := os.ReadFile(exe) if err != nil { t.Fatalf("read the replaced binary: %v", err) } if string(staged) != string(binary) { t.Fatalf("the binary was not replaced: %q", staged) } info, err := os.Stat(exe) if err != nil { t.Fatalf("stat: %v", err) } if info.Mode().Perm() != 0o755 { t.Fatalf("mode = %v, want 0755", info.Mode().Perm()) } } func TestSelfUpdateRejectsAChecksumMismatch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/releases/latest"): _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"}) case strings.HasSuffix(r.URL.Path, "checksums.txt"): fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", strings.Repeat("0", 64), goos(), goarch()) default: _, _ = w.Write([]byte("binary")) } })) defer srv.Close() old := ReleaseBase ReleaseBase = srv.URL defer func() { ReleaseBase = old }() exe := filepath.Join(t.TempDir(), "volumen") if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { t.Fatalf("write: %v", err) } oldExecutable := executable executable = func() (string, error) { return exe, nil } t.Cleanup(func() { executable = oldExecutable }) if _, err := SelfUpdate("0.0.1"); err == nil || !strings.Contains(err.Error(), "checksum") { t.Fatalf("SelfUpdate = %v, want a checksum failure", err) } } func TestCompareVersionsClampsLongComponents(t *testing.T) { // A component wider than an int must compare, not overflow. huge := strings.Repeat("9", 30) if CompareVersions(huge, "1.0.0") <= 0 { t.Fatal("a very large component did not compare as newer") } if CompareVersions("1.0.0", huge) >= 0 { t.Fatal("a very large component did not compare as newer") } }