Files
Bose-SoundTouch/pkg/service/setup/reboot_test.go
T
Tobias GesellchenandClaude Opus 4.7 fb47807f70 feat(telnet): add port-17000 migration method and account pairing
Adds an SSH-free third migration path that drives the SoundTouch device's
diagnostic shell on TCP port 17000, plus a hardened replacement for the
fragile /setMargeAccount HTTP pairing call.

* `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`)
  with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests
  cover happy path, command-not-found, mid-stream close, and the wedged-device
  read-timeout scenario.

* `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs
  plus the parallel `envswitch boseurls set` persistence layer that otherwise
  wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`.
  Aborts on the first non-OK response so configuration is never half-written.
  No SSH backup or rw pre-flight (the path is SSH-free by design).

* `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds
  POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the
  hangs reported in #236, and falls back to `envswitch accountid set <id>`
  over telnet when the HTTP endpoint is missing or wedged. Returns a
  PairAccountResult breadcrumb so the UI can show which path actually
  succeeded.

* `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector;
  RebootMethodSSH stays the default (preserving prior behavior),
  RebootMethodTelnet sends `sys reboot` over a fresh telnet session and
  treats the inevitable socket-close as success.

* New endpoints on `/setup`:
  - GET  /account-id-suggestions/{deviceId} — returns the device's current
    margeAccountUUID (from :8090/info) plus known account IDs from the
    datastore, so the UI can offer reuse.
  - POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount;
    the existing reboot endpoint reads ?method=ssh|telnet from the query
    string.

* Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID`
  (crypto/rand, retries on collision against a known-IDs list).

Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match
the implementation: bare-URL convention for `soundtouch-service`, no automatic
`sys reboot` (user-initiated via the existing button with a method selector),
and the realised package layout. The /etc/hosts method is intentionally not
exposed in the new flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00

95 lines
2.1 KiB
Go

package setup
import (
"errors"
"strings"
"testing"
)
func TestReboot_DefaultIsSSH(t *testing.T) {
var ranCmds []string
m := &Manager{
NewSSH: func(host string) SSHClient {
return &mockSSH{runFunc: func(cmd string) (string, error) {
ranCmds = append(ranCmds, cmd)
return "ok\n", nil
}}
},
}
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
t.Fatalf("Reboot: %v", err)
}
found := false
for _, c := range ranCmds {
if strings.Contains(c, "reboot") {
found = true
break
}
}
if !found {
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
}
}
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
f := &fakeTelnet{
responses: map[string]string{"sys reboot": "OK\n"},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
t.Fatalf("Reboot: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
t.Errorf("commands = %v, want [sys reboot]", f.commands)
}
}
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
// The device closes the socket as part of rebooting. SendCommand surfaces
// that as an EOF/closed error; the reboot path must absorb it.
f := &fakeTelnet{
fail: map[string]error{"sys reboot": errors.New("EOF")},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
if err != nil {
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
}
if !strings.Contains(out, "connection closed by reboot") {
t.Errorf("output should annotate the close, got %q", out)
}
}
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
t.Fatal("expected dial error, got nil")
}
}
func TestReboot_UnknownMethodErrors(t *testing.T) {
m := &Manager{}
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
t.Fatal("expected error for unsupported reboot method")
}
}