package setup import ( "encoding/base64" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) type mockSSH struct { runFunc func(command string) (string, error) uploadContentFunc func(content []byte, remotePath string) error // uploaded mirrors UploadContent calls so that a subsequent // `cat ` against a path that the test didn't explicitly // script via runFunc returns what we just wrote there. This is // what makes the tmp-then-mv flow in TrustCACertFromBytes work // against tests that only scripted the live-bundle path. Tests // that *do* script `cat ` keep priority — runFunc is // consulted first and the upload mirror is the fallback. uploaded map[string][]byte } // probeScriptHeader is the first line of the batched probe script // emitted by buildSpeakerProbeScript. We use it as a sentinel so that // per-command test mocks (which only know `cat` / `[ -f ]` / etc.) can // still satisfy GetMigrationSummary after the SSH probes were batched // into a single Run() call — the mock synthesizes the framed probe // response by invoking its existing runFunc for each path the script // would have probed. const probeScriptHeader = "echo '@SSH_OK@'" func (m *mockSSH) Run(command string) (string, error) { if strings.HasPrefix(command, probeScriptHeader) { return m.synthesizeProbeResponse(command) } if m.runFunc != nil { out, err := m.runFunc(command) if err != nil { return out, err } if out != "" { return out, nil } // runFunc returned ("", nil) — fall through to the upload // mirror so tmp readbacks that the test didn't script // explicitly still produce the bytes we just wrote there. } if strings.HasPrefix(command, "cat ") { path := strings.TrimPrefix(command, "cat ") if body, ok := m.uploaded[path]; ok { return string(body), nil } } return "", nil } // synthesizeProbeResponse parses the batched probe script for the file // and existence paths it references, calls the test's runFunc to find // out what each one "contains," and emits the framed response format // that parseSpeakerProbe expects. Lets existing per-command test mocks // drive the batched probe without any test-side changes. // // If runFunc errors on a simple reachability probe (`ls /`), we treat // the SSH connection as down and return the same error — matching the // behaviour tests expect when they wire a runFunc that errors on every // command. func (m *mockSSH) synthesizeProbeResponse(script string) (string, error) { if m.runFunc == nil { return "@SSH_OK@\n", nil } // SSH-reachability probe: if a simple read fails, the connection // itself is "down" in mock-land and the real batched script would // also have produced an error from ssh.Dial. if _, err := m.runFunc("ls /"); err != nil { return "", err } var out strings.Builder out.WriteString("@SSH_OK@\n") fileRE := regexp.MustCompile(`\[ -f '([^']+)' \]`) for _, match := range fileRE.FindAllStringSubmatch(script, -1) { path := match[1] content, err := m.runFunc("cat " + path) if err != nil || content == "" { continue } out.WriteString("@FILE@" + path + "@\n") out.WriteString(base64.StdEncoding.EncodeToString([]byte(content))) out.WriteString("\n@END@\n") } existsRE := regexp.MustCompile(`\[ -e '([^']+)' \]`) for _, match := range existsRE.FindAllStringSubmatch(script, -1) { path := match[1] if _, err := m.runFunc("[ -e " + path + " ]"); err == nil { out.WriteString("@EXISTS@" + path + "@\n") } } return out.String(), nil } func (m *mockSSH) UploadContent(content []byte, remotePath string) error { if m.uploaded == nil { m.uploaded = make(map[string][]byte) } m.uploaded[remotePath] = append([]byte(nil), content...) if m.uploadContentFunc != nil { return m.uploadContentFunc(content, remotePath) } return nil } // Connect/Close are no-ops here — the mock has no real connection to // reuse, and every test call already goes through Run/UploadContent above // regardless of whether Connect was called first. func (m *mockSSH) Connect() error { return nil } func (m *mockSSH) Close() error { return nil } func TestMigrateViaHosts(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://192.0.2.100:8000", nil, cm) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /etc/hosts" { // Handle both initial read and verification read if len(runCalls) > 2 { // Rough heuristic: verification happens after upload return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } if strings.HasPrefix(command, "grep -F") { return "", fmt.Errorf("not found") } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { if remotePath == "/etc/hosts" { if !strings.Contains(string(content), "192.0.2.100\tstreaming.bose.com") { t.Errorf("Expected hosts content to contain redirect, got %s", string(content)) } } return nil }, } } _, err = m.migrateViaHosts("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaHosts failed: %v", err) } // Verify backups were attempted foundHostsBackup := false foundBundleBackup := false for _, call := range runCalls { if strings.Contains(call, "cp /etc/hosts /etc/hosts.original") { foundHostsBackup = true } if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") { foundBundleBackup = true } } if !foundHostsBackup { t.Errorf("Expected /etc/hosts backup to be attempted") } if !foundBundleBackup { t.Errorf("Expected ca-bundle.crt backup to be attempted") } // Verify reboot was NOT called foundReboot := false for _, call := range runCalls { if strings.Contains(call, "reboot") { foundReboot = true break } } if foundReboot { t.Errorf("Expected reboot NOT to be called automatically") } } func TestMigrateViaHosts_UpdateExisting(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-update") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) _ = cm.EnsureCA() m := NewManager("http://192.0.2.100:8000", nil, cm) m.NewSSH = func(host string) SSHClient { runCount := 0 return &mockSSH{ runFunc: func(command string) (string, error) { runCount++ if command == "cat /etc/hosts" { if runCount > 1 { return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil } if strings.HasPrefix(command, "[ -f") { return "", nil // Backup already exists } if strings.HasPrefix(command, "grep -F") { return "matched", nil // CA already trusted } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { if remotePath == "/etc/hosts" { c := string(content) if !strings.Contains(c, "192.0.2.100\tstreaming.bose.com") { t.Errorf("Expected updated IP for streaming.bose.com, got:\n%s", c) } if !strings.Contains(c, "192.0.2.100\tupdates.bose.com") { t.Errorf("Expected updated IP for updates.bose.com, got:\n%s", c) } if !strings.Contains(c, "192.0.2.100\tevents.api.bosecm.com") { t.Errorf("Expected new domain events.api.bosecm.com, got:\n%s", c) } // Ensure no duplicates if strings.Count(c, "streaming.bose.com") != 1 { t.Errorf("Expected streaming.bose.com to appear exactly once, got %d", strings.Count(c, "streaming.bose.com")) } } return nil }, } } _, err = m.migrateViaHosts("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaHosts failed: %v", err) } } func TestGetLiveDeviceInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/info" { t.Errorf("Expected to request /info, got %s", r.URL.Path) } w.Header().Set("Content-Type", "application/xml") _, _ = fmt.Fprint(w, ` Test Speaker SoundTouch 20 SCM 19.0.5 AABBCCDDEE0A `) })) defer server.Close() // Extract IP and port from the test server URL // The test server URL is like http://127.0.0.1:54321 host := server.Listener.Addr().String() manager := NewManager("http://localhost:8000", nil, nil) info, err := manager.GetLiveDeviceInfo(host) if err != nil { t.Fatalf("Failed to get live device info: %v", err) } if info.Name != "Test Speaker" { t.Errorf("Expected Name 'Test Speaker', got '%s'", info.Name) } if info.SoftwareVer != "19.0.5" { t.Errorf("Expected SoftwareVer '19.0.5', got '%s'", info.SoftwareVer) } if info.SerialNumber != "AABBCCDDEE0A" { t.Errorf("Expected SerialNumber 'AABBCCDDEE0A', got '%s'", info.SerialNumber) } } func TestGetMigrationSummary_SSHFailure(t *testing.T) { // Use an IP that is unlikely to have an SSH server running or reachable // or use a local port that is closed. // We'll use a local port that we know is closed. manager := NewManager("http://localhost:8000", nil, nil) summary, err := manager.GetMigrationSummary("127.0.0.1", "", "", nil) // Currently it might return an error OR it might return a summary with SSHSuccess: false // but the issue description says the user is told connection SUCCEEDED. if err == nil { if summary.SSHSuccess { t.Errorf("Expected SSHSuccess to be false for closed port, got true") } if summary.CurrentConfig == "" { t.Errorf("Expected CurrentConfig to contain error message, got empty string") } } else { t.Errorf("Expected no error from GetMigrationSummary, got %v", err) } } func TestGetMigrationSummary_WithProxyOptions(t *testing.T) { // Setup a mock server for live info server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") _, _ = fmt.Fprint(w, `Test`) })) defer server.Close() host := server.Listener.Addr().String() manager := NewManager("http://st-service:8000", nil, nil) // Since we can't easily mock SSH here without a full SSH server, // we are testing the logic that depends on ParsedCurrentConfig being nil or not. // However, GetMigrationSummary tries to connect via SSH. // If SSH fails, ParsedCurrentConfig will be nil. options := map[string]string{ "marge": "proxied", "stats": "self", "sw_update": "proxied", "bmx": "self", } summary, err := manager.GetMigrationSummary(host, "http://target:8000", "http://proxy:8000", options) if err != nil { t.Fatalf("GetMigrationSummary failed: %v", err) } // When SSH fails (which it will here), PlannedConfig should be the default one for target:8000 if !contains(summary.PlannedConfig, "http://target:8000") { t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig) } // Test PlannedResolv if !contains(summary.PlannedResolv, "nameserver target") { t.Errorf("Expected PlannedResolv to contain nameserver target, got: %s", summary.PlannedResolv) } } func TestCheckCACertTrusted(t *testing.T) { tempDir, err := os.MkdirTemp("", "ca-trust-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) // Test 1: Found via label m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.HasPrefix(command, "grep -F") && strings.Contains(command, CALabel) { return CALabel, nil } return "", nil }, } } summary := &MigrationSummary{} m.checkCACertTrusted(summary, "192.0.2.10") if !summary.CACertTrusted { t.Errorf("Expected CACertTrusted to be true when label is found") } // Test 2: Found via data snippet (label missing) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.HasPrefix(command, "grep -F") { if strings.Contains(command, CALabel) { return "", fmt.Errorf("not found") } // Searching for cert data return "found data", nil } return "", nil }, } } summary = &MigrationSummary{} m.checkCACertTrusted(summary, "192.0.2.10") if !summary.CACertTrusted { t.Errorf("Expected CACertTrusted to be true when cert data is found") } // Test 3: Not found m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.HasPrefix(command, "grep -F") { return "", fmt.Errorf("not found") } return "", nil }, } } summary = &MigrationSummary{} m.checkCACertTrusted(summary, "192.0.2.10") if summary.CACertTrusted { t.Errorf("Expected CACertTrusted to be false when nothing is found") } } // TestCheckCACertTrustedStaleLabel is the #517 regression: a previous // migration's CALabel survived a service-CA regeneration, so the label is in the // bundle but the *current* CA payload is not. The device must then be treated as // NOT trusting the new CA (so the migration re-installs it), instead of being // falsely skipped on the stale label. func TestCheckCACertTrustedStaleLabel(t *testing.T) { tempDir, err := os.MkdirTemp("", "ca-trust-stale") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) m.NewSSH = func(_ string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { // Stale label is still present... if strings.Contains(command, CALabel) { return CALabel, nil } // ...but the current CA payload is not in the bundle. return "", fmt.Errorf("not found") }, } } summary := &MigrationSummary{} m.checkCACertTrusted(summary, "192.0.2.10") if summary.CACertTrusted { t.Error("stale label without a matching CA payload must not count as trusted (should re-install)") } } // TestCheckCACertTrustedNoCryptoFallback verifies that when the service CA is // not available (e.g. a CLI caller without Crypto), the injected label remains // the trust signal. func TestCheckCACertTrustedNoCryptoFallback(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) m.NewSSH = func(_ string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.Contains(command, CALabel) { return CALabel, nil } return "", fmt.Errorf("not found") }, } } summary := &MigrationSummary{} m.checkCACertTrusted(summary, "192.0.2.10") if !summary.CACertTrusted { t.Error("without Crypto, a present label should count as trusted") } } func TestTestConnection(t *testing.T) { tempDir, err := os.MkdirTemp("", "test-connection") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) runCalls := []string{} uploadCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if strings.Contains(command, "curl") { return "HTTP/1.1 200 OK", nil } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploadCalls = append(uploadCalls, remotePath) return nil }, } } // Test 1: Shared trust store (no explicit CA) output, err := m.TestConnection("192.0.2.10", "https://localhost:8443/health", false) if err != nil { t.Fatalf("TestConnection failed: %v", err) } if !strings.Contains(output, "200 OK") { t.Errorf("Expected output to contain '200 OK', got %s", output) } if len(uploadCalls) != 0 { t.Errorf("Expected no uploads for shared trust store test, got %v", uploadCalls) } // Test 2: Explicit CA output, err = m.TestConnection("192.0.2.10", "https://localhost:8443/health", true) if err != nil { t.Fatalf("TestConnection failed: %v", err) } if !strings.Contains(output, "200 OK") { t.Errorf("Expected output to contain '200 OK', got %s", output) } foundUpload := false for _, path := range uploadCalls { if path == "/tmp/soundtouch-test-ca.crt" { foundUpload = true break } } if !foundUpload { t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt") } foundCurlWithCA := false for _, call := range runCalls { if strings.Contains(call, "curl") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") { foundCurlWithCA = true break } } if !foundCurlWithCA { t.Errorf("Expected curl command to use --cacert") } // Verify cleanup foundRm := false for _, call := range runCalls { if call == "rm /tmp/soundtouch-test-ca.crt" { foundRm = true break } } if !foundRm { t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called") } } func TestTestHostsRedirection(t *testing.T) { tempDir, err := os.MkdirTemp("", "hosts-redirection-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) runCalls := []string{} uploadCalls := []string{} var currentHostsContent = "127.0.0.1 localhost\n" m.NewSSH = func(host string) SSHClient { mock := &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /etc/hosts" { return currentHostsContent, nil } if strings.Contains(command, "curl") { return "HTTP/1.1 200 OK", nil } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploadCalls = append(uploadCalls, remotePath) if remotePath == "/etc/hosts" { currentHostsContent = string(content) if strings.Contains(string(content), "custom-test-api.bose.fake") { if !strings.Contains(string(content), "1.2.3.4\tcustom-test-api.bose.fake") { t.Errorf("Expected hosts content to contain test redirect with IP 1.2.3.4, got %s", string(content)) } } } return nil }, } return mock } output, err := m.TestHostsRedirection("192.0.2.10", "http://1.2.3.4:8000") if err != nil { t.Fatalf("TestHostsRedirection failed: %v", err) } if !strings.Contains(output, "200 OK") { t.Errorf("Expected output to contain '200 OK', got %s", output) } // Verify upload of test hosts foundHostsUpload := false foundCAUpload := false for _, path := range uploadCalls { if path == "/etc/hosts" { foundHostsUpload = true } if path == "/tmp/soundtouch-test-ca.crt" { foundCAUpload = true } } if !foundHostsUpload { t.Errorf("Expected /etc/hosts to be uploaded") } if !foundCAUpload { t.Errorf("Expected CA to be uploaded to /tmp/soundtouch-test-ca.crt") } // Verify curl calls for both HTTP and HTTPS foundHTTP := false foundHTTPSWithCA := false for _, call := range runCalls { if strings.Contains(call, "curl") { if strings.Contains(call, "http://") { foundHTTP = true } if strings.Contains(call, "https://") && strings.Contains(call, "--cacert /tmp/soundtouch-test-ca.crt") { foundHTTPSWithCA = true } } } if !foundHTTP { t.Errorf("Expected HTTP curl call") } if !foundHTTPSWithCA { t.Errorf("Expected HTTPS curl call with --cacert") } // Verify cleanup foundRmCA := false for _, call := range runCalls { if call == "rm /tmp/soundtouch-test-ca.crt" { foundRmCA = true break } } if !foundRmCA { t.Errorf("Expected cleanup command 'rm /tmp/soundtouch-test-ca.crt' to be called") } cleanupHostsCount := 0 for _, path := range uploadCalls { if path == "/etc/hosts" { cleanupHostsCount++ } } if cleanupHostsCount < 2 { t.Errorf("Expected at least 2 uploads to /etc/hosts (one for test, one for cleanup), got %d", cleanupHostsCount) } } func TestResolveIP(t *testing.T) { m := &Manager{} // IP passthrough: no resolution needed, no error ip, err := m.resolveIP("1.2.3.4", nil) if ip != "1.2.3.4" || err != nil { t.Errorf("Expected 1.2.3.4/nil, got %s/%v", ip, err) } // localhost resolves from service DNS; error expected (no SSH client) ip, err = m.resolveIP("localhost", nil) if ip != "127.0.0.1" && ip != "::1" { t.Errorf("Expected localhost resolution, got %s", ip) } if err == nil { t.Errorf("Expected error for service-side fallback, got nil") } // Device SSH ping succeeds: IP returned, no error mock := &mockSSH{ runFunc: func(command string) (string, error) { if strings.Contains(command, "ping -c 1 myhost") { return "PING myhost (10.0.0.5): 56 data bytes", nil } return "", nil }, } ip, err = m.resolveIP("myhost", mock) if ip != "10.0.0.5" || err != nil { t.Errorf("Expected 10.0.0.5/nil from device, got %s/%v", ip, err) } // Non-existent host, no SSH client: both methods fail, error returned ip, err = m.resolveIP("non-existent.host.fake", nil) if ip != "" { t.Errorf("Expected empty IP on failure, got %s", ip) } if err == nil { t.Errorf("Expected error for unresolvable host, got nil") } } // TestResolveIP_ServiceFallbackTagsSentinel pins the #282 fix: // service-side fallback returns the resolved IP wrapped with // ErrResolvedFromServiceOnly. Callers can errors.Is()-check the // sentinel to distinguish informational fallback from a hard // failure, which fixes the long-standing CLI/UI ❌ row that appeared // every time a hostname target was used with SSH off. func TestResolveIP_ServiceFallbackTagsSentinel(t *testing.T) { m := &Manager{} // No SSH client → forces the service-side fallback path. ip, err := m.resolveIP("localhost", nil) if ip != "127.0.0.1" && ip != "::1" { t.Fatalf("expected localhost to resolve service-side, got ip=%q err=%v", ip, err) } if err == nil { t.Fatalf("expected service-side fallback to surface ErrResolvedFromServiceOnly, got nil err") } if !errors.Is(err, ErrResolvedFromServiceOnly) { t.Errorf("expected error to wrap ErrResolvedFromServiceOnly, got %v", err) } } // TestResolveIP_DeviceSuccessReturnsNilError keeps the happy-path // guarantee explicit alongside the sentinel-tagging contract above. func TestResolveIP_DeviceSuccessReturnsNilError(t *testing.T) { m := &Manager{} mock := &mockSSH{ runFunc: func(command string) (string, error) { if strings.Contains(command, "ping -c 1 myhost") { return "PING myhost (10.0.0.5): 56 data bytes", nil } return "", nil }, } ip, err := m.resolveIP("myhost", mock) if ip != "10.0.0.5" { t.Errorf("expected 10.0.0.5, got %s", ip) } if err != nil { t.Errorf("device-side success must return nil error, got %v", err) } if errors.Is(err, ErrResolvedFromServiceOnly) { t.Errorf("device-side success must NOT carry ErrResolvedFromServiceOnly sentinel") } } func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-skip-ca") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://192.0.2.100:8000", nil, cm) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /etc/hosts" { // Handle both initial read and verification read if len(runCalls) > 2 { // Rough heuristic: verification happens after upload return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil } return "127.0.0.1 localhost", nil } if strings.HasPrefix(command, "grep -F") { // Simulate CA already trusted return "found", nil } return "", nil }, } } _, err = m.migrateViaHosts("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaHosts failed: %v", err) } // Verify CA injection was skipped foundCAInjection := false for _, call := range runCalls { if strings.Contains(call, "cat /tmp/local-ca.crt >> /etc/pki/tls/certs/ca-bundle.crt") { foundCAInjection = true break } } if foundCAInjection { t.Errorf("Expected CA injection to be skipped when already trusted") } } func TestTrustCACert(t *testing.T) { tempDir, err := os.MkdirTemp("", "trust-ca-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) runCalls := []string{} uploadCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } return "", nil // mockSSH automatically mirrors uploads back on // `cat ` when runFunc returns ("", nil), so the // post-upload tmp readback in TrustCACertFromBytes // works without test-side wiring. }, uploadContentFunc: func(content []byte, remotePath string) error { uploadCalls = append(uploadCalls, remotePath) return nil }, } } _, err = m.TrustCACert("192.0.2.10") if err != nil { t.Fatalf("TrustCACert failed: %v", err) } // Verify CA backup and injection foundBackup := false for _, call := range runCalls { if strings.Contains(call, "cp /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt.original") { foundBackup = true } } if !foundBackup { t.Errorf("Expected ca-bundle.crt backup") } // Verify CA upload landed on the tmp path (atomic-replace flow). foundTmpUpload := false for _, path := range uploadCalls { if path == "/etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp" { foundTmpUpload = true break } } if !foundTmpUpload { t.Errorf("Expected candidate bundle to be uploaded to ca-bundle.crt.aftertouch.tmp; got upload paths: %v", uploadCalls) } // Verify the live bundle was NOT touched directly by UploadContent — // the rename via Run() is the only path that touches the live file. for _, path := range uploadCalls { if path == "/etc/pki/tls/certs/ca-bundle.crt" { t.Errorf("UploadContent wrote directly to live bundle %s — atomic-replace flow expects tmp + mv only", path) } } // Verify the atomic rename ran and that no rm of the tmp happened // (rm only fires on a verification failure). foundMv := false foundRm := false for _, call := range runCalls { if call == "mv /etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp /etc/pki/tls/certs/ca-bundle.crt" { foundMv = true } if strings.HasPrefix(call, "rm -f /etc/pki/tls/certs/ca-bundle.crt.aftertouch.tmp") { foundRm = true } } if !foundMv { t.Errorf("Expected atomic mv from .aftertouch.tmp to live bundle; got run calls: %v", runCalls) } if foundRm { t.Errorf("Did not expect a cleanup rm on the happy path; got run calls: %v", runCalls) } } // TestTrustCACert_StripsMultipleStaleEntriesSilently pins the // behaviour the user flagged for AfterTouch installs that pre-date // the strip-then-append logic: live bundles in the field can carry // two or more copies of our CA from older releases that appended // without cleanup. The new install must: // // - strip every stale AfterTouch entry, // - log how many duplicates were cleaned up, // - append exactly one fresh entry, // - upload to the tmp path, // - verify and rename — i.e. the cleanup itself must not break the // validation or trigger the rollback path. func TestTrustCACert_StripsMultipleStaleEntriesSilently(t *testing.T) { tempDir, err := os.MkdirTemp("", "trust-ca-multi-") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) const ( bundlePath = "/etc/pki/tls/certs/ca-bundle.crt" tmpPath = bundlePath + ".aftertouch.tmp" ) // Pre-existing bundle: one legitimate upstream cert plus two // stale AfterTouch entries from old installs. Generated inline // to stay self-contained. upstream := generatePEMCertificate(t, "upstream-root") stale1 := generatePEMCertificate(t, "aftertouch-stale-1") stale2 := generatePEMCertificate(t, "aftertouch-stale-2") preexisting := string(upstream) + CALabel + "\n" + string(stale1) + CALabel + "\n" + CALabel + "\n" + string(stale2) + CALabel + "\n" runCalls := []string{} var sshMock *mockSSH m.NewSSH = func(_ string) SSHClient { sshMock = &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if strings.HasPrefix(command, "[ -f") { // .original doesn't exist yet → triggers initial backup return "", fmt.Errorf("file not found") } if command == "cat "+bundlePath { return preexisting, nil } return "", nil // Tmp readback falls through to mockSSH's upload mirror. }, } return sshMock } logs, err := m.TrustCACert("192.0.2.10") if err != nil { t.Fatalf("TrustCACert failed: %v", err) } if !strings.Contains(logs, "Cleaned up 2 duplicate AfterTouch CA entries") { t.Errorf("logs do not mention duplicate cleanup; got:\n%s", logs) } uploaded, ok := sshMock.uploaded[tmpPath] if !ok { t.Fatalf("nothing uploaded to %s; only got: %v", tmpPath, uploadKeys(sshMock.uploaded)) } // The uploaded bundle must contain exactly two sentinels (open + // close) bracketing exactly one CERTIFICATE block, regardless of // how many stale entries the input had. if err := validateAfterTouchLabelBracketing(uploaded); err != nil { t.Errorf("uploaded bundle has malformed AfterTouch bracketing despite the cleanup: %v", err) } // And the cleanup must not have dropped the legitimate upstream cert. count, err := validateCABundleBytes(uploaded) if err != nil { t.Fatalf("uploaded bundle does not validate: %v", err) } if count != 2 { t.Errorf("uploaded bundle has %d CERTIFICATE blocks, want 2 (the upstream root + our fresh AfterTouch CA)", count) } // Atomic rename should have fired, and no rollback rm. foundMv := false foundRm := false for _, call := range runCalls { if call == "mv "+tmpPath+" "+bundlePath { foundMv = true } if strings.HasPrefix(call, "rm -f "+tmpPath) { foundRm = true } } if !foundMv { t.Errorf("Expected atomic mv after cleanup; got run calls: %v", runCalls) } if foundRm { t.Errorf("Cleanup path triggered rollback rm — multi-entry input should not be a failure case; got: %v", runCalls) } } func uploadKeys(m map[string][]byte) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // TestTrustCACert_PostUploadVerificationFailureCleansUpTmp pins the // rollback-free recovery story from issue #262: when the tmp file's // readback doesn't validate (here we simulate transport truncation by // returning the tmp content stripped of its closing AfterTouch label), // the rename must NOT fire, the tmp must be removed, and the error // must name the verification failure plus reassure the caller the // live bundle wasn't touched. func TestTrustCACert_PostUploadVerificationFailureCleansUpTmp(t *testing.T) { tempDir, err := os.MkdirTemp("", "trust-ca-fail-") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://localhost:8000", nil, cm) const ( bundlePath = "/etc/pki/tls/certs/ca-bundle.crt" tmpPath = bundlePath + ".aftertouch.tmp" ) runCalls := []string{} var sshMock *mockSSH m.NewSSH = func(_ string) SSHClient { sshMock = &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) switch { case strings.HasPrefix(command, "[ -f"): return "", fmt.Errorf("file not found") case command == "cat "+tmpPath: // Simulate transport corruption: chop the closing // AfterTouch sentinel off the bytes mockSSH would // otherwise mirror back. Pre-upload validation // passed (the full bytes were well-formed), but // the readback doesn't bracket cleanly anymore. return strings.Replace(string(sshMock.uploaded[tmpPath]), "\n"+CALabel+"\n", "\n", 1), nil } return "", nil }, } return sshMock } _, err = m.TrustCACert("192.0.2.10") if err == nil { t.Fatalf("TrustCACert succeeded, want a verification failure") } if !strings.Contains(err.Error(), "verification of "+tmpPath+" failed") { t.Errorf("error does not name the verification target: %v", err) } if !strings.Contains(err.Error(), "live bundle untouched") { t.Errorf("error does not reassure that the live bundle was untouched: %v", err) } foundRm := false foundMv := false for _, call := range runCalls { if call == "rm -f "+tmpPath { foundRm = true } if strings.HasPrefix(call, "mv "+tmpPath) { foundMv = true } } if !foundRm { t.Errorf("Expected cleanup rm of %s after verification failure; got run calls: %v", tmpPath, runCalls) } if foundMv { t.Errorf("mv ran despite verification failure — live bundle was overwritten with bad content; run calls: %v", runCalls) } } func TestRevertMigration(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) runCalls := []string{} uploadCalls := make(map[string]string) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /etc/pki/tls/certs/ca-bundle.crt" { return "existing content\n" + CALabel + "\nCERT DATA\n" + CALabel + "\nmore content", nil } if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n# Aftertouch DNS hook\nlogic\nfi\n", nil } // Mock file existence checks for .original files if strings.HasPrefix(command, "[ -f") { if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") { return "", nil // file exists } } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploadCalls[remotePath] = string(content) return nil }, } } _, err := m.RevertMigration("192.0.2.10") if err != nil { t.Fatalf("RevertMigration failed: %v", err) } // Verify revert commands foundXMLRevert := false foundHostsRevert := false foundResolvRevert := false foundChattrRemove := false foundReboot := false foundAftertouchConfRemove := false foundDHCPRevert := false for _, call := range runCalls { if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) { foundXMLRevert = true } if strings.Contains(call, "cp /etc/hosts.original /etc/hosts") { foundHostsRevert = true } if strings.Contains(call, "cp /etc/resolv.conf.original /etc/resolv.conf") { foundResolvRevert = true } if strings.Contains(call, "chattr -i /etc/resolv.conf") { foundChattrRemove = true } if strings.Contains(call, "reboot") { foundReboot = true } if strings.Contains(call, "rm /mnt/nv/aftertouch.resolv.conf") { foundAftertouchConfRemove = true } if strings.Contains(call, "cp /etc/udhcpc.d/50default.original /etc/udhcpc.d/50default") { foundDHCPRevert = true } } if !foundXMLRevert { t.Errorf("Expected XML config revert") } if !foundHostsRevert { t.Errorf("Expected /etc/hosts revert") } if !foundResolvRevert { t.Errorf("Expected /etc/resolv.conf revert") } if !foundChattrRemove { t.Errorf("Expected chattr -i /etc/resolv.conf") } if !foundAftertouchConfRemove { t.Errorf("Expected /mnt/nv/aftertouch.resolv.conf removal") } if !foundDHCPRevert { t.Errorf("Expected /etc/udhcpc.d/50default revert") } if foundReboot { t.Errorf("Expected reboot NOT to be called automatically during revert") } // Verify rc.local cleanup if content, ok := uploadCalls["/mnt/nv/rc.local"]; ok { if strings.Contains(content, "# Aftertouch DNS hook") { t.Errorf("Expected Aftertouch hook to be removed from rc.local, got: %s", content) } } else { t.Errorf("Expected rc.local to be updated") } // Verify RemoveRemoteServices was NOT called for _, call := range runCalls { if strings.Contains(call, "rm -f /etc/remote_services") { t.Errorf("Remote services should NOT be removed during revert") } } // Verify CA removal if content, ok := uploadCalls["/etc/pki/tls/certs/ca-bundle.crt"]; ok { if strings.Contains(content, CALabel) { t.Errorf("Expected CA label to be removed from bundle, got: %s", content) } if !strings.Contains(content, "existing content") || !strings.Contains(content, "more content") { t.Errorf("Expected existing content to be preserved in bundle, got: %s", content) } } else { t.Errorf("Expected updated bundle to be uploaded") } } func TestRevertMigration_CorruptedRcLocal(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /mnt/nv/rc.local" { return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil } if strings.HasPrefix(command, "[ -f") { if strings.Contains(command, ".original") { if strings.Contains(command, "SoundTouchSdkPrivateCfg.xml") { return "", nil // Pretend XML backup exists to satisfy RevertMigration } return "", fmt.Errorf("not found") } } return "", nil }, } } _, err := m.RevertMigration("192.0.2.10") if err != nil { t.Fatalf("RevertMigration failed: %v", err) } foundRmRcLocal := false for _, call := range runCalls { if call == "rm /mnt/nv/rc.local" { foundRmRcLocal = true break } } if !foundRmRcLocal { t.Errorf("Expected corrupted rc.local to be removed") } } func TestRevertMigration_NoBackup(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } return "", nil }, } } _, err := m.RevertMigration("192.0.2.10") if err == nil { t.Errorf("Expected error when backup is missing, got nil") } else if !strings.Contains(err.Error(), "backup") { t.Errorf("Expected error about missing backup, got: %v", err) } } func TestReboot(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) return "", nil }, } } _, err := m.Reboot("192.0.2.10", "") if err != nil { t.Fatalf("Reboot failed: %v", err) } foundReboot := false for _, call := range runCalls { if strings.Contains(call, "reboot") { foundReboot = true break } } if !foundReboot { t.Errorf("Expected reboot command to be called") } } func TestTestDNSRedirection(t *testing.T) { m := NewManager("http://192.0.2.100:8000", nil, nil) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if !strings.Contains(command, "-u") && strings.Contains(command, "nc") { // Verify TCP length prefix is present: \x00\x21 if !strings.Contains(command, "\\x00\\x21") { return "", fmt.Errorf("missing TCP length prefix in nc command") } // Mock od output: " 192 0 2 100" return " 192 0 2 100", nil } if strings.HasPrefix(command, "nslookup aftertouch.test 192.0.2.100") { return "Server: 192.0.2.100\nAddress 1: 192.0.2.100\n\nName: aftertouch.test\nAddress 1: 192.0.2.100", nil } return "", nil }, } } output, err := m.TestDNSRedirection("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("TestDNSRedirection failed: %v", err) } if !strings.Contains(output, "192.0.2.100") { t.Errorf("Expected output to contain service IP, got %s", output) } foundNc := false for _, call := range runCalls { if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.0.2.100 53") { foundNc = true break } } if !foundNc { t.Errorf("Expected nc command with port 53, got calls: %v", runCalls) } } func TestTestDNSRedirection_CustomPort(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-dns-port") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) ds := datastore.NewDataStore(tempDir) _ = ds.Initialize() _ = ds.SaveSettings(datastore.Settings{ DNSBindAddr: ":1053", }) m := NewManager("http://192.0.2.100:8000", ds, nil) runCalls := []string{} m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if !strings.Contains(command, "-u") && strings.Contains(command, "nc") { // Verify TCP length prefix is present: \x00\x21 if !strings.Contains(command, "\\x00\\x21") { return "", fmt.Errorf("missing TCP length prefix in nc command") } return " 192 0 2 100", nil } return "", nil }, } } output, err := m.TestDNSRedirection("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("TestDNSRedirection failed: %v", err) } if !strings.Contains(output, "192.0.2.100") { t.Errorf("Expected output to contain service IP, got %s", output) } foundNc := false for _, call := range runCalls { if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.0.2.100 1053") { foundNc = true break } } if !foundNc { t.Errorf("Expected nc command with custom port 1053, got calls: %v", runCalls) } } func TestBackupConfigOffDevice(t *testing.T) { tempDir, err := os.MkdirTemp("", "backup-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) ds := datastore.NewDataStore(tempDir) _ = ds.Initialize() m := NewManager("http://localhost:8000", ds, nil) serial := "AABBCCDDEE0A" accountID := "1000001" // Mock info server infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") fmt.Fprintf(w, `Test%sSCM%s`, serial, accountID, serial) })) defer infoServer.Close() // Extract IP and port deviceIP := infoServer.Listener.Addr().String() // Mock SSH to return some config and hosts content m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.Contains(command, SoundTouchSdkPrivateCfgPath) { return "http://original", nil } if strings.Contains(command, "/etc/hosts") { return "127.0.0.1 localhost\n192.0.2.1 bmx.bose.com", nil } return "", nil }, } } err = m.BackupConfigOffDevice(deviceIP) if err != nil { t.Fatalf("BackupConfigOffDevice failed: %v", err) } // Verify files were created in datastore deviceDir := m.DataStore.AccountDeviceDir(accountID, serial) configPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak") hostsPath := filepath.Join(deviceDir, "hosts.bak") if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Errorf("Expected config backup at %s, but it doesn't exist", configPath) } if _, err := os.Stat(hostsPath); os.IsNotExist(err) { t.Errorf("Expected hosts backup at %s, but it doesn't exist", hostsPath) } // Verify content configContent, _ := os.ReadFile(configPath) if !strings.Contains(string(configContent), "http://original") { t.Errorf("Unexpected config backup content: %s", string(configContent)) } hostsContent, _ := os.ReadFile(hostsPath) if !strings.Contains(string(hostsContent), "bmx.bose.com") { t.Errorf("Unexpected hosts backup content: %s", string(hostsContent)) } } func TestMigrateSpeaker_PreFlightFailure(t *testing.T) { m := NewManager("http://localhost:8000", nil, nil) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.Contains(command, "mount -o remount,rw /") { return "mount: / is read-only", fmt.Errorf("remount failed") } return "", nil }, } } _, err := m.MigrateSpeaker("192.0.2.10", "", "", nil, MigrationMethodXML) if err == nil { t.Errorf("Expected error during pre-flight write check, got nil") } if !strings.Contains(err.Error(), "pre-flight check failed") { t.Errorf("Expected pre-flight error message, got: %v", err) } } func TestMigrateViaResolvConf(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-resolv") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://192.0.2.100:8000", nil, cm) runCalls := []string{} uploads := make(map[string]string) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n", nil } if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploads[remotePath] = string(content) return nil }, } } _, err = m.migrateViaResolvConf("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaResolvConf failed: %v", err) } // Verify uploads if !strings.Contains(uploads["/mnt/nv/soundtouch-service/aftertouch.resolv.conf"], "nameserver 192.0.2.100") { t.Errorf("aftertouch.resolv.conf missing nameserver") } if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") { t.Errorf("rc.local missing hook logic") } // Verify immediate patch foundPatch := false for _, call := range runCalls { if strings.Contains(call, "sed -i") && strings.Contains(call, "/etc/udhcpc.d/50default") { foundPatch = true break } } if !foundPatch { t.Errorf("Expected immediate patch to /etc/udhcpc.d/50default") } } func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-resolv-corrupted") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://192.0.2.100:8000", nil, cm) uploads := make(map[string]string) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if command == "cat /mnt/nv/rc.local" { // Simulate corrupted file containing error message return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil } if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploads[remotePath] = string(content) return nil }, } } _, err = m.migrateViaResolvConf("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaResolvConf failed: %v", err) } // Verify uploads - rc.local should have been sanitized and only contain shebang and hook rcLocal := uploads["/mnt/nv/rc.local"] if strings.Contains(rcLocal, "cat: can't open") { t.Errorf("rc.local still contains corrupted content: %s", rcLocal) } if !strings.HasPrefix(rcLocal, "#!/bin/sh") { t.Errorf("rc.local missing shebang: %s", rcLocal) } if !strings.Contains(rcLocal, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") { t.Errorf("rc.local missing hook logic: %s", rcLocal) } } func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-resolv-script") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("Failed to ensure CA: %v", err) } m := NewManager("http://192.0.2.100:8000", nil, cm) runCalls := []string{} uploads := make(map[string]string) targetScript := "/opt/Bose/udhcpc.script" m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n", nil } if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if command == "[ -f "+targetScript+" ]" { return "", nil // file exists } if strings.HasPrefix(command, "[ -f") { return "", fmt.Errorf("file not found") } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploads[remotePath] = string(content) return nil }, } } _, err = m.migrateViaResolvConf("192.0.2.10", "http://192.0.2.100:8000") if err != nil { t.Fatalf("migrateViaResolvConf failed: %v", err) } // Verify immediate patch to udhcpc.script foundPatch := false for _, call := range runCalls { if strings.Contains(call, "sed -i") && strings.Contains(call, targetScript) { foundPatch = true break } } if !foundPatch { t.Errorf("Expected immediate patch to %s", targetScript) } // Verify rc.local contains patch for udhcpc.script rcLocal := uploads["/mnt/nv/rc.local"] if !strings.Contains(rcLocal, "targetScript=\"/opt/Bose/udhcpc.script\"") { t.Errorf("rc.local missing targetScript definition: %s", rcLocal) } if !strings.Contains(rcLocal, "sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"$HOOK_MARKER\"' ] && cat '\"$HOOK_MARKER\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' \"$targetScript\"") { // Note: The actual string in rcLocal might have variables expanded or escaped depending on how it was constructed. // Let's check for the critical part: the escaped $RESOLV_CONF if !strings.Contains(rcLocal, ">> '\"\\$RESOLV_CONF\"'") { t.Errorf("rc.local missing correctly escaped RESOLV_CONF in sed patch for udhcpc.script: %s", rcLocal) } } } func TestRevertMigration_ResolvConf(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test-revert-resolv") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) m := NewManager("http://192.0.2.100:8000", nil, nil) runCalls := []string{} uploads := make(map[string]string) targetDHCPFile := "/etc/udhcpc.d/50default" targetScript := "/opt/Bose/udhcpc.script" m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil } if strings.Contains(command, ".original ]") { return "", nil // backup exists } if strings.Contains(command, "[ -f /mnt/nv/soundtouch-service/aftertouch.resolv.conf ]") || strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") { return "", nil } return "", nil }, uploadContentFunc: func(content []byte, remotePath string) error { uploads[remotePath] = string(content) return nil }, } } _, err = m.RevertMigration("192.0.2.10") if err != nil { t.Fatalf("RevertMigration failed: %v", err) } // Verify backups were restored foundDHCPRestore := false foundScriptRestore := false for _, call := range runCalls { if strings.Contains(call, "cp "+targetDHCPFile+".original "+targetDHCPFile) { foundDHCPRestore = true } if strings.Contains(call, "cp "+targetScript+".original "+targetScript) { foundScriptRestore = true } } if !foundDHCPRestore { t.Errorf("Expected %s to be restored from backup", targetDHCPFile) } if !foundScriptRestore { t.Errorf("Expected %s to be restored from backup", targetScript) } // Verify rc.local was cleaned up rcLocal := uploads["/mnt/nv/rc.local"] if strings.Contains(rcLocal, "# Aftertouch DNS hook") { t.Errorf("rc.local still contains hook logic after revert: %s", rcLocal) } } func contains(s, substr string) bool { return strings.Contains(s, substr) } func TestCheckIsMigrated(t *testing.T) { m := NewManager("http://aftertouch:8000", nil, nil) t.Run("XML Migrated", func(t *testing.T) { summary := &MigrationSummary{ SSHSuccess: true, ParsedCurrentConfig: &PrivateCfg{ MargeServerUrl: "http://aftertouch:8000", }, } m.checkIsMigrated(summary, "127.0.0.1") if !summary.IsMigrated { t.Errorf("Expected IsMigrated to be true for XML migration") } }) t.Run("Hosts Migrated", func(t *testing.T) { m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if command == "cat /etc/hosts" { return "127.0.0.1\tstreaming.bose.com", nil } return "", nil }, } } summary := &MigrationSummary{ SSHSuccess: true, CACertTrusted: true, } m.checkIsMigrated(summary, "127.0.0.1") if !summary.IsMigrated { t.Errorf("Expected IsMigrated to be true for hosts migration") } }) t.Run("ResolvConf Migrated (Marker)", func(t *testing.T) { m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if command == "cat /etc/hosts" { return "127.0.0.1\tlocalhost", nil } if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" { return "", fmt.Errorf("not found") } return "", nil }, } } summary := &MigrationSummary{ SSHSuccess: true, CACertTrusted: true, CurrentResolvConf: "# Priority nameserver for Bose service redirection\nnameserver 192.0.2.1\n", } m.checkIsMigrated(summary, "127.0.0.1") if !summary.IsMigrated { t.Errorf("Expected IsMigrated to be true for resolv.conf migration with marker comment") } }) t.Run("ResolvConf Migrated (IP)", func(t *testing.T) { m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if command == "cat /etc/hosts" { return "127.0.0.1\tlocalhost", nil } if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" { return "", fmt.Errorf("not found") } // Mock resolveIP by mocking its SSH commands if any, or just wait for it to return targetHost return "", nil }, } } // m.ServerURL is "http://aftertouch:8000" in this test (see top of TestCheckIsMigrated) summary := &MigrationSummary{ SSHSuccess: true, CACertTrusted: true, CurrentResolvConf: "nameserver aftertouch\n", } m.checkIsMigrated(summary, "127.0.0.1") if !summary.IsMigrated { t.Errorf("Expected IsMigrated to be true for resolv.conf migration with matching hostname/IP") } }) t.Run("Not Migrated", func(t *testing.T) { m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if command == "cat /etc/hosts" { return "127.0.0.1\tlocalhost", nil } return "", nil }, } } summary := &MigrationSummary{ SSHSuccess: true, ParsedCurrentConfig: &PrivateCfg{ MargeServerUrl: "http://streaming.bose.com", }, CACertTrusted: false, } m.checkIsMigrated(summary, "127.0.0.1") if summary.IsMigrated { t.Errorf("Expected IsMigrated to be false for non-migrated device") } }) } // TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads // from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test). func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) { m := NewManager("http://aftertouch:8000", nil, nil) originalCfg := "http://streaming.bose.com" m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") { return "", fmt.Errorf("exit status 1") } if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath) { return originalCfg, nil } return "", nil }, } } summary := &MigrationSummary{} cfg, err := m.checkCurrentConfig(summary, "127.0.0.1") if err != nil { t.Fatalf("checkCurrentConfig returned unexpected error: %v", err) } if cfg != originalCfg { t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg) } if !summary.SSHSuccess { t.Errorf("Expected SSHSuccess to be true when original config is readable") } } func TestMigrateSpeaker_ResolvBlocking(t *testing.T) { tempDir, err := os.MkdirTemp("", "setup-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) ds := datastore.NewDataStore(tempDir) cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) m := NewManager("http://192.0.2.100:8000", ds, cm) m.NewSSH = func(host string) SSHClient { return &mockSSH{ runFunc: func(command string) (string, error) { return "", nil }, } } // 1. DNS Disabled ds.SaveSettings(datastore.Settings{ DNSEnabled: false, DNSBindAddr: ":53", }) // Mock HTTP server for device info ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/info" { w.Header().Set("Content-Type", "application/xml") _, _ = w.Write([]byte(`Test SpeakerST1000:11:22:33:44:55acc-123`)) } })) defer ts.Close() // Use the test server address as device IP tsIP := strings.TrimPrefix(ts.URL, "http://") _, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf) if err == nil || !strings.Contains(err.Error(), "DNS discovery server is not enabled") { t.Errorf("Expected error about DNS not being enabled, got %v", err) } // 2. DNS Enabled but wrong port ds.SaveSettings(datastore.Settings{ DNSEnabled: true, DNSBindAddr: ":5353", }) _, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf) if err == nil || !strings.Contains(err.Error(), "port 53 is required") { t.Errorf("Expected error about port 53 required, got %v", err) } // 3. DNS Enabled and port 53, but not running ds.SaveSettings(datastore.Settings{ DNSEnabled: true, DNSBindAddr: ":53", }) m.GetDNSRunning = func() (bool, string) { return false, ":53" } _, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf) if err == nil || !strings.Contains(err.Error(), "not actually running") { t.Errorf("Expected error about DNS not actually running, got %v", err) } // 4. DNS Enabled and port 53, and running m.GetDNSRunning = func() (bool, string) { return true, ":53" } // This should now proceed to migrateViaResolvConf _, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf) if err != nil && (strings.Contains(err.Error(), "DNS discovery server is not enabled") || strings.Contains(err.Error(), "port 53 is required") || strings.Contains(err.Error(), "not actually running")) { t.Errorf("Did not expect pre-flight DNS errors, got %v", err) } } // TestMigrateViaXML_ReappliesBoseURLsOverTelnet verifies the #471 follow-up: // after an XML migration, the runtime boseurls layer is re-applied over telnet // so it matches the persisted config (otherwise the preflight cross-check keeps // warning about the enable-ssh placeholder until a reboot). func TestMigrateViaXML_ReappliesBoseURLsOverTelnet(t *testing.T) { cm := certmanager.NewCertificateManager(filepath.Join(t.TempDir(), "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("EnsureCA: %v", err) } target := "https://192.0.2.10:8443" m := NewManager(target, nil, cm) m.NewSSH = func(string) SSHClient { return &mockSSH{runFunc: func(string) (string, error) { return "", nil }} } wantCmds := telnetURLs{ Marge: target, Stats: target, SwUpdate: target + "/updates/soundtouch", BmxRegistry: target + "/bmx/registry/v1/services", }.Commands() resp := make(map[string]string, len(wantCmds)) for _, c := range wantCmds { resp[c] = "OK\n" } ft := &fakeTelnet{banner: "->", responses: resp} m.NewTelnet = func(string) TelnetClient { return ft } if _, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML); err != nil { t.Fatalf("MigrateSpeaker: %v", err) } // All four `sys configuration` writes must land before the envswitch // commit — see enable_ssh.go's setAllBoseURLsViaTelnet — otherwise the // commit freezes whatever stale value was still in the runtime layer for // any field not passed to it (the #621 statsServerUrl/bmxRegistryUrl bug). for _, want := range wantCmds { var found bool for _, c := range ft.commands { if c == want { found = true break } } if !found { t.Errorf("expected boseurls re-apply command %q after XML migration; sent: %v", want, ft.commands) } } } // TestMigrateViaXML_BoseURLsResyncIsBestEffort verifies that a telnet failure // during the post-migration boseurls re-apply does not fail the migration (a // device reboot still reconciles the runtime layer). func TestMigrateViaXML_BoseURLsResyncIsBestEffort(t *testing.T) { cm := certmanager.NewCertificateManager(filepath.Join(t.TempDir(), "certs")) if err := cm.EnsureCA(); err != nil { t.Fatalf("EnsureCA: %v", err) } target := "https://192.0.2.10:8443" m := NewManager(target, nil, cm) m.NewSSH = func(string) SSHClient { return &mockSSH{runFunc: func(string) (string, error) { return "", nil }} } m.NewTelnet = func(string) TelnetClient { return &fakeTelnet{dialErr: errors.New("connection refused")} } logs, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML) if err != nil { t.Fatalf("MigrateSpeaker should not fail when the telnet re-sync fails: %v", err) } if !strings.Contains(logs, "could not re-sync boseurls over telnet") { t.Errorf("expected a best-effort note about the failed telnet re-sync; logs:\n%s", logs) } }