mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
b8a60db6fa
Back-port the agentupdate half of v5 fix 8600706da to v6 (the install.sh
FreeBSD rc.d supervisor-pidfile change is already present in substance).
performUpdate now resolves the executable via resolveExecutablePath, which
falls back to an absolute, existing os.Args[0] when os.Executable() fails
or returns empty — the condition that breaks agent self-update on
FreeBSD/OPNsense. Adds an injectable osArgsFn and a unit test.
569 lines
14 KiB
Go
569 lines
14 KiB
Go
package agentupdate
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func TestDetermineArch(t *testing.T) {
|
|
// Test that determineArch returns a non-empty string on common platforms
|
|
result := determineArch()
|
|
|
|
// On known platforms, should return os-arch format
|
|
switch runtime.GOOS {
|
|
case "linux", "darwin", "windows":
|
|
if result == "" {
|
|
t.Errorf("determineArch() returned empty string on %s/%s", runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
// Should contain the OS
|
|
if len(result) < len(runtime.GOOS) {
|
|
t.Errorf("determineArch() = %q, expected to start with %s", result, runtime.GOOS)
|
|
}
|
|
|
|
// Should be in format "os-arch"
|
|
expectedPrefix := runtime.GOOS + "-"
|
|
if result[:len(expectedPrefix)] != expectedPrefix {
|
|
t.Errorf("determineArch() = %q, expected to start with %q", result, expectedPrefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetermineArch_Format(t *testing.T) {
|
|
result := determineArch()
|
|
if result == "" {
|
|
t.Skip("determineArch returned empty string on this platform")
|
|
}
|
|
|
|
// Result should contain a dash separating OS and arch
|
|
found := false
|
|
for i := 0; i < len(result); i++ {
|
|
if result[i] == '-' {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("determineArch() = %q, expected format os-arch with dash separator", result)
|
|
}
|
|
}
|
|
|
|
func TestUnraidPersistentPath(t *testing.T) {
|
|
tests := []struct {
|
|
agentName string
|
|
expected string
|
|
}{
|
|
{
|
|
agentName: "pulse-agent",
|
|
expected: "/boot/config/plugins/pulse-agent/pulse-agent",
|
|
},
|
|
{
|
|
agentName: "custom-agent",
|
|
expected: "/boot/config/plugins/custom-agent/custom-agent",
|
|
},
|
|
{
|
|
agentName: "",
|
|
expected: "/boot/config/plugins//",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.agentName, func(t *testing.T) {
|
|
result := unraidPersistentPath(tc.agentName)
|
|
if result != tc.expected {
|
|
t.Errorf("unraidPersistentPath(%q) = %q, want %q", tc.agentName, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQnapPersistentPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stateDir string
|
|
agentName string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "canonical qnap state dir",
|
|
stateDir: "/share/CACHEDEV1_DATA/.pulse-agent",
|
|
agentName: "pulse-agent",
|
|
expected: "/share/CACHEDEV1_DATA/.pulse-agent/pulse-agent",
|
|
},
|
|
{
|
|
name: "trims state dir whitespace",
|
|
stateDir: " /share/MD0_DATA/.pulse-agent ",
|
|
agentName: "custom-agent",
|
|
expected: "/share/MD0_DATA/.pulse-agent/custom-agent",
|
|
},
|
|
{
|
|
name: "empty state dir",
|
|
stateDir: "",
|
|
agentName: "pulse-agent",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "invalid agent name",
|
|
stateDir: "/share/CACHEDEV1_DATA/.pulse-agent",
|
|
agentName: "../pulse-agent",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := qnapPersistentPath(tc.stateDir, tc.agentName)
|
|
if result != tc.expected {
|
|
t.Errorf("qnapPersistentPath(%q, %q) = %q, want %q", tc.stateDir, tc.agentName, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_ELF(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("ELF verification test only runs on Linux")
|
|
}
|
|
|
|
// Create a temp file with ELF magic bytes
|
|
tmpDir := t.TempDir()
|
|
elfPath := filepath.Join(tmpDir, "test_elf")
|
|
|
|
// ELF magic: 0x7f 'E' 'L' 'F' followed by some data
|
|
elfData := []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}
|
|
if err := os.WriteFile(elfPath, elfData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(elfPath)
|
|
if err != nil {
|
|
t.Errorf("verifyBinaryMagic() error = %v for valid ELF", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_InvalidELF(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("ELF verification test only runs on Linux")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
invalidPath := filepath.Join(tmpDir, "invalid")
|
|
|
|
// Write invalid magic bytes
|
|
invalidData := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
|
if err := os.WriteFile(invalidPath, invalidData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(invalidPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for invalid binary")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_MachO64(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("Mach-O verification test only runs on macOS")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
machoPath := filepath.Join(tmpDir, "test_macho")
|
|
|
|
// Mach-O 64-bit magic (little-endian): 0xcf 0xfa 0xed 0xfe
|
|
machoData := []byte{0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01}
|
|
if err := os.WriteFile(machoPath, machoData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(machoPath)
|
|
if err != nil {
|
|
t.Errorf("verifyBinaryMagic() error = %v for valid Mach-O", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_MachO32(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("Mach-O verification test only runs on macOS")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
machoPath := filepath.Join(tmpDir, "test_macho32")
|
|
|
|
// Mach-O 32-bit magic (little-endian): 0xce 0xfa 0xed 0xfe
|
|
machoData := []byte{0xce, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01}
|
|
if err := os.WriteFile(machoPath, machoData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(machoPath)
|
|
if err != nil {
|
|
t.Errorf("verifyBinaryMagic() error = %v for valid Mach-O 32-bit", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_MachOUniversal(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("Mach-O verification test only runs on macOS")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
machoPath := filepath.Join(tmpDir, "test_macho_fat")
|
|
|
|
// Mach-O universal/fat binary magic: 0xca 0xfe 0xba 0xbe
|
|
machoData := []byte{0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x02}
|
|
if err := os.WriteFile(machoPath, machoData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(machoPath)
|
|
if err != nil {
|
|
t.Errorf("verifyBinaryMagic() error = %v for valid Mach-O universal", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_InvalidMachO(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("Mach-O verification test only runs on macOS")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
invalidPath := filepath.Join(tmpDir, "invalid")
|
|
|
|
// Write invalid magic bytes
|
|
invalidData := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
|
if err := os.WriteFile(invalidPath, invalidData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(invalidPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for invalid Mach-O binary")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_PE(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("PE verification test only runs on Windows")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
pePath := filepath.Join(tmpDir, "test_pe.exe")
|
|
|
|
// PE magic: 'M' 'Z'
|
|
peData := []byte{'M', 'Z', 0x90, 0x00, 0x03, 0x00, 0x00, 0x00}
|
|
if err := os.WriteFile(pePath, peData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(pePath)
|
|
if err != nil {
|
|
t.Errorf("verifyBinaryMagic() error = %v for valid PE", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_InvalidPE(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("PE verification test only runs on Windows")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
invalidPath := filepath.Join(tmpDir, "invalid.exe")
|
|
|
|
// Write invalid magic bytes (not MZ)
|
|
invalidData := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
|
if err := os.WriteFile(invalidPath, invalidData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(invalidPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for invalid PE binary")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_NonexistentFile(t *testing.T) {
|
|
err := verifyBinaryMagic("/nonexistent/path/to/binary")
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_TooShort(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
shortPath := filepath.Join(tmpDir, "short")
|
|
|
|
// Write only 2 bytes (less than 4 required for magic)
|
|
shortData := []byte{0x7f, 'E'}
|
|
if err := os.WriteFile(shortPath, shortData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(shortPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for file too short to read magic")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_EmptyFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
emptyPath := filepath.Join(tmpDir, "empty")
|
|
|
|
// Write empty file
|
|
if err := os.WriteFile(emptyPath, []byte{}, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(emptyPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for empty file")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryMagic_TextFile(t *testing.T) {
|
|
// Skip on unknown platforms where verification is skipped
|
|
switch runtime.GOOS {
|
|
case "linux", "darwin", "windows":
|
|
// continue with test
|
|
default:
|
|
t.Skip("Platform verification skipped on unknown OS")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
textPath := filepath.Join(tmpDir, "script.sh")
|
|
|
|
// Write a shell script (not a binary)
|
|
textData := []byte("#!/bin/bash\necho hello\n")
|
|
if err := os.WriteFile(textPath, textData, 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
err := verifyBinaryMagic(textPath)
|
|
if err == nil {
|
|
t.Error("verifyBinaryMagic() expected error for text file")
|
|
}
|
|
}
|
|
|
|
func TestIsUnraid(t *testing.T) {
|
|
// isUnraid checks for /etc/unraid-version
|
|
// On non-Unraid systems, this should return false
|
|
result := isUnraid()
|
|
|
|
// Check if /etc/unraid-version exists
|
|
_, err := os.Stat("/etc/unraid-version")
|
|
expected := err == nil
|
|
|
|
if result != expected {
|
|
t.Errorf("isUnraid() = %v, want %v", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestConfig_Defaults(t *testing.T) {
|
|
cfg := Config{}
|
|
|
|
// Verify zero values
|
|
if cfg.PulseURL != "" {
|
|
t.Error("PulseURL should be empty by default")
|
|
}
|
|
if cfg.APIToken != "" {
|
|
t.Error("APIToken should be empty by default")
|
|
}
|
|
if cfg.AgentName != "" {
|
|
t.Error("AgentName should be empty by default")
|
|
}
|
|
if cfg.CurrentVersion != "" {
|
|
t.Error("CurrentVersion should be empty by default")
|
|
}
|
|
if cfg.CheckInterval != 0 {
|
|
t.Error("CheckInterval should be zero by default")
|
|
}
|
|
if cfg.InsecureSkipVerify {
|
|
t.Error("InsecureSkipVerify should be false by default")
|
|
}
|
|
if cfg.Logger != nil {
|
|
t.Error("Logger should be nil by default")
|
|
}
|
|
if cfg.Disabled {
|
|
t.Error("Disabled should be false by default")
|
|
}
|
|
}
|
|
|
|
func TestNew_DefaultCheckInterval(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
// CheckInterval not set
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
// Should have defaulted to 1 hour
|
|
if updater.cfg.CheckInterval != 1*time.Hour {
|
|
t.Errorf("CheckInterval = %v, want 1h", updater.cfg.CheckInterval)
|
|
}
|
|
}
|
|
|
|
func TestNew_CustomCheckInterval(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
CheckInterval: 30 * time.Minute,
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
// Should preserve custom interval
|
|
if updater.cfg.CheckInterval != 30*time.Minute {
|
|
t.Errorf("CheckInterval = %v, want 30m", updater.cfg.CheckInterval)
|
|
}
|
|
}
|
|
|
|
func TestNew_NegativeCheckIntervalUsesDefault(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
CheckInterval: -30 * time.Second,
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
if updater.cfg.CheckInterval != 1*time.Hour {
|
|
t.Errorf("CheckInterval = %v, want 1h default", updater.cfg.CheckInterval)
|
|
}
|
|
}
|
|
|
|
func TestNew_WithLogger(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
Logger: &logger,
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
// Should not panic and should create valid updater
|
|
if updater == nil {
|
|
t.Error("New() returned nil")
|
|
}
|
|
}
|
|
|
|
func TestNew_NilLogger(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
Logger: nil,
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
// Should not panic and should create valid updater with nop logger
|
|
if updater == nil {
|
|
t.Error("New() returned nil")
|
|
}
|
|
}
|
|
|
|
func TestNew_InsecureSkipVerify(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
InsecureSkipVerify: true,
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
if !updater.cfg.InsecureSkipVerify {
|
|
t.Error("InsecureSkipVerify should be true")
|
|
}
|
|
}
|
|
|
|
func TestNew_ClientNotNil(t *testing.T) {
|
|
cfg := Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
APIToken: "test-token",
|
|
AgentName: "pulse-agent",
|
|
CurrentVersion: "1.0.0",
|
|
}
|
|
|
|
updater := New(cfg)
|
|
|
|
if updater.client == nil {
|
|
t.Error("client should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestConstants(t *testing.T) {
|
|
// Verify maxBinarySize is reasonable (100 MB)
|
|
if maxBinarySize != 100*1024*1024 {
|
|
t.Errorf("maxBinarySize = %d, want %d", maxBinarySize, 100*1024*1024)
|
|
}
|
|
|
|
// Verify downloadTimeout is reasonable (5 minutes)
|
|
if downloadTimeout != 5*time.Minute {
|
|
t.Errorf("downloadTimeout = %v, want 5m", downloadTimeout)
|
|
}
|
|
}
|
|
|
|
func TestResolveExecutablePath(t *testing.T) {
|
|
origExec := osExecutableFn
|
|
origArgs := osArgsFn
|
|
t.Cleanup(func() {
|
|
osExecutableFn = origExec
|
|
osArgsFn = origArgs
|
|
})
|
|
|
|
t.Run("returns os.Executable when available", func(t *testing.T) {
|
|
osExecutableFn = func() (string, error) { return "/usr/local/bin/pulse-agent", nil }
|
|
osArgsFn = func() []string { return []string{"argv0"} }
|
|
got, err := resolveExecutablePath()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "/usr/local/bin/pulse-agent" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to absolute argv0 when os.Executable is empty", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "pulse-agent")
|
|
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
osExecutableFn = func() (string, error) { return "", nil }
|
|
osArgsFn = func() []string { return []string{bin} }
|
|
got, err := resolveExecutablePath()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != bin {
|
|
t.Fatalf("got %q, want %q", got, bin)
|
|
}
|
|
})
|
|
|
|
t.Run("errors when neither source is usable", func(t *testing.T) {
|
|
osExecutableFn = func() (string, error) { return "", errors.New("boom") }
|
|
osArgsFn = func() []string { return []string{"relative-arg"} }
|
|
if _, err := resolveExecutablePath(); err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
})
|
|
}
|