| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "fmt" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "runtime" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/version" |
| 20 | ) |
| 21 | |
| 22 | func TestReleaseBuild(t *testing.T) { |
| 23 | for tag, want := range map[string]bool{ |
| 24 | "v0.7.0": true, "v10.2.33": true, |
| 25 | "dev": false, "0.7.0": false, "v0.7": false, "v0.7.0-1": false, "": false, |
| 26 | } { |
| 27 | if got := releaseBuild(tag); got != want { |
| 28 | t.Errorf("releaseBuild(%q) = %v, want %v", tag, got, want) |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestNewerVersion(t *testing.T) { |
| 34 | for _, tc := range []struct { |
| 35 | latest, current string |
| 36 | want bool |
| 37 | }{ |
| 38 | {"v0.8.0", "v0.7.0", true}, |
| 39 | {"v1.0.0", "v0.9.9", true}, |
| 40 | {"v0.7.0", "v0.7.0", false}, |
| 41 | {"v0.6.0", "v0.7.0", false}, |
| 42 | {"garbage", "v0.7.0", false}, // a garbled feed never nags |
| 43 | {"v0.8.0", "dev", false}, |
| 44 | } { |
| 45 | if got := newerVersion(tc.latest, tc.current); got != tc.want { |
| 46 | t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // useVersion pins the build stamp for one test — it is a package var |
| 52 | // precisely so release-only behavior is testable from a dev build. |
| 53 | func useVersion(t *testing.T, v string) { |
| 54 | t.Helper() |
| 55 | old := version.Build |
| 56 | version.Build = v |
| 57 | t.Cleanup(func() { version.Build = old }) |
| 58 | } |
| 59 | |
| 60 | // latestServer serves latest.txt with the given tag and counts fetches. |
| 61 | func latestServer(t *testing.T, tag string) *int { |
| 62 | t.Helper() |
| 63 | hits := 0 |
| 64 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 65 | hits++ |
| 66 | fmt.Fprintln(w, tag) |
| 67 | })) |
| 68 | t.Cleanup(ts.Close) |
| 69 | oldURL := updateLatestURL |
| 70 | updateLatestURL = ts.URL |
| 71 | t.Cleanup(func() { updateLatestURL = oldURL }) |
| 72 | return &hits |
| 73 | } |
| 74 | |
| 75 | func TestUpdateHintShowsOncePerDay(t *testing.T) { |
| 76 | useTempCredentials(t) |
| 77 | clearDeploymentEnv(t) |
| 78 | out := captureStdout(t) |
| 79 | forceTerminal(t, true) |
| 80 | useVersion(t, "v0.7.0") |
| 81 | hits := latestServer(t, "v0.8.0") |
| 82 | |
| 83 | maybeUpdateHint("ship") |
| 84 | if !strings.Contains(out.String(), "carlos v0.8.0 is available (you have v0.7.0) — run: carlos update") { |
| 85 | t.Fatalf("no hint:\n%s", out.String()) |
| 86 | } |
| 87 | // Within the cache window: no second fetch, no second hint. |
| 88 | out.Reset() |
| 89 | maybeUpdateHint("ship") |
| 90 | if *hits != 1 || out.String() != "" { |
| 91 | t.Fatalf("second run: hits=%d out=%q, want cached silence", *hits, out.String()) |
| 92 | } |
| 93 | // An expired cache checks again. |
| 94 | path, _ := updateCachePath() |
| 95 | stale := fmt.Sprintf(`{"checked_at": %q, "latest": "v0.8.0"}`, time.Now().Add(-25*time.Hour).Format(time.RFC3339)) |
| 96 | if err := os.WriteFile(path, []byte(stale), 0o600); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | maybeUpdateHint("ship") |
| 100 | if *hits != 2 { |
| 101 | t.Fatalf("hits after stale cache = %d, want 2", *hits) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestUpdateHintGates(t *testing.T) { |
| 106 | useTempCredentials(t) |
| 107 | clearDeploymentEnv(t) |
| 108 | out := captureStdout(t) |
| 109 | forceTerminal(t, true) |
| 110 | useVersion(t, "v0.7.0") |
| 111 | hits := latestServer(t, "v0.8.0") |
| 112 | |
| 113 | // Each gate must keep the network untouched, not merely the output. |
| 114 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-box-bucket") |
| 115 | maybeUpdateHint("ship") |
| 116 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| 117 | |
| 118 | t.Setenv("CARLOS_NO_UPDATE_CHECK", "1") |
| 119 | maybeUpdateHint("ship") |
| 120 | t.Setenv("CARLOS_NO_UPDATE_CHECK", "") |
| 121 | |
| 122 | writeConfig(t, ".", `{"update_check": "off"}`) |
| 123 | maybeUpdateHint("ship") |
| 124 | os.RemoveAll(".carlos") |
| 125 | |
| 126 | maybeUpdateHint("edge") // box verb |
| 127 | maybeUpdateHint("version") // parsed output |
| 128 | maybeUpdateHint("update") // the verb itself |
| 129 | |
| 130 | forceTerminal(t, false) |
| 131 | maybeUpdateHint("ship") |
| 132 | forceTerminal(t, true) |
| 133 | |
| 134 | useVersion(t, "dev") |
| 135 | maybeUpdateHint("ship") |
| 136 | |
| 137 | if *hits != 0 || out.String() != "" { |
| 138 | t.Fatalf("gated runs: hits=%d out=%q, want zero of both", *hits, out.String()) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestUpdateHintUpToDateIsSilent(t *testing.T) { |
| 143 | useTempCredentials(t) |
| 144 | clearDeploymentEnv(t) |
| 145 | out := captureStdout(t) |
| 146 | forceTerminal(t, true) |
| 147 | useVersion(t, "v0.8.0") |
| 148 | hits := latestServer(t, "v0.8.0") |
| 149 | |
| 150 | maybeUpdateHint("ship") |
| 151 | if *hits != 1 || out.String() != "" { |
| 152 | t.Fatalf("up to date: hits=%d out=%q, want one silent check", *hits, out.String()) |
| 153 | } |
| 154 | // And the silent check still stamped the cache. |
| 155 | path, _ := updateCachePath() |
| 156 | if _, err := os.Stat(path); err != nil { |
| 157 | t.Fatalf("cache not written on the up-to-date path: %v", err) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestInstallMethod(t *testing.T) { |
| 162 | for path, want := range map[string]string{ |
| 163 | "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos": "brew", |
| 164 | "/home/linuxbrew/.linuxbrew/Cellar/carlos/x/carlos": "brew", |
| 165 | "/home/tom/bin/carlos": "binary", |
| 166 | } { |
| 167 | if got := installMethod(path); got != want { |
| 168 | t.Errorf("installMethod(%q) = %q, want %q", path, got, want) |
| 169 | } |
| 170 | } |
| 171 | // The dpkg branch depends on the host's dpkg database, so it is not |
| 172 | // asserted here — /usr/bin/carlos on a non-deb box is still "binary". |
| 173 | } |
| 174 | |
| 175 | // Windows has no package manager in this story and no working |
| 176 | // self-replace — a running .exe cannot be renamed over — so every Windows |
| 177 | // install is the zip download, whatever the path looks like. The check |
| 178 | // comes FIRST: a path containing /Cellar/ on Windows is a coincidence, not |
| 179 | // a brew install, and handing such a user `brew upgrade` would be a dead |
| 180 | // end rather than a smaller inconvenience. |
| 181 | func TestInstallMethodWindowsIsZip(t *testing.T) { |
| 182 | old := updateGOOS |
| 183 | updateGOOS = "windows" |
| 184 | t.Cleanup(func() { updateGOOS = old }) |
| 185 | for _, path := range []string{ |
| 186 | `C:\Users\tom\bin\carlos.exe`, |
| 187 | `C:\Program Files\carlos\carlos.exe`, |
| 188 | "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos", |
| 189 | } { |
| 190 | if got := installMethod(path); got != "zip" { |
| 191 | t.Errorf("installMethod(%q) on windows = %q, want %q", path, got, "zip") |
| 192 | } |
| 193 | } |
| 194 | updateGOOS = old |
| 195 | if got := installMethod("/home/tom/bin/carlos"); got != "binary" { |
| 196 | t.Errorf("installMethod off windows = %q, want %q", got, "binary") |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // The Windows zip's name is a contract with the release recipe, not a |
| 201 | // cosmetic string: `carlos update` prints this exact URL to a user who then |
| 202 | // has to find the file on the release. Pinned here so renaming the artifact |
| 203 | // on one side without the other fails a test instead of a download. |
| 204 | func TestWindowsAssetName(t *testing.T) { |
| 205 | old := updateGOOS |
| 206 | updateGOOS = "windows" |
| 207 | t.Cleanup(func() { updateGOOS = old }) |
| 208 | want := "carlos-windows-" + runtime.GOARCH + ".zip" |
| 209 | if got := windowsAsset(); got != want { |
| 210 | t.Errorf("windowsAsset() = %q, want %q", got, want) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // releaseServer serves a fake carlosframework/releases download endpoint: |
| 215 | // a binary, its SHA256SUMS.txt, and a signature by the given key. |
| 216 | func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) { |
| 217 | t.Helper() |
| 218 | sig := ed25519.Sign(priv, []byte(sums)) |
| 219 | if breakSig { |
| 220 | sig[0] ^= 0xff |
| 221 | } |
| 222 | asset := "carlos-" + goosArch() |
| 223 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 224 | // Downloads are tag-pinned: /vX.Y.Z/<asset>, never a floating |
| 225 | // "latest" alias — anything else here is a client bug. |
| 226 | if !strings.HasPrefix(r.URL.Path, "/v0.8.0/") { |
| 227 | t.Errorf("un-pinned download path %q", r.URL.Path) |
| 228 | http.NotFound(w, r) |
| 229 | return |
| 230 | } |
| 231 | switch strings.TrimPrefix(r.URL.Path, "/v0.8.0/") { |
| 232 | case "SHA256SUMS.txt": |
| 233 | w.Write([]byte(sums)) |
| 234 | case "SHA256SUMS.txt.sig": |
| 235 | w.Write([]byte(base64.StdEncoding.EncodeToString(sig))) |
| 236 | case asset: |
| 237 | w.Write(binary) |
| 238 | default: |
| 239 | http.NotFound(w, r) |
| 240 | } |
| 241 | })) |
| 242 | t.Cleanup(ts.Close) |
| 243 | oldBase := updateDownloadBase |
| 244 | updateDownloadBase = ts.URL |
| 245 | t.Cleanup(func() { updateDownloadBase = oldBase }) |
| 246 | } |
| 247 | |
| 248 | func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH } |
| 249 | |
| 250 | func TestSelfReplace(t *testing.T) { |
| 251 | useTempCredentials(t) |
| 252 | pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| 253 | if err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | oldKey := cliUpdatePubKey |
| 257 | cliUpdatePubKey = base64.StdEncoding.EncodeToString(pub) |
| 258 | t.Cleanup(func() { cliUpdatePubKey = oldKey }) |
| 259 | |
| 260 | newBinary := []byte("#!/bin/sh\necho carlos v0.8.0\n") |
| 261 | sum := sha256.Sum256(newBinary) |
| 262 | asset := fmt.Sprintf("carlos-%s", goosArch()) |
| 263 | sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset) |
| 264 | |
| 265 | exe := filepath.Join(t.TempDir(), "carlos") |
| 266 | if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | |
| 270 | // A broken signature refuses before anything is downloaded or touched. |
| 271 | releaseServer(t, newBinary, sums, priv, true) |
| 272 | if err := selfReplace(exe, "v0.8.0"); err == nil || |
| 273 | !strings.Contains(err.Error(), "signature does not verify") { |
| 274 | t.Fatalf("broken sig = %v, want the signature refusal", err) |
| 275 | } |
| 276 | if got, _ := os.ReadFile(exe); string(got) != "old" { |
| 277 | t.Fatal("the binary was touched despite a bad signature") |
| 278 | } |
| 279 | |
| 280 | // A checksum mismatch refuses too. |
| 281 | releaseServer(t, []byte("not what was signed"), sums, priv, false) |
| 282 | if err := selfReplace(exe, "v0.8.0"); err == nil || |
| 283 | !strings.Contains(err.Error(), "does not match its signed checksum") { |
| 284 | t.Fatalf("bad sum = %v, want the checksum refusal", err) |
| 285 | } |
| 286 | |
| 287 | // The happy path replaces atomically and keeps 0755. |
| 288 | releaseServer(t, newBinary, sums, priv, false) |
| 289 | if err := selfReplace(exe, "v0.8.0"); err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | got, err := os.ReadFile(exe) |
| 293 | if err != nil || string(got) != string(newBinary) { |
| 294 | t.Fatalf("replaced binary = %q, %v", got, err) |
| 295 | } |
| 296 | fi, err := os.Stat(exe) |
| 297 | if err != nil || fi.Mode().Perm() != 0o755 { |
| 298 | t.Fatalf("replaced mode = %v, %v; want 0755", fi.Mode(), err) |
| 299 | } |
| 300 | } |
| 301 | |