Fix TestMigrationAndCA by enhancing mock SSH client

This commit updates the mock SSH client in the handler tests to support the recently added verification steps. It now correctly handles stateful responses for /etc/hosts and properly responds to file existence and CA trust checks.
This commit is contained in:
Tobias Gesellchen
2026-02-16 20:17:25 +01:00
parent 69210638e5
commit 7d140b3e2a
2 changed files with 197 additions and 123 deletions
+19 -3
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
@@ -140,7 +141,7 @@ func TestMigrationAndCA(t *testing.T) {
sm := setup.NewManager("http://localhost:8000", ds, cm)
// Mock SSH to avoid real connections
sm.NewSSH = func(host string) setup.SSHClient {
return &mockSSH{}
return &mockSSH{host: host}
}
r, server := setupRouter("http://localhost:8001", ds)
@@ -338,12 +339,27 @@ func TestRemoveDevice(t *testing.T) {
}
}
type mockSSH struct{}
type mockSSH struct {
host string
runCount int
}
func (m *mockSSH) Run(command string) (string, error) {
if command == "cat /etc/hosts" {
if strings.Contains(command, "cat /etc/hosts") {
m.runCount++
if m.runCount > 1 {
// Return updated hosts for verification
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
}
return "127.0.0.1 localhost", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", nil // Pretend file exists for backups
}
if strings.HasPrefix(command, "grep -F") {
return "matched", nil // CA trusted
}
return "", nil
}
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
+178 -120
View File
@@ -718,6 +718,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
if !strings.Contains(verification, cfg.MargeServerUrl) {
return logs, fmt.Errorf("verification failed: uploaded config on %s does not contain expected margeServerUrl", deviceIP)
}
logs += "Verified configuration on device\n"
} else {
logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err)
@@ -982,7 +983,54 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
}
lines := strings.Split(hostsContent, "\n")
hostsContent = m.generateHostsContent(hostsContent, domains, hostIP)
// 3. Upload new /etc/hosts
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup /etc/hosts if it doesn't exist
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
}
logs += "Uploaded updated /etc/hosts\n"
// 4. Verify /etc/hosts on device
if err := m.verifyHosts(client, domains, hostIP, deviceIP); err != nil {
return logs, err
}
logs += "Verified /etc/hosts on device\n"
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
// 5. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
}
return logs, nil
}
func (m *Manager) generateHostsContent(currentContent string, domains []string, hostIP string) string {
lines := strings.Split(currentContent, "\n")
var newLines []string
@@ -1026,58 +1074,27 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
}
}
hostsContent = strings.Join(newLines, "\n")
hostsContent := strings.Join(newLines, "\n")
if !strings.HasSuffix(hostsContent, "\n") {
hostsContent += "\n"
}
// 3. Upload new /etc/hosts
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup /etc/hosts if it doesn't exist
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
return hostsContent
}
func (m *Manager) verifyHosts(client SSHClient, domains []string, hostIP, deviceIP string) error {
verification, err := client.Run("cat /etc/hosts")
if err != nil {
return fmt.Errorf("could not verify /etc/hosts on device: %w", err)
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
}
logs += "Uploaded updated /etc/hosts\n"
// 4. Verify /etc/hosts on device
if verification, err := client.Run("cat /etc/hosts"); err == nil {
for _, domain := range domains {
if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) {
return logs, fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain)
}
for _, domain := range domains {
if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) {
return fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain)
}
logs += "Verified /etc/hosts on device\n"
} else {
logs += fmt.Sprintf("Warning: could not verify /etc/hosts on device: %v\n", err)
}
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
// 5. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
}
return logs, nil
return nil
}
func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) {
@@ -1107,13 +1124,65 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
// Ensure /mnt/nv exists
_, _ = client.Run("mkdir -p /mnt/nv")
if err := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); err != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", err)
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); uploadErr != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", uploadErr)
}
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
// 4. Update /mnt/nv/rc.local with idempotent patch
patchOut, err := m.updateRcLocalWithDNSHook(client)
logs += patchOut
if err != nil {
return logs, err
}
// 5. Apply patch immediately to /etc/udhcpc.d/50default
rwOut, _ := client.Run(rwCmd)
logs += rwCmd + ": " + rwOut + "\n"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
targetDHCPFile := "/etc/udhcpc.d/50default"
dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker)
logs += dhcpPatchOut
if err != nil {
logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetDHCPFile, err)
}
// Apply patch immediately to /opt/Bose/udhcpc.script if it exists
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil {
scriptPatchOut, err := m.patchUdhcpcScript(client, targetScript, hookMarker)
logs += scriptPatchOut
if err != nil {
logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetScript, err)
}
}
// 6. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
}
return logs, nil
}
func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
var logs string
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
@@ -1124,6 +1193,10 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
currentRcLocal = ""
}
if strings.Contains(currentRcLocal, hookMarker) {
return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil
}
patchLogic := fmt.Sprintf(`
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
if [ -f "%s" ]; then
@@ -1139,38 +1212,36 @@ if [ -f "%s" ]; then
fi
`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker)
if !strings.Contains(currentRcLocal, hookMarker) {
newRcLocal := currentRcLocal
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(newRcLocal, "cat: can't open") {
newRcLocal = ""
}
if !strings.HasPrefix(newRcLocal, "#!/bin/sh") {
newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh")
}
if !strings.HasSuffix(newRcLocal, "\n") {
newRcLocal += "\n"
}
newRcLocal += patchLogic
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err)
}
logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath)
// Make it executable
_, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath))
} else {
logs += fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath)
newRcLocal := currentRcLocal
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(newRcLocal, "cat: can't open") {
newRcLocal = ""
}
// 5. Apply patch immediately to /etc/udhcpc.d/50default
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if !strings.HasPrefix(newRcLocal, "#!/bin/sh") {
newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh")
}
if !strings.HasSuffix(newRcLocal, "\n") {
newRcLocal += "\n"
}
newRcLocal += patchLogic
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err)
}
logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath)
// Make it executable
_, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath))
return logs, nil
}
func (m *Manager) patchDHCPFile(client SSHClient, targetDHCPFile, hookMarker string) (string, error) {
var logs string
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil {
@@ -1184,58 +1255,45 @@ fi
// Run the patch logic via SSH to apply it now
patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile)
if _, err := client.Run(patchCmd); err != nil {
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetDHCPFile, err)
} else {
logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile)
// Verify patch on 50default
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetDHCPFile)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetDHCPFile)
} else {
logs += fmt.Sprintf("Warning: could not verify patch on %s: %v\n", targetDHCPFile, err)
}
return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetDHCPFile, err)
}
// Apply patch immediately to /opt/Bose/udhcpc.script if it exists
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil {
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out)
} else {
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript))
}
logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile)
patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript)
if _, err := client.Run(patchCmdScript); err != nil {
logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetScript, err)
} else {
logs += fmt.Sprintf("Applied patch to %s\n", targetScript)
// Verify patch on udhcpc.script
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetScript)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetScript)
} else {
logs += fmt.Sprintf("Warning: could not verify patch on %s: %v\n", targetScript, err)
}
}
// Verify patch on 50default
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetDHCPFile)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetDHCPFile)
} else {
return logs, fmt.Errorf("could not verify patch on %s: %w", targetDHCPFile, err)
}
// 6. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
return logs, nil
}
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker string) (string, error) {
var logs string
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out)
} else {
logs += "CA certificate already trusted, skipping injection\n"
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript))
}
patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript)
if _, err := client.Run(patchCmdScript); err != nil {
return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetScript, err)
}
logs += fmt.Sprintf("Applied patch to %s\n", targetScript)
// Verify patch on udhcpc.script
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetScript)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetScript)
} else {
return logs, fmt.Errorf("could not verify patch on %s: %w", targetScript, err)
}
return logs, nil