package main import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/carlosframework/platform/internal/version" ) func TestReleaseBuild(t *testing.T) { for tag, want := range map[string]bool{ "v0.7.0": true, "v10.2.33": true, "dev": false, "0.7.0": false, "v0.7": false, "v0.7.0-1": false, "": false, } { if got := releaseBuild(tag); got != want { t.Errorf("releaseBuild(%q) = %v, want %v", tag, got, want) } } } func TestNewerVersion(t *testing.T) { for _, tc := range []struct { latest, current string want bool }{ {"v0.8.0", "v0.7.0", true}, {"v1.0.0", "v0.9.9", true}, {"v0.7.0", "v0.7.0", false}, {"v0.6.0", "v0.7.0", false}, {"garbage", "v0.7.0", false}, // a garbled feed never nags {"v0.8.0", "dev", false}, } { if got := newerVersion(tc.latest, tc.current); got != tc.want { t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want) } } } // useVersion pins the build stamp for one test — it is a package var // precisely so release-only behavior is testable from a dev build. func useVersion(t *testing.T, v string) { t.Helper() old := version.Build version.Build = v t.Cleanup(func() { version.Build = old }) } // latestServer serves latest.txt with the given tag and counts fetches. func latestServer(t *testing.T, tag string) *int { t.Helper() hits := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ fmt.Fprintln(w, tag) })) t.Cleanup(ts.Close) oldURL := updateLatestURL updateLatestURL = ts.URL t.Cleanup(func() { updateLatestURL = oldURL }) return &hits } func TestUpdateHintShowsOncePerDay(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) out := captureStdout(t) forceTerminal(t, true) useVersion(t, "v0.7.0") hits := latestServer(t, "v0.8.0") maybeUpdateHint("ship") if !strings.Contains(out.String(), "carlos v0.8.0 is available (you have v0.7.0) — run: carlos update") { t.Fatalf("no hint:\n%s", out.String()) } // Within the cache window: no second fetch, no second hint. out.Reset() maybeUpdateHint("ship") if *hits != 1 || out.String() != "" { t.Fatalf("second run: hits=%d out=%q, want cached silence", *hits, out.String()) } // An expired cache checks again. path, _ := updateCachePath() stale := fmt.Sprintf(`{"checked_at": %q, "latest": "v0.8.0"}`, time.Now().Add(-25*time.Hour).Format(time.RFC3339)) if err := os.WriteFile(path, []byte(stale), 0o600); err != nil { t.Fatal(err) } maybeUpdateHint("ship") if *hits != 2 { t.Fatalf("hits after stale cache = %d, want 2", *hits) } } func TestUpdateHintGates(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) out := captureStdout(t) forceTerminal(t, true) useVersion(t, "v0.7.0") hits := latestServer(t, "v0.8.0") // Each gate must keep the network untouched, not merely the output. t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-box-bucket") maybeUpdateHint("ship") t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") t.Setenv("CARLOS_NO_UPDATE_CHECK", "1") maybeUpdateHint("ship") t.Setenv("CARLOS_NO_UPDATE_CHECK", "") writeConfig(t, ".", `{"update_check": "off"}`) maybeUpdateHint("ship") os.RemoveAll(".carlos") maybeUpdateHint("edge") // box verb maybeUpdateHint("version") // parsed output maybeUpdateHint("update") // the verb itself forceTerminal(t, false) maybeUpdateHint("ship") forceTerminal(t, true) useVersion(t, "dev") maybeUpdateHint("ship") if *hits != 0 || out.String() != "" { t.Fatalf("gated runs: hits=%d out=%q, want zero of both", *hits, out.String()) } } func TestUpdateHintUpToDateIsSilent(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) out := captureStdout(t) forceTerminal(t, true) useVersion(t, "v0.8.0") hits := latestServer(t, "v0.8.0") maybeUpdateHint("ship") if *hits != 1 || out.String() != "" { t.Fatalf("up to date: hits=%d out=%q, want one silent check", *hits, out.String()) } // And the silent check still stamped the cache. path, _ := updateCachePath() if _, err := os.Stat(path); err != nil { t.Fatalf("cache not written on the up-to-date path: %v", err) } } func TestInstallMethod(t *testing.T) { for path, want := range map[string]string{ "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos": "brew", "/home/linuxbrew/.linuxbrew/Cellar/carlos/x/carlos": "brew", "/home/tom/bin/carlos": "binary", } { if got := installMethod(path); got != want { t.Errorf("installMethod(%q) = %q, want %q", path, got, want) } } // The dpkg branch depends on the host's dpkg database, so it is not // asserted here — /usr/bin/carlos on a non-deb box is still "binary". } // Windows has no package manager in this story and no working // self-replace — a running .exe cannot be renamed over — so every Windows // install is the zip download, whatever the path looks like. The check // comes FIRST: a path containing /Cellar/ on Windows is a coincidence, not // a brew install, and handing such a user `brew upgrade` would be a dead // end rather than a smaller inconvenience. func TestInstallMethodWindowsIsZip(t *testing.T) { old := updateGOOS updateGOOS = "windows" t.Cleanup(func() { updateGOOS = old }) for _, path := range []string{ `C:\Users\tom\bin\carlos.exe`, `C:\Program Files\carlos\carlos.exe`, "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos", } { if got := installMethod(path); got != "zip" { t.Errorf("installMethod(%q) on windows = %q, want %q", path, got, "zip") } } updateGOOS = old if got := installMethod("/home/tom/bin/carlos"); got != "binary" { t.Errorf("installMethod off windows = %q, want %q", got, "binary") } } // The Windows zip's name is a contract with the release recipe, not a // cosmetic string: `carlos update` prints this exact URL to a user who then // has to find the file on the release. Pinned here so renaming the artifact // on one side without the other fails a test instead of a download. func TestWindowsAssetName(t *testing.T) { old := updateGOOS updateGOOS = "windows" t.Cleanup(func() { updateGOOS = old }) want := "carlos-windows-" + runtime.GOARCH + ".zip" if got := windowsAsset(); got != want { t.Errorf("windowsAsset() = %q, want %q", got, want) } } // releaseServer serves a fake carlosframework/releases download endpoint: // a binary, its SHA256SUMS.txt, and a signature by the given key. func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) { t.Helper() sig := ed25519.Sign(priv, []byte(sums)) if breakSig { sig[0] ^= 0xff } asset := "carlos-" + goosArch() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Downloads are tag-pinned: /vX.Y.Z/, never a floating // "latest" alias — anything else here is a client bug. if !strings.HasPrefix(r.URL.Path, "/v0.8.0/") { t.Errorf("un-pinned download path %q", r.URL.Path) http.NotFound(w, r) return } switch strings.TrimPrefix(r.URL.Path, "/v0.8.0/") { case "SHA256SUMS.txt": w.Write([]byte(sums)) case "SHA256SUMS.txt.sig": w.Write([]byte(base64.StdEncoding.EncodeToString(sig))) case asset: w.Write(binary) default: http.NotFound(w, r) } })) t.Cleanup(ts.Close) oldBase := updateDownloadBase updateDownloadBase = ts.URL t.Cleanup(func() { updateDownloadBase = oldBase }) } func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH } func TestSelfReplace(t *testing.T) { useTempCredentials(t) pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } oldKey := cliUpdatePubKey cliUpdatePubKey = base64.StdEncoding.EncodeToString(pub) t.Cleanup(func() { cliUpdatePubKey = oldKey }) newBinary := []byte("#!/bin/sh\necho carlos v0.8.0\n") sum := sha256.Sum256(newBinary) asset := fmt.Sprintf("carlos-%s", goosArch()) sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset) exe := filepath.Join(t.TempDir(), "carlos") if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { t.Fatal(err) } // A broken signature refuses before anything is downloaded or touched. releaseServer(t, newBinary, sums, priv, true) if err := selfReplace(exe, "v0.8.0"); err == nil || !strings.Contains(err.Error(), "signature does not verify") { t.Fatalf("broken sig = %v, want the signature refusal", err) } if got, _ := os.ReadFile(exe); string(got) != "old" { t.Fatal("the binary was touched despite a bad signature") } // A checksum mismatch refuses too. releaseServer(t, []byte("not what was signed"), sums, priv, false) if err := selfReplace(exe, "v0.8.0"); err == nil || !strings.Contains(err.Error(), "does not match its signed checksum") { t.Fatalf("bad sum = %v, want the checksum refusal", err) } // The happy path replaces atomically and keeps 0755. releaseServer(t, newBinary, sums, priv, false) if err := selfReplace(exe, "v0.8.0"); err != nil { t.Fatal(err) } got, err := os.ReadFile(exe) if err != nil || string(got) != string(newBinary) { t.Fatalf("replaced binary = %q, %v", got, err) } fi, err := os.Stat(exe) if err != nil || fi.Mode().Perm() != 0o755 { t.Fatalf("replaced mode = %v, %v; want 0755", fi.Mode(), err) } }