master
go 428 lines 13.6 KB
Raw
1 package commands
2
3 import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "crypto/sha512"
8 "encoding/json"
9 "fmt"
10 "net/http"
11 "net/http/httptest"
12 "runtime"
13 "testing"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
18
19 // --- SHA-512 verification ---
20 //
21 // These tests verify the integrity-checking code that protects users from
22 // tampered or corrupted downloads. A broken hash check could allow
23 // installing a malicious binary, so each failure mode must be covered.
24
25 // TestVerifySHA512 exercises the low-level hash comparison function.
26 func TestVerifySHA512(t *testing.T) {
27 t.Parallel()
28 data := []byte("hello world")
29 sum := sha512.Sum512(data)
30 validHex := fmt.Sprintf("%x", sum[:])
31
32 t.Run("accepts matching hash", func(t *testing.T) {
33 t.Parallel()
34 err := verifySHA512(data, validHex)
35 assert.NoError(t, err)
36 })
37
38 t.Run("rejects data that does not match hash", func(t *testing.T) {
39 t.Parallel()
40 err := verifySHA512([]byte("tampered"), validHex)
41 assert.ErrorContains(t, err, "SHA-512 mismatch",
42 "must reject data whose hash differs from the expected value")
43 })
44
45 t.Run("rejects malformed hex string", func(t *testing.T) {
46 t.Parallel()
47 err := verifySHA512(data, "not-valid-hex")
48 assert.ErrorContains(t, err, "invalid hex in SHA-512 checksum")
49 })
50 }
51
52 // TestDownloadAndVerifySHA512 tests the complete download-and-verify flow:
53 // fetching a .sha512 sidecar file from alongside the archive URL, parsing
54 // the standard sha512sum format ("<hex> <filename>\n"), and comparing
55 // against the archive data. This is the function called by "ipfs update install".
56 func TestDownloadAndVerifySHA512(t *testing.T) {
57 t.Parallel()
58 archiveData := []byte("fake-archive-content")
59 sum := sha512.Sum512(archiveData)
60 checksumBody := fmt.Sprintf("%x kubo_v0.41.0_linux-amd64.tar.gz\n", sum[:])
61
62 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 switch r.URL.Path {
64 case "/archive.tar.gz.sha512":
65 _, _ = w.Write([]byte(checksumBody))
66 default:
67 w.WriteHeader(http.StatusNotFound)
68 }
69 }))
70 t.Cleanup(srv.Close)
71
72 t.Run("accepts archive matching sidecar hash", func(t *testing.T) {
73 t.Parallel()
74 err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/archive.tar.gz")
75 assert.NoError(t, err)
76 })
77
78 t.Run("rejects archive with wrong content", func(t *testing.T) {
79 t.Parallel()
80 err := downloadAndVerifySHA512(t.Context(), []byte("tampered"), srv.URL+"/archive.tar.gz")
81 assert.ErrorContains(t, err, "SHA-512 mismatch",
82 "must hard-fail when downloaded archive doesn't match the published checksum")
83 })
84
85 t.Run("fails when sidecar file is missing", func(t *testing.T) {
86 t.Parallel()
87 err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/no-such-file.tar.gz")
88 assert.ErrorContains(t, err, "downloading checksum file",
89 "must fail if the .sha512 sidecar can't be fetched")
90 })
91 }
92
93 // --- GitHub API layer ---
94
95 // TestGitHubGet verifies the low-level GitHub API helper that adds
96 // authentication headers and translates HTTP errors into actionable
97 // messages (especially rate-limit hints for unauthenticated users).
98 func TestGitHubGet(t *testing.T) {
99 t.Parallel()
100
101 t.Run("sets Accept and User-Agent headers", func(t *testing.T) {
102 t.Parallel()
103 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept"),
105 "must request GitHub's v3 JSON format")
106 assert.Contains(t, r.Header.Get("User-Agent"), "kubo/",
107 "User-Agent must identify the kubo version for debugging")
108 _, _ = w.Write([]byte("{}"))
109 }))
110 t.Cleanup(srv.Close)
111
112 resp, err := githubGet(t.Context(), srv.URL)
113 require.NoError(t, err)
114 resp.Body.Close()
115 })
116
117 t.Run("returns rate-limit error on HTTP 403", func(t *testing.T) {
118 t.Parallel()
119 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
120 w.WriteHeader(http.StatusForbidden)
121 }))
122 t.Cleanup(srv.Close)
123
124 _, err := githubGet(t.Context(), srv.URL)
125 assert.ErrorContains(t, err, "rate limit exceeded")
126 })
127
128 t.Run("returns rate-limit error on HTTP 429", func(t *testing.T) {
129 t.Parallel()
130 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131 w.WriteHeader(http.StatusTooManyRequests)
132 }))
133 t.Cleanup(srv.Close)
134
135 _, err := githubGet(t.Context(), srv.URL)
136 assert.ErrorContains(t, err, "rate limit exceeded")
137 })
138
139 t.Run("returns HTTP status on server error", func(t *testing.T) {
140 t.Parallel()
141 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
142 w.WriteHeader(http.StatusInternalServerError)
143 }))
144 t.Cleanup(srv.Close)
145
146 _, err := githubGet(t.Context(), srv.URL)
147 assert.ErrorContains(t, err, "HTTP 500")
148 })
149 }
150
151 // TestGitHubListReleases verifies that release listing correctly filters
152 // prereleases and respects the count limit. Uses a mock GitHub API server
153 // to avoid network dependencies and rate limits in CI.
154 //
155 // Not parallel: temporarily overrides the package-level githubReleaseFmt var.
156 func TestGitHubListReleases(t *testing.T) {
157 allReleases := []ghRelease{
158 {TagName: "v0.42.0-rc1", Prerelease: true},
159 {TagName: "v0.41.0"},
160 {TagName: "v0.40.0"},
161 }
162 body, err := json.Marshal(allReleases)
163 require.NoError(t, err)
164
165 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
166 _, _ = w.Write(body)
167 }))
168 t.Cleanup(srv.Close)
169
170 saved := githubReleaseFmt
171 githubReleaseFmt = srv.URL
172 t.Cleanup(func() { githubReleaseFmt = saved })
173
174 t.Run("excludes prereleases by default", func(t *testing.T) {
175 got, err := githubListReleases(t.Context(), 10, false)
176 require.NoError(t, err)
177 assert.Len(t, got, 2, "the rc1 prerelease should be filtered out")
178 assert.Equal(t, "v0.41.0", got[0].TagName)
179 assert.Equal(t, "v0.40.0", got[1].TagName)
180 })
181
182 t.Run("includes prereleases when requested", func(t *testing.T) {
183 got, err := githubListReleases(t.Context(), 10, true)
184 require.NoError(t, err)
185 assert.Len(t, got, 3)
186 assert.Equal(t, "v0.42.0-rc1", got[0].TagName)
187 })
188
189 t.Run("respects count limit", func(t *testing.T) {
190 got, err := githubListReleases(t.Context(), 1, false)
191 require.NoError(t, err)
192 assert.Len(t, got, 1, "should return at most 1 release")
193 })
194 }
195
196 // TestGitHubLatestRelease verifies that the "find latest release" logic
197 // skips releases that don't have a binary for the current OS/arch.
198 // This handles the real-world case where a release tag is created but
199 // CI hasn't finished uploading build artifacts yet.
200 //
201 // Not parallel: temporarily overrides the package-level githubReleaseFmt var.
202 func TestGitHubLatestRelease(t *testing.T) {
203 releases := []ghRelease{
204 {
205 TagName: "v0.42.0",
206 Assets: []ghAsset{{Name: "kubo_v0.42.0_some-other-arch.tar.gz"}},
207 },
208 {
209 TagName: "v0.41.0",
210 Assets: []ghAsset{{Name: assetNameForPlatformTag("v0.41.0")}},
211 },
212 }
213 body, err := json.Marshal(releases)
214 require.NoError(t, err)
215
216 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
217 _, _ = w.Write(body)
218 }))
219 t.Cleanup(srv.Close)
220
221 saved := githubReleaseFmt
222 githubReleaseFmt = srv.URL
223 t.Cleanup(func() { githubReleaseFmt = saved })
224
225 rel, err := githubLatestRelease(t.Context(), false)
226 require.NoError(t, err)
227 assert.Equal(t, "v0.41.0", rel.TagName,
228 "should skip v0.42.0 (no binary for %s/%s) and return v0.41.0",
229 runtime.GOOS, runtime.GOARCH)
230 }
231
232 // TestFindReleaseAsset verifies that findReleaseAsset locates the correct
233 // platform-specific asset in a release, and returns a clear error when the
234 // release exists but has no binary for the current OS/arch.
235 //
236 // Not parallel: temporarily overrides the package-level githubReleaseFmt var.
237 func TestFindReleaseAsset(t *testing.T) {
238 wantAsset := assetNameForPlatformTag("v0.50.0")
239
240 release := ghRelease{
241 TagName: "v0.50.0",
242 Assets: []ghAsset{
243 {Name: "kubo_v0.50.0_some-other-arch.tar.gz", BrowserDownloadURL: "https://example.com/other"},
244 {Name: wantAsset, BrowserDownloadURL: "https://example.com/correct"},
245 },
246 }
247 body, err := json.Marshal(release)
248 require.NoError(t, err)
249
250 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
251 _, _ = w.Write(body)
252 }))
253 t.Cleanup(srv.Close)
254
255 saved := githubReleaseFmt
256 githubReleaseFmt = srv.URL
257 t.Cleanup(func() { githubReleaseFmt = saved })
258
259 t.Run("returns matching asset for current platform", func(t *testing.T) {
260 rel, asset, err := findReleaseAsset(t.Context(), "v0.50.0")
261 require.NoError(t, err)
262 assert.Equal(t, "v0.50.0", rel.TagName)
263 assert.Equal(t, wantAsset, asset.Name)
264 assert.Equal(t, "https://example.com/correct", asset.BrowserDownloadURL)
265 })
266
267 t.Run("returns error when no asset matches current platform", func(t *testing.T) {
268 // Serve a release that only has an asset for a different arch.
269 noMatch := ghRelease{
270 TagName: "v0.51.0",
271 Assets: []ghAsset{{Name: "kubo_v0.51.0_plan9-mips.tar.gz"}},
272 }
273 noMatchBody, err := json.Marshal(noMatch)
274 require.NoError(t, err)
275
276 noMatchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
277 _, _ = w.Write(noMatchBody)
278 }))
279 t.Cleanup(noMatchSrv.Close)
280
281 githubReleaseFmt = noMatchSrv.URL
282
283 _, _, err = findReleaseAsset(t.Context(), "v0.51.0")
284 assert.ErrorContains(t, err, "has no binary for",
285 "should explain that the release exists but lacks a matching asset")
286 })
287 }
288
289 // --- Asset download ---
290
291 // TestDownloadAsset verifies the HTTP download helper that fetches release
292 // archives from GitHub's CDN. Tests both the happy path and HTTP error
293 // reporting.
294 func TestDownloadAsset(t *testing.T) {
295 t.Parallel()
296
297 t.Run("downloads content successfully", func(t *testing.T) {
298 t.Parallel()
299 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
300 _, _ = w.Write([]byte("binary-content"))
301 }))
302 t.Cleanup(srv.Close)
303
304 data, err := downloadAsset(t.Context(), srv.URL)
305 require.NoError(t, err)
306 assert.Equal(t, []byte("binary-content"), data)
307 })
308
309 t.Run("returns clear error on HTTP failure", func(t *testing.T) {
310 t.Parallel()
311 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
312 w.WriteHeader(http.StatusNotFound)
313 }))
314 t.Cleanup(srv.Close)
315
316 _, err := downloadAsset(t.Context(), srv.URL)
317 assert.ErrorContains(t, err, "HTTP 404")
318 })
319 }
320
321 // --- Archive extraction ---
322
323 // TestExtractBinaryFromArchive verifies that the ipfs binary can be
324 // extracted from release archives. Kubo releases use tar.gz on Unix
325 // and zip on Windows, with the binary at "kubo/ipfs" inside the archive.
326 func TestExtractBinaryFromArchive(t *testing.T) {
327 t.Parallel()
328
329 t.Run("extracts binary from valid tar.gz", func(t *testing.T) {
330 t.Parallel()
331 wantContent := []byte("#!/bin/fake-ipfs-binary")
332 archive := makeTarGz(t, "kubo/ipfs", wantContent)
333
334 got, err := extractBinaryFromArchive(archive)
335 require.NoError(t, err)
336 assert.Equal(t, wantContent, got)
337 })
338
339 t.Run("rejects archive without kubo/ipfs entry", func(t *testing.T) {
340 t.Parallel()
341 // A valid tar.gz that contains a file at the wrong path.
342 archive := makeTarGz(t, "wrong-path/ipfs", []byte("binary"))
343
344 _, err := extractBinaryFromArchive(archive)
345 assert.ErrorContains(t, err, "could not find ipfs binary")
346 })
347
348 t.Run("rejects non-archive data", func(t *testing.T) {
349 t.Parallel()
350 _, err := extractBinaryFromArchive([]byte("not an archive"))
351 assert.ErrorContains(t, err, "could not find ipfs binary")
352 })
353 }
354
355 // makeTarGz creates an in-memory tar.gz archive containing a single file.
356 func makeTarGz(t *testing.T, path string, content []byte) []byte {
357 t.Helper()
358 var buf bytes.Buffer
359 gzw := gzip.NewWriter(&buf)
360 tw := tar.NewWriter(gzw)
361 require.NoError(t, tw.WriteHeader(&tar.Header{
362 Name: path,
363 Mode: 0o755,
364 Size: int64(len(content)),
365 }))
366 _, err := tw.Write(content)
367 require.NoError(t, err)
368 require.NoError(t, tw.Close())
369 require.NoError(t, gzw.Close())
370 return buf.Bytes()
371 }
372
373 // --- Asset name and version helpers ---
374
375 // TestAssetNameForPlatformTag ensures the archive filename matches the
376 // naming convention used by Kubo's CI release pipeline:
377 //
378 // kubo_<tag>_<os>-<arch>.<ext>
379 func TestAssetNameForPlatformTag(t *testing.T) {
380 t.Parallel()
381 name := assetNameForPlatformTag("v0.41.0")
382 assert.Contains(t, name, fmt.Sprintf("kubo_v0.41.0_%s-%s.", runtime.GOOS, runtime.GOARCH))
383
384 if runtime.GOOS == "windows" {
385 assert.Contains(t, name, ".zip")
386 } else {
387 assert.Contains(t, name, ".tar.gz")
388 }
389 }
390
391 // TestVersionHelpers exercises the version string utilities used throughout
392 // the update command. These handle the mismatch between Go's semver
393 // (no "v" prefix) and GitHub's tag convention ("v" prefix).
394 func TestVersionHelpers(t *testing.T) {
395 t.Parallel()
396
397 t.Run("trimVPrefix strips leading v", func(t *testing.T) {
398 t.Parallel()
399 assert.Equal(t, "0.41.0", trimVPrefix("v0.41.0"))
400 assert.Equal(t, "0.41.0", trimVPrefix("0.41.0"), "no-op when v is absent")
401 })
402
403 t.Run("normalizeVersion adds v prefix for GitHub tags", func(t *testing.T) {
404 t.Parallel()
405 assert.Equal(t, "v0.41.0", normalizeVersion("0.41.0"))
406 assert.Equal(t, "v0.41.0", normalizeVersion("v0.41.0"), "no-op when v is present")
407 assert.Equal(t, "v0.41.0", normalizeVersion(" v0.41.0 "), "trims whitespace")
408 })
409
410 t.Run("isNewerVersion compares semver correctly", func(t *testing.T) {
411 t.Parallel()
412 tests := []struct {
413 current, target string
414 wantNewer bool
415 desc string
416 }{
417 {"0.40.0", "0.41.0", true, "newer minor version"},
418 {"0.41.0", "0.40.0", false, "older minor version"},
419 {"0.41.0", "0.41.0", false, "same version"},
420 {"0.41.0-dev", "0.41.0", true, "release is newer than dev pre-release"},
421 }
422 for _, tt := range tests {
423 got, err := isNewerVersion(tt.current, tt.target)
424 require.NoError(t, err)
425 assert.Equal(t, tt.wantNewer, got, tt.desc)
426 }
427 })
428 }