Files
Bose-SoundTouch/pkg/ssh/ssh_test.go
T
Tobias Gesellchen 2b0172c21b fix(ssh): reuse one SSH connection across RevertMigration's ~17 calls
Confirmed on real hardware (192.168.178.28): RevertMigration's full call
graph (revertXMLConfig/revertHosts/revertResolvConf/revertAftertouchHook/
removeRcLocalHooks/revertCACert) makes 17 separate client.Run() calls, and
pkg/ssh.Client.Run/UploadContent each dialed a brand-new SSH connection
per call with no reuse. Hitting a resource-constrained speaker with 17
rapid reconnects overwhelmed it -- confirmed via a follow-up plain SSH
command timing out at the TCP level, and the speaker going visibly
unresponsive.

Gives pkg/ssh.Client an opt-in persistent connection: Connect() dials
once and caches it, Close() releases it, and a shared dial() helper makes
Run/UploadContent reuse the cached connection when one's open, falling
back to today's per-call dial otherwise. RevertMigration now calls
Connect() once and defer Close(), collapsing 17 connections into 1. The
other ~21 m.NewSSH() call sites in pkg/service/setup never call Connect,
so their behavior is completely unchanged -- this only touches the one
function that was actually causing real-world problems.

SSHClient interface gained Connect()/Close(); both test mocks
(pkg/service/setup/setup_test.go, pkg/service/handlers/handlers_setup_test.go)
got no-op stubs. Added TestClose_NoOpWithoutConnect and
TestConnect_DialFailureLeavesConnNil in pkg/ssh/ssh_test.go -- these don't
prove connection reuse against a real server (Client.Run hardcodes :22,
no configurable port for a test listener), so that specific behavior is
verified by code review (a single `if c.conn != nil` branch) plus the
real-hardware confirmation above, not an automated integration test.

Also fixes the web UI's "Revert to Defaults" button, which calls the same
RevertMigration code path.
2026-08-16 15:54:00 +02:00

77 lines
1.7 KiB
Go

package ssh
import (
"strings"
"testing"
)
func TestNewClient(t *testing.T) {
host := "192.0.2.10"
client := NewClient(host)
if client.Host != host {
t.Errorf("Expected host %s, got %s", host, client.Host)
}
if client.User != "root" {
t.Errorf("Expected user root, got %s", client.User)
}
}
func TestGetConfig(t *testing.T) {
client := NewClient("localhost")
config := client.getConfig()
if config.User != "root" {
t.Errorf("Expected config user root, got %s", config.User)
}
if len(config.Auth) == 0 {
t.Error("Expected at least one auth method")
}
}
func TestRun_DialFailure(t *testing.T) {
// Use an invalid port/host to trigger dial failure
client := NewClient("127.0.0.1:0")
_, err := client.Run("ls")
if err == nil {
t.Error("Expected dial failure, got nil")
}
if !strings.Contains(err.Error(), "failed to dial") {
t.Errorf("Expected 'failed to dial' error, got: %v", err)
}
}
func TestClose_NoOpWithoutConnect(t *testing.T) {
client := NewClient("127.0.0.1")
if err := client.Close(); err != nil {
t.Errorf("Close on a never-connected client should be a no-op, got: %v", err)
}
}
func TestConnect_DialFailureLeavesConnNil(t *testing.T) {
client := NewClient("127.0.0.1:0")
err := client.Connect()
if err == nil {
t.Fatal("Expected dial failure, got nil")
}
if !strings.Contains(err.Error(), "failed to dial") {
t.Errorf("Expected 'failed to dial' error, got: %v", err)
}
if client.conn != nil {
t.Error("Connect should leave conn nil after a dial failure, so Run/UploadContent still fall back to their own one-off dial")
}
// Close after a failed Connect should still be a harmless no-op.
if err := client.Close(); err != nil {
t.Errorf("Close after a failed Connect should be a no-op, got: %v", err)
}
}