Files
zero/internal/update/apply_test.go
PierrunoYTandVasanthdev2004 5f36349c18 feat(update): add zero upgrade command to apply self-updates (#461)
* feat(update): add zero upgrade command to apply self-updates

Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:

- `zero update --apply` / `zero upgrade` download, sha256-verify, and
  install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
  checksum, extract it (zip-slip guarded), and atomically replace the
  running binary plus any pre-existing optional sandbox helpers. On
  Windows, the running exe is renamed aside (NTFS allows this even
  while it's executing) since it can't be overwritten directly; the
  leftover ".old" file is cleaned up on a later invocation.

Closes #459.

* fix(update): correct macOS expectations in apply test

The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.

* fix(update): address CodeRabbit review findings on zero upgrade

- Reject --target when combined with --apply/upgrade: applying installs
  onto the current machine, so a mismatched --target could replace the
  running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
  from the release-metadata check timeout, so a stalled connection
  can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
  of swallowing it, so a botched install is reported rather than
  silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
  so a leftover Windows ".old" file isn't stuck waiting for a future
  upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
  ApplyResult (surfaced in text and JSON output) instead of discarding
  them silently.

* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI

TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.

Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-04 08:52:07 +08:00

303 lines
9.2 KiB
Go

package update
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/release"
)
func TestApplyReturnsNoopWhenUpToDate(t *testing.T) {
result, err := Apply(context.Background(), Options{
CurrentVersion: "0.2.0",
GOOS: "linux",
GOARCH: "amd64",
Fetch: func(_ context.Context, endpoint string) (Release, error) {
return releaseForTarget(t, "v0.2.0", "linux", "amd64"), nil
},
})
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if result.Applied {
t.Fatalf("expected Applied=false when already up to date, got %#v", result)
}
}
func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
binaryName := "zero"
// macOS ships no optional helper binaries (matching scripts/postinstall.mjs),
// so there's nothing to refresh there; only linux/windows have one to check.
optionalName := ""
switch runtime.GOOS {
case "windows":
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
case "linux":
optionalName = "zero-seccomp"
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
// Only pre-existing optional helpers should be refreshed; an absent one
// (e.g. the platform's other optional helper) must not be introduced.
var existingHelperPath string
if optionalName != "" {
existingHelperPath = filepath.Join(installDir, optionalName)
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{
"zero": "new-binary",
"zero.exe": "new-binary-exe",
"zero-seccomp": "new-helper",
"zero-windows-command-runner.exe": "new-helper-exe",
})
checksum, err := release.SHA256File(archivePath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(checksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
LatestVersion: "0.2.0",
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
if warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath); err != nil {
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
} else if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
wantBinary := "new-binary"
if runtime.GOOS == "windows" {
wantBinary = "new-binary-exe"
}
if string(data) != wantBinary {
t.Fatalf("executable content = %q, want %q", data, wantBinary)
}
if optionalName != "" {
helperData, err := os.ReadFile(existingHelperPath)
if err != nil {
t.Fatalf("ReadFile helper: %v", err)
}
wantHelper := "new-helper"
if runtime.GOOS == "windows" {
wantHelper = "new-helper-exe"
}
if string(helperData) != wantHelper {
t.Fatalf("helper content = %q, want %q", helperData, wantHelper)
}
}
if entries, err := os.ReadDir(installDir); err == nil {
for _, entry := range entries {
name := entry.Name()
// On Windows, replaceBinary leaves "<name>.old" behind (the running
// binary is renamed aside, not deleted) for later best-effort cleanup.
if name == binaryName || (optionalName != "" && name == optionalName) || name == binaryName+".old" || (optionalName != "" && name == optionalName+".old") {
continue
}
t.Fatalf("unexpected extra file left in install dir: %s", name)
}
}
}
func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
binaryName := "zero"
optionalName := "zero-seccomp"
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
} else if runtime.GOOS == "darwin" {
t.Skip("macOS ships no optional helper binaries to refresh")
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
// The helper must already exist for a refresh to be attempted at all.
existingHelperPath := filepath.Join(installDir, optionalName)
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
// Force installBinary's staging copy to fail by occupying its staged
// "<helper>.new" path with a directory instead of a file.
if err := os.MkdirAll(existingHelperPath+".new", 0o755); err != nil {
t.Fatalf("MkdirAll staged path: %v", err)
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{
"zero": "new-binary",
"zero.exe": "new-binary-exe",
"zero-seccomp": "new-helper",
"zero-windows-command-runner.exe": "new-helper-exe",
})
checksum, err := release.SHA256File(archivePath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(checksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
LatestVersion: "0.2.0",
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath)
if err != nil {
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], optionalName) {
t.Fatalf("expected one warning mentioning %s, got %v", optionalName, warnings)
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
wantBinary := "new-binary"
if runtime.GOOS == "windows" {
wantBinary = "new-binary-exe"
}
if string(data) != wantBinary {
t.Fatalf("main binary should still be updated despite helper failure: got %q, want %q", data, wantBinary)
}
}
func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
binaryName := "zero"
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{"zero": "new-binary", "zero.exe": "new-binary-exe"})
badChecksumText, err := release.FormatSHA256Checksum("0000000000000000000000000000000000000000000000000000000000000000"[:64], archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(badChecksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
if _, err := applyStandaloneUpdate(context.Background(), result, executablePath); err == nil {
t.Fatal("expected checksum mismatch error")
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
if string(data) != "old-binary" {
t.Fatalf("executable should be untouched after checksum failure, got %q", data)
}
}