carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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
22func 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
33func 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.
53func 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.
61func 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
75func 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
105func 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
142func 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
161func 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.
181func 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.
204func 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.
216func 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
248func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH }
249
250func 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