Files
probo/pkg/deviceagent/update/update_test.go
Bryan Frimin 712f00f434 Update copyright year
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-05-26 13:59:17 -07:00

469 lines
13 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
)
func TestParseTag(t *testing.T) {
t.Parallel()
cases := []struct {
tag string
prefix string
want string
ok bool
}{
{"probo-agent/v0.1.0", "probo-agent/v", "0.1.0", true},
{"probo-agent/v1.2.3", "probo-agent/v", "1.2.3", true},
{"v1.2.3", "probo-agent/v", "", false},
{"probo-agent/vlatest", "probo-agent/v", "", false},
{"probo-agent/v", "probo-agent/v", "", false},
{"unrelated/v0.1.0", "probo-agent/v", "", false},
}
for _, tc := range cases {
got, ok := parseTag(tc.tag, tc.prefix)
assert.Equal(t, tc.ok, ok, tc.tag)
assert.Equal(t, tc.want, got, tc.tag)
}
}
func TestNormalizeSemver(t *testing.T) {
t.Parallel()
assert.Equal(t, "v0.1.0", normalizeSemver("0.1.0"))
assert.Equal(t, "v1.2.3", normalizeSemver("v1.2.3"))
assert.Equal(t, "v1.2.3-alpha.1", normalizeSemver("1.2.3-alpha.1"))
assert.Equal(t, "", normalizeSemver(""))
assert.Equal(t, "", normalizeSemver("not-a-version"))
}
func TestReadChecksum(t *testing.T) {
t.Parallel()
t.Run(
"plain sha256sum output",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
content := "" +
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef probo-agent_Linux_x86_64.tar.gz\n" +
"abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd probo-agent_Darwin_arm64.tar.gz\n"
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
got, err := readChecksum(file, "probo-agent_Darwin_arm64.tar.gz")
require.NoError(t, err)
assert.Equal(t, "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", got)
},
)
t.Run(
"binary-mode flag is stripped",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
content := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef *probo-agent_Linux_x86_64.tar.gz\n"
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
got, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
require.NoError(t, err)
assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got)
},
)
t.Run(
"missing entry returns error",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
require.NoError(t, os.WriteFile(file, []byte("deadbeef other.tar.gz\n"), 0o600))
_, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
require.Error(t, err)
},
)
}
// fakeReleaseServer simulates the GitHub releases API and the
// browser_download_url asset endpoints.
type fakeReleaseServer struct {
t *testing.T
server *httptest.Server
// release plumbing
tag string
prerelease bool
draft bool
// archive plumbing
binaryContent []byte
archiveBytes []byte
checksumLine string
bundleBytes []byte
// when true, the release does not advertise a checksums.txt.bundle asset
omitBundle bool
}
func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, binary []byte) *fakeReleaseServer {
t.Helper()
archive := buildArchive(t, layout, binary)
sum := sha256.Sum256(archive)
checksum := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), layout.ArchiveName)
frs := &fakeReleaseServer{
t: t,
tag: tag,
binaryContent: binary,
archiveBytes: archive,
checksumLine: checksum,
bundleBytes: []byte("dummy-sigstore-bundle"),
}
mux := http.NewServeMux()
mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
assets := []map[string]any{
{
"name": layout.ArchiveName,
"browser_download_url": base + "/download/" + layout.ArchiveName,
},
{
"name": checksumFileName,
"browser_download_url": base + "/download/" + checksumFileName,
},
}
if !frs.omitBundle {
assets = append(assets, map[string]any{
"name": checksumBundleFileName,
"browser_download_url": base + "/download/" + checksumBundleFileName,
})
}
body := []map[string]any{
{
"tag_name": frs.tag,
"draft": frs.draft,
"prerelease": frs.prerelease,
"assets": assets,
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
_ = version
})
mux.HandleFunc("/download/"+layout.ArchiveName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(frs.archiveBytes)
})
mux.HandleFunc("/download/"+checksumFileName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(frs.checksumLine))
})
mux.HandleFunc("/download/"+checksumBundleFileName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(frs.bundleBytes)
})
frs.server = httptest.NewServer(mux)
t.Cleanup(frs.server.Close)
return frs
}
func (f *fakeReleaseServer) URL() string { return f.server.URL }
func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
if layout.IsZip {
return buildZip(t, layout, binary)
}
return buildTarGz(t, layout, binary)
}
func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, layout.ArchiveName)
f, err := os.Create(out)
require.NoError(t, err)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: path.Join(layout.ArchiveDir, layout.BinaryName),
Mode: 0o755,
Size: int64(len(binary)),
Typeflag: tar.TypeReg,
}))
_, err = tw.Write(binary)
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gz.Close())
require.NoError(t, f.Close())
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}
func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, layout.ArchiveName)
f, err := os.Create(out)
require.NoError(t, err)
zw := zip.NewWriter(f)
w, err := zw.Create(path.Join(layout.ArchiveDir, layout.BinaryName))
require.NoError(t, err)
_, err = w.Write(binary)
require.NoError(t, err)
require.NoError(t, zw.Close())
require.NoError(t, f.Close())
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}
func newTestUpdater(server *fakeReleaseServer, currentVersion, exePath, goos, goarch string) *Updater {
return &Updater{
Repo: "getprobo/probo",
TagPrefix: DefaultTagPrefix,
APIBaseURL: server.URL(),
AssetBaseURL: server.URL(),
CurrentVersion: currentVersion,
ExePath: exePath,
UserAgent: "probo-agent-test/0.0.0",
Logger: log.NewLogger(log.WithName("update-test")),
HTTP: &http.Client{
Transport: httpclient.DefaultPooledTransport(
httpclient.WithSSRFProtection(),
httpclient.WithSSRFAllowLoopback(),
),
},
// Tests bypass the cosign verifier; production code wires
// CosignVerifier in via Updater.SigstoreCacheDir.
Verifier: AllowAllVerifier{},
GOOS: goos,
GOARCH: goarch,
}
}
func TestUpdater_CheckLatest(t *testing.T) {
t.Parallel()
t.Run(
"returns release when newer version is available",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
assert.Equal(t, "0.2.0", rel.Version)
assert.Equal(t, layout.ArchiveName, rel.AssetName)
},
)
t.Run(
"returns ErrNoUpdateAvailable when running latest",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("darwin", "arm64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("same"))
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "darwin", "arm64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
},
)
t.Run(
"skips draft and prerelease tags",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0-rc.1", "0.2.0-rc.1", layout, []byte("rc"))
fake.prerelease = true
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
},
)
t.Run(
"dev build always sees update available",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("rel"))
u := newTestUpdater(fake, "dev", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
assert.Equal(t, "0.1.0", rel.Version)
},
)
}
func TestUpdater_Apply(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("apply test exercises the unix swap path; windows has its own .old shuffle")
}
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
require.NoError(t, u.Apply(context.Background(), rel))
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("new-binary"), got)
stat, err := os.Stat(exePath)
require.NoError(t, err)
assert.NotZero(t, stat.Mode().Perm()&0o100, "new binary should be executable")
}
func TestUpdater_CheckLatest_SkipsUnsignedRelease(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
fake.omitBundle = true
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable, "release without a sigstore bundle must be ignored")
}
func TestUpdater_Apply_RejectsBadSignature(t *testing.T) {
t.Parallel()
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
u.Verifier = rejectAllVerifier{err: fmt.Errorf("test: signer identity mismatch")}
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
err = u.Apply(context.Background(), rel)
require.Error(t, err)
assert.Contains(t, err.Error(), "sigstore")
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("old-binary"), got, "rejected signature must not touch the running binary")
}
func TestUpdater_Apply_RejectsCorruptedArchive(t *testing.T) {
t.Parallel()
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
// Corrupt the archive without updating checksums.
fake.archiveBytes = append(fake.archiveBytes, 0xff)
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
err = u.Apply(context.Background(), rel)
require.Error(t, err)
assert.Contains(t, err.Error(), "checksum mismatch")
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("old-binary"), got, "corrupted update must not touch the running binary")
}