| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "archive/tar" |
| 5 | "archive/zip" |
| 6 | "bytes" |
| 7 | "compress/gzip" |
| 8 | "crypto/sha512" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "runtime" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | |
| 19 | "github.com/ipfs/kubo/test/cli/harness" |
| 20 | "github.com/stretchr/testify/assert" |
| 21 | "github.com/stretchr/testify/require" |
| 22 | ) |
| 23 | |
| 24 | // TestUpdate exercises the built-in "ipfs update" command tree. |
| 25 | // |
| 26 | // A local httptest server replaces GitHub Releases so the test does not |
| 27 | // depend on network reachability or rate limits. The node is created |
| 28 | // without Init or daemon, so install/revert error paths that don't |
| 29 | // depend on a running daemon can be tested. |
| 30 | func TestUpdate(t *testing.T) { |
| 31 | t.Parallel() |
| 32 | h := harness.NewT(t) |
| 33 | node := h.NewNode() |
| 34 | |
| 35 | srv := newMockGitHubReleases(t) |
| 36 | node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srv.URL |
| 37 | |
| 38 | t.Run("help text describes the command", func(t *testing.T) { |
| 39 | t.Parallel() |
| 40 | res := node.IPFS("update", "--help") |
| 41 | assert.Contains(t, res.Stdout.String(), "Update Kubo to a different version") |
| 42 | }) |
| 43 | |
| 44 | // check and versions are read-only GitHub API queries. They must work |
| 45 | // regardless of daemon state, since users need to check for updates |
| 46 | // before deciding whether to stop the daemon and install. |
| 47 | t.Run("check", func(t *testing.T) { |
| 48 | t.Parallel() |
| 49 | |
| 50 | t.Run("text output reports update availability", func(t *testing.T) { |
| 51 | t.Parallel() |
| 52 | res := node.IPFS("update", "check") |
| 53 | out := res.Stdout.String() |
| 54 | assert.True(t, |
| 55 | strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"), |
| 56 | "expected update status message, got: %s", out) |
| 57 | }) |
| 58 | |
| 59 | t.Run("json output includes version fields", func(t *testing.T) { |
| 60 | t.Parallel() |
| 61 | res := node.IPFS("update", "check", "--enc=json") |
| 62 | var result struct { |
| 63 | CurrentVersion string |
| 64 | LatestVersion string |
| 65 | UpdateAvailable bool |
| 66 | } |
| 67 | err := json.Unmarshal(res.Stdout.Bytes(), &result) |
| 68 | require.NoError(t, err, "invalid JSON: %s", res.Stdout.String()) |
| 69 | assert.NotEmpty(t, result.CurrentVersion, "must report current version") |
| 70 | assert.NotEmpty(t, result.LatestVersion, "must report latest version") |
| 71 | }) |
| 72 | }) |
| 73 | |
| 74 | t.Run("versions", func(t *testing.T) { |
| 75 | t.Parallel() |
| 76 | |
| 77 | t.Run("lists available versions", func(t *testing.T) { |
| 78 | t.Parallel() |
| 79 | res := node.IPFS("update", "versions") |
| 80 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 81 | assert.Greater(t, len(lines), 0, "should list at least one version") |
| 82 | }) |
| 83 | |
| 84 | t.Run("respects --count flag", func(t *testing.T) { |
| 85 | t.Parallel() |
| 86 | res := node.IPFS("update", "versions", "--count=5") |
| 87 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 88 | assert.LessOrEqual(t, len(lines), 5) |
| 89 | }) |
| 90 | |
| 91 | t.Run("json output includes current version and list", func(t *testing.T) { |
| 92 | t.Parallel() |
| 93 | res := node.IPFS("update", "versions", "--count=3", "--enc=json") |
| 94 | var result struct { |
| 95 | Current string |
| 96 | Versions []string |
| 97 | } |
| 98 | err := json.Unmarshal(res.Stdout.Bytes(), &result) |
| 99 | require.NoError(t, err, "invalid JSON: %s", res.Stdout.String()) |
| 100 | assert.NotEmpty(t, result.Current, "must report current version") |
| 101 | assert.NotEmpty(t, result.Versions, "must list at least one version") |
| 102 | }) |
| 103 | |
| 104 | t.Run("--pre includes prerelease versions", func(t *testing.T) { |
| 105 | t.Parallel() |
| 106 | res := node.IPFS("update", "versions", "--count=5", "--pre") |
| 107 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 108 | assert.Greater(t, len(lines), 0, "should list at least one version") |
| 109 | }) |
| 110 | }) |
| 111 | |
| 112 | // install and revert mutate the binary on disk, so they have stricter |
| 113 | // preconditions. These tests verify the error paths. |
| 114 | t.Run("install rejects same version", func(t *testing.T) { |
| 115 | t.Parallel() |
| 116 | vRes := node.IPFS("version", "-n") |
| 117 | current := strings.TrimSpace(vRes.Stdout.String()) |
| 118 | |
| 119 | res := node.RunIPFS("update", "install", current) |
| 120 | assert.Error(t, res.Err) |
| 121 | assert.Contains(t, res.Stderr.String(), "already running version", |
| 122 | "should refuse to re-install the current version") |
| 123 | }) |
| 124 | |
| 125 | t.Run("revert fails when no backup exists", func(t *testing.T) { |
| 126 | t.Parallel() |
| 127 | res := node.RunIPFS("update", "revert") |
| 128 | assert.Error(t, res.Err) |
| 129 | assert.Contains(t, res.Stderr.String(), "no stashed binaries", |
| 130 | "should explain there is no previous version to restore") |
| 131 | }) |
| 132 | } |
| 133 | |
| 134 | // TestUpdateWhileDaemonRuns verifies that read-only update subcommands |
| 135 | // (check, versions) work while the IPFS daemon holds the repo lock. |
| 136 | // These commands only query the GitHub API and never touch the repo, |
| 137 | // so they must succeed regardless of daemon state. |
| 138 | // |
| 139 | // A local httptest server replaces GitHub so the test does not depend |
| 140 | // on network reachability or GitHub rate limits. The locking behavior |
| 141 | // under test is independent of which endpoint serves the release JSON. |
| 142 | func TestUpdateWhileDaemonRuns(t *testing.T) { |
| 143 | t.Parallel() |
| 144 | |
| 145 | srv := newMockGitHubReleases(t) |
| 146 | node := harness.NewT(t).NewNode() |
| 147 | node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srv.URL |
| 148 | node.Init().StartDaemon() |
| 149 | defer node.StopDaemon() |
| 150 | |
| 151 | t.Run("check succeeds with daemon running", func(t *testing.T) { |
| 152 | t.Parallel() |
| 153 | res := node.IPFS("update", "check") |
| 154 | out := res.Stdout.String() |
| 155 | assert.True(t, |
| 156 | strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"), |
| 157 | "check must work while daemon runs, got: %s", out) |
| 158 | }) |
| 159 | |
| 160 | t.Run("versions succeeds with daemon running", func(t *testing.T) { |
| 161 | t.Parallel() |
| 162 | res := node.IPFS("update", "versions", "--count=3") |
| 163 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 164 | assert.Greater(t, len(lines), 0, |
| 165 | "versions must work while daemon runs") |
| 166 | }) |
| 167 | } |
| 168 | |
| 169 | // TestUpdateInstall exercises the full install flow end-to-end: |
| 170 | // API query, archive download, SHA-512 verification, tar.gz extraction, |
| 171 | // binary stash (backup), and atomic replace. |
| 172 | // |
| 173 | // A local mock HTTP server replaces GitHub so the test is fast, offline, |
| 174 | // and deterministic. The built ipfs binary is copied to a temp directory |
| 175 | // so the install replaces the copy, not the real build artifact. |
| 176 | // |
| 177 | // The env var TEST_KUBO_UPDATE_GITHUB_URL redirects the binary's GitHub |
| 178 | // API calls to the mock server. TEST_KUBO_VERSION makes the binary |
| 179 | // report a specific version so the "upgrade" to v0.99.0 is deterministic. |
| 180 | func TestUpdateInstall(t *testing.T) { |
| 181 | // Not t.Parallel(): this test writes a copy of the ipfs binary and |
| 182 | // then exec's it. Running in parallel with other tests exposes the |
| 183 | // ETXTBSY race where a concurrent fork() in another test goroutine |
| 184 | // inherits our still-open write fd, leaving the freshly written |
| 185 | // file "text file busy" for exec until the sibling child execs. |
| 186 | // Running sequentially guarantees no other goroutine is mid-fork |
| 187 | // while we're writing. |
| 188 | |
| 189 | // Build a fake binary to put inside the archive. After install, the |
| 190 | // file at tmpBinPath should contain exactly these bytes. |
| 191 | fakeBinary := []byte("#!/bin/sh\necho fake-ipfs-v0.99.0\n") |
| 192 | |
| 193 | // Archive entry path: extractBinaryFromArchive looks for "kubo/<exename>". |
| 194 | binName := "ipfs" |
| 195 | if runtime.GOOS == "windows" { |
| 196 | binName = "ipfs.exe" |
| 197 | } |
| 198 | var archive []byte |
| 199 | if runtime.GOOS == "windows" { |
| 200 | archive = buildTestZip(t, "kubo/"+binName, fakeBinary) |
| 201 | } else { |
| 202 | archive = buildTestTarGz(t, "kubo/"+binName, fakeBinary) |
| 203 | } |
| 204 | |
| 205 | // Compute SHA-512 of the archive for the .sha512 sidecar file. |
| 206 | sum := sha512.Sum512(archive) |
| 207 | |
| 208 | // Asset name must match what findReleaseAsset expects for the |
| 209 | // current OS/arch (e.g., kubo_v0.99.0_linux-amd64.tar.gz). |
| 210 | ext := "tar.gz" |
| 211 | if runtime.GOOS == "windows" { |
| 212 | ext = "zip" |
| 213 | } |
| 214 | assetName := fmt.Sprintf("kubo_v0.99.0_%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext) |
| 215 | checksumBody := fmt.Sprintf("%x %s\n", sum[:], assetName) |
| 216 | |
| 217 | // Mock server: serves GitHub Releases API, archive, and .sha512 sidecar. |
| 218 | // srvURL is captured after the server starts, so the handler can build |
| 219 | // browser_download_url values pointing back to itself. |
| 220 | var srvURL string |
| 221 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 222 | switch r.URL.Path { |
| 223 | // githubReleaseByTag: GET /tags/v0.99.0 |
| 224 | case "/tags/v0.99.0": |
| 225 | rel := map[string]any{ |
| 226 | "tag_name": "v0.99.0", |
| 227 | "prerelease": false, |
| 228 | "assets": []map[string]any{{ |
| 229 | "name": assetName, |
| 230 | "browser_download_url": srvURL + "/download/" + assetName, |
| 231 | }}, |
| 232 | } |
| 233 | w.Header().Set("Content-Type", "application/json") |
| 234 | _ = json.NewEncoder(w).Encode(rel) |
| 235 | |
| 236 | // downloadAsset: GET /download/<asset>.tar.gz |
| 237 | case "/download/" + assetName: |
| 238 | _, _ = w.Write(archive) |
| 239 | |
| 240 | // downloadAndVerifySHA512: GET /download/<asset>.tar.gz.sha512 |
| 241 | case "/download/" + assetName + ".sha512": |
| 242 | _, _ = w.Write([]byte(checksumBody)) |
| 243 | |
| 244 | default: |
| 245 | http.NotFound(w, r) |
| 246 | } |
| 247 | })) |
| 248 | t.Cleanup(srv.Close) |
| 249 | srvURL = srv.URL |
| 250 | |
| 251 | // Copy the real built binary to a temp directory. The install command |
| 252 | // uses os.Executable() to find the binary to replace, so the subprocess |
| 253 | // will replace this copy instead of the real build artifact. |
| 254 | tmpBinDir := t.TempDir() |
| 255 | tmpBinPath := filepath.Join(tmpBinDir, binName) |
| 256 | copyBuiltBinary(t, tmpBinPath) |
| 257 | |
| 258 | // Create a harness that uses the temp binary copy. |
| 259 | h := harness.NewT(t, func(h *harness.Harness) { |
| 260 | h.IPFSBin = tmpBinPath |
| 261 | }) |
| 262 | node := h.NewNode() |
| 263 | |
| 264 | // Make the binary report v0.30.0 so the "upgrade" to v0.99.0 has a |
| 265 | // deterministic from-version. Point API calls at the mock server. |
| 266 | node.Runner.Env["TEST_KUBO_VERSION"] = "0.30.0" |
| 267 | node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srvURL |
| 268 | |
| 269 | // Run: ipfs update install v0.99.0 |
| 270 | res := node.RunIPFS("update", "install", "v0.99.0") |
| 271 | require.NoError(t, res.Err, "install failed; stderr:\n%s", res.Stderr.String()) |
| 272 | |
| 273 | // Verify progress messages on stderr. |
| 274 | stderr := res.Stderr.String() |
| 275 | assert.Contains(t, stderr, "Downloading Kubo 0.99.0", |
| 276 | "should show download progress") |
| 277 | assert.Contains(t, stderr, "Checksum verified (SHA-512)", |
| 278 | "should confirm checksum passed") |
| 279 | assert.Contains(t, stderr, "Backed up current binary to", |
| 280 | "should report where the old binary was stashed") |
| 281 | |
| 282 | // Verify the stash: the original binary should be saved to |
| 283 | // $IPFS_PATH/old-bin/ipfs-0.30.0 (with .exe on Windows). |
| 284 | stashName := "ipfs-0.30.0" |
| 285 | if runtime.GOOS == "windows" { |
| 286 | stashName += ".exe" |
| 287 | } |
| 288 | stashPath := filepath.Join(node.Dir, "old-bin", stashName) |
| 289 | _, err := os.Stat(stashPath) |
| 290 | require.NoError(t, err, "stash file should exist at %s", stashPath) |
| 291 | |
| 292 | // On Windows the OS locks the executable of a running process, so |
| 293 | // atomicfile cannot rename over it. The install command falls back |
| 294 | // to saving the new binary to a temp path with manual move instructions. |
| 295 | if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") { |
| 296 | assert.Contains(t, stderr, "Could not replace", |
| 297 | "should explain why in-place replacement failed") |
| 298 | assert.Contains(t, stderr, "New binary saved to:", |
| 299 | "should print where the new binary was saved") |
| 300 | |
| 301 | // Extract the temp path from stderr and verify the file exists |
| 302 | // with the expected content. |
| 303 | for line := range strings.SplitSeq(stderr, "\n") { |
| 304 | if savedPath, ok := strings.CutPrefix(line, "New binary saved to: "); ok { |
| 305 | savedPath = strings.TrimSpace(savedPath) |
| 306 | got, err := os.ReadFile(savedPath) |
| 307 | require.NoError(t, err, "new binary should exist at %s", savedPath) |
| 308 | assert.Equal(t, fakeBinary, got, |
| 309 | "binary at %s should contain the extracted archive content", savedPath) |
| 310 | break |
| 311 | } |
| 312 | } |
| 313 | } else { |
| 314 | // Non-Windows (or Windows where in-place replace succeeded): |
| 315 | // binary was replaced atomically. |
| 316 | assert.Contains(t, stderr, "Successfully updated Kubo 0.30.0 -> 0.99.0", |
| 317 | "should confirm the version change") |
| 318 | got, err := os.ReadFile(tmpBinPath) |
| 319 | require.NoError(t, err) |
| 320 | assert.Equal(t, fakeBinary, got, |
| 321 | "binary at %s should contain the extracted archive content", tmpBinPath) |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | // TestUpdateRevert exercises the full revert flow end-to-end: reading |
| 326 | // a stashed binary from $IPFS_PATH/old-bin/, atomically replacing the |
| 327 | // current binary, and cleaning up the stash file. |
| 328 | // |
| 329 | // The stash is created manually (rather than via install) so this test |
| 330 | // is self-contained and does not depend on network access or a mock server. |
| 331 | // |
| 332 | // How it works: the subprocess runs from tmpBinPath, so os.Executable() |
| 333 | // inside the subprocess returns tmpBinPath. The revert command reads the |
| 334 | // stash and atomically replaces the file at tmpBinPath with stash content. |
| 335 | func TestUpdateRevert(t *testing.T) { |
| 336 | // Not t.Parallel(): same ETXTBSY rationale as TestUpdateInstall. |
| 337 | // This test writes a binary copy and exec's it, which must not |
| 338 | // overlap with concurrent fork() calls from other test goroutines. |
| 339 | |
| 340 | binName := "ipfs" |
| 341 | if runtime.GOOS == "windows" { |
| 342 | binName = "ipfs.exe" |
| 343 | } |
| 344 | |
| 345 | // Copy the real built binary to a temp directory. Revert will replace |
| 346 | // this copy with the stash content via os.Executable() -> tmpBinPath. |
| 347 | tmpBinDir := t.TempDir() |
| 348 | tmpBinPath := filepath.Join(tmpBinDir, binName) |
| 349 | copyBuiltBinary(t, tmpBinPath) |
| 350 | |
| 351 | h := harness.NewT(t, func(h *harness.Harness) { |
| 352 | h.IPFSBin = tmpBinPath |
| 353 | }) |
| 354 | node := h.NewNode() |
| 355 | |
| 356 | // Create a stash directory with known content that differs from the |
| 357 | // current binary. findLatestStash looks for ipfs-<semver> files. |
| 358 | stashDir := filepath.Join(node.Dir, "old-bin") |
| 359 | require.NoError(t, os.MkdirAll(stashDir, 0o755)) |
| 360 | stashName := "ipfs-0.30.0" |
| 361 | if runtime.GOOS == "windows" { |
| 362 | stashName = "ipfs-0.30.0.exe" |
| 363 | } |
| 364 | stashPath := filepath.Join(stashDir, stashName) |
| 365 | stashContent := []byte("#!/bin/sh\necho reverted-to-0.30.0\n") |
| 366 | require.NoError(t, os.WriteFile(stashPath, stashContent, 0o755)) |
| 367 | |
| 368 | // Run: ipfs update revert |
| 369 | // The subprocess executes from tmpBinPath (a real ipfs binary). |
| 370 | // os.Executable() returns tmpBinPath, so revert replaces that file |
| 371 | // with stashContent and removes the stash file. |
| 372 | res := node.RunIPFS("update", "revert") |
| 373 | require.NoError(t, res.Err, "revert failed; stderr:\n%s", res.Stderr.String()) |
| 374 | |
| 375 | stderr := res.Stderr.String() |
| 376 | |
| 377 | // On Windows the OS locks the running binary, so the revert falls |
| 378 | // back to saving to a temp path with manual move instructions. |
| 379 | if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") { |
| 380 | assert.Contains(t, stderr, "Could not replace", |
| 381 | "should explain why in-place replacement failed") |
| 382 | assert.Contains(t, stderr, "Reverted binary saved to:", |
| 383 | "should print where the reverted binary was saved") |
| 384 | |
| 385 | // Verify the saved binary has the stash content. |
| 386 | for line := range strings.SplitSeq(stderr, "\n") { |
| 387 | if savedPath, ok := strings.CutPrefix(line, "Reverted binary saved to: "); ok { |
| 388 | savedPath = strings.TrimSpace(savedPath) |
| 389 | got, err := os.ReadFile(savedPath) |
| 390 | require.NoError(t, err, "reverted binary should exist at %s", savedPath) |
| 391 | assert.Equal(t, stashContent, got, |
| 392 | "binary at %s should contain the stash content", savedPath) |
| 393 | break |
| 394 | } |
| 395 | } |
| 396 | } else { |
| 397 | // Non-Windows: binary was replaced in place. |
| 398 | assert.Contains(t, stderr, "Reverted to Kubo 0.30.0", |
| 399 | "should confirm which version was restored") |
| 400 | |
| 401 | // Verify the stash file was cleaned up after successful revert. |
| 402 | _, err := os.Stat(stashPath) |
| 403 | assert.True(t, os.IsNotExist(err), |
| 404 | "stash file should be removed after revert, but still exists at %s", stashPath) |
| 405 | |
| 406 | // Verify the binary was replaced with the stash content. |
| 407 | got, err := os.ReadFile(tmpBinPath) |
| 408 | require.NoError(t, err) |
| 409 | assert.Equal(t, stashContent, got, |
| 410 | "binary at %s should contain the stash content after revert", tmpBinPath) |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | // TestUpdateClean exercises the cleanup command that drops every backed-up |
| 415 | // Kubo binary from $IPFS_PATH/old-bin/. The test stages a stash directory |
| 416 | // directly so it doesn't need network access or a real install. |
| 417 | func TestUpdateClean(t *testing.T) { |
| 418 | t.Parallel() |
| 419 | h := harness.NewT(t) |
| 420 | node := h.NewNode() |
| 421 | |
| 422 | stashDir := filepath.Join(node.Dir, "old-bin") |
| 423 | require.NoError(t, os.MkdirAll(stashDir, 0o755)) |
| 424 | |
| 425 | binSuffix := "" |
| 426 | if runtime.GOOS == "windows" { |
| 427 | binSuffix = ".exe" |
| 428 | } |
| 429 | stashFiles := []string{ |
| 430 | "ipfs-0.30.0" + binSuffix, |
| 431 | "ipfs-0.31.0" + binSuffix, |
| 432 | "ipfs-0.32.0" + binSuffix, |
| 433 | } |
| 434 | for _, name := range stashFiles { |
| 435 | require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("fake"), 0o755)) |
| 436 | } |
| 437 | // A file that does not match ipfs-<version> must be left alone so users |
| 438 | // can store unrelated notes or scripts in old-bin/ without losing them. |
| 439 | unrelated := filepath.Join(stashDir, "notes.txt") |
| 440 | require.NoError(t, os.WriteFile(unrelated, []byte("keep me"), 0o644)) |
| 441 | |
| 442 | t.Run("removes all stashed binaries", func(t *testing.T) { |
| 443 | res := node.IPFS("update", "clean") |
| 444 | out := res.Stdout.String() |
| 445 | for _, name := range stashFiles { |
| 446 | assert.Contains(t, out, name, "should report removing %s", name) |
| 447 | _, err := os.Stat(filepath.Join(stashDir, name)) |
| 448 | assert.True(t, os.IsNotExist(err), "%s should be removed from disk", name) |
| 449 | } |
| 450 | _, err := os.Stat(unrelated) |
| 451 | require.NoError(t, err, "unrelated files in old-bin/ must not be touched") |
| 452 | }) |
| 453 | |
| 454 | t.Run("reports nothing on empty stash", func(t *testing.T) { |
| 455 | res := node.IPFS("update", "clean") |
| 456 | assert.Contains(t, res.Stdout.String(), "No stashed binaries to remove") |
| 457 | }) |
| 458 | |
| 459 | t.Run("json output lists removed files and bytes freed", func(t *testing.T) { |
| 460 | // Re-create one stash file to verify the JSON encoder. |
| 461 | name := "ipfs-0.33.0" + binSuffix |
| 462 | require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("data"), 0o755)) |
| 463 | |
| 464 | res := node.IPFS("update", "clean", "--enc=json") |
| 465 | var result struct { |
| 466 | Removed []string |
| 467 | BytesFreed int64 |
| 468 | } |
| 469 | err := json.Unmarshal(res.Stdout.Bytes(), &result) |
| 470 | require.NoError(t, err, "invalid JSON: %s", res.Stdout.String()) |
| 471 | assert.Equal(t, []string{name}, result.Removed) |
| 472 | assert.Equal(t, int64(4), result.BytesFreed) |
| 473 | }) |
| 474 | } |
| 475 | |
| 476 | // --- test helpers --- |
| 477 | |
| 478 | // newMockGitHubReleases returns an httptest server that mimics the GitHub |
| 479 | // Releases listing API with a single stable release at v0.99.0 carrying |
| 480 | // a binary asset for the current GOOS/GOARCH. This is enough to drive |
| 481 | // "ipfs update check" and "ipfs update versions" without touching the |
| 482 | // real api.github.com. |
| 483 | // |
| 484 | // The asset name follows the same convention used by real kubo releases |
| 485 | // (see https://github.com/ipfs/kubo/releases): kubo_<tag>_<os>-<arch>.<ext>, |
| 486 | // where ext is "zip" on Windows and "tar.gz" everywhere else. This must |
| 487 | // match what assetNameForPlatformTag produces in core/commands/update_github.go, |
| 488 | // otherwise findReleaseAsset cannot locate the binary and reports |
| 489 | // "no release found with a binary for <os>/<arch>". |
| 490 | func newMockGitHubReleases(t *testing.T) *httptest.Server { |
| 491 | t.Helper() |
| 492 | ext := "tar.gz" |
| 493 | if runtime.GOOS == "windows" { |
| 494 | ext = "zip" |
| 495 | } |
| 496 | asset := fmt.Sprintf("kubo_v0.99.0_%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext) |
| 497 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 498 | // Both `update check` and `update versions` call |
| 499 | // GET /releases?per_page=N. One stable release with a matching |
| 500 | // platform asset exercises both paths. |
| 501 | rels := []map[string]any{{ |
| 502 | "tag_name": "v0.99.0", |
| 503 | "prerelease": false, |
| 504 | "assets": []map[string]any{{ |
| 505 | "name": asset, |
| 506 | }}, |
| 507 | }} |
| 508 | w.Header().Set("Content-Type", "application/json") |
| 509 | _ = json.NewEncoder(w).Encode(rels) |
| 510 | })) |
| 511 | t.Cleanup(srv.Close) |
| 512 | return srv |
| 513 | } |
| 514 | |
| 515 | // copyBuiltBinary copies the built ipfs binary (cmd/ipfs/ipfs) to dst. |
| 516 | // It locates the project root the same way the test harness does. |
| 517 | func copyBuiltBinary(t *testing.T, dst string) { |
| 518 | t.Helper() |
| 519 | // Use a throwaway harness to resolve the default binary path, |
| 520 | // reusing the same project-root lookup the harness already has. |
| 521 | h := harness.NewT(t) |
| 522 | srcBin := h.IPFSBin |
| 523 | // The harness hardcodes "ipfs" without .exe suffix, but on Windows |
| 524 | // the built binary is "ipfs.exe". |
| 525 | if runtime.GOOS == "windows" && !strings.HasSuffix(srcBin, ".exe") { |
| 526 | srcBin += ".exe" |
| 527 | } |
| 528 | data, err := os.ReadFile(srcBin) |
| 529 | require.NoError(t, err, "failed to read built binary at %s (did you run 'make build'?)", srcBin) |
| 530 | require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) |
| 531 | require.NoError(t, os.WriteFile(dst, data, 0o755)) |
| 532 | } |
| 533 | |
| 534 | // buildTestTarGz creates an in-memory tar.gz archive with a single file entry. |
| 535 | func buildTestTarGz(t *testing.T, path string, content []byte) []byte { |
| 536 | t.Helper() |
| 537 | var buf bytes.Buffer |
| 538 | gzw := gzip.NewWriter(&buf) |
| 539 | tw := tar.NewWriter(gzw) |
| 540 | require.NoError(t, tw.WriteHeader(&tar.Header{ |
| 541 | Name: path, |
| 542 | Mode: 0o755, |
| 543 | Size: int64(len(content)), |
| 544 | })) |
| 545 | _, err := tw.Write(content) |
| 546 | require.NoError(t, err) |
| 547 | require.NoError(t, tw.Close()) |
| 548 | require.NoError(t, gzw.Close()) |
| 549 | return buf.Bytes() |
| 550 | } |
| 551 | |
| 552 | // buildTestZip creates an in-memory zip archive with a single file entry. |
| 553 | func buildTestZip(t *testing.T, path string, content []byte) []byte { |
| 554 | t.Helper() |
| 555 | var buf bytes.Buffer |
| 556 | zw := zip.NewWriter(&buf) |
| 557 | fw, err := zw.Create(path) |
| 558 | require.NoError(t, err) |
| 559 | _, err = fw.Write(content) |
| 560 | require.NoError(t, err) |
| 561 | require.NoError(t, zw.Close()) |
| 562 | return buf.Bytes() |
| 563 | } |