mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
feat(setup): expose per-axis migration booleans on MigrationSummary
Adds XMLMigrated, HostsMigrated, ResolvMigrated, TelnetMigrated, and IsPaired as explicit fields on the summary so the UI can render partial-state cells (URLs flipped via telnet but the on-disk XML hasn't caught up; DNS interception in place but no CA installed; etc.) and surface pairing as its own precondition. IsMigrated remains backward-compatible — it is now the OR of the four migration axes. checkIsMigrated stops short-circuiting and writes each axis verdict unconditionally so a "partial" state on any axis is always visible to the UI even when another axis already reports the device migrated. populateDeviceInfo now derives IsPaired from the live :8090/info margeAccountUUID (clobbering any stale datastore copy), so a factory-reset speaker is correctly flagged as unpaired. Tests cover the per-axis verdicts independently and the IsPaired derivation in both the populated and empty live-info cases. This is the data layer for the upcoming three-axis "state view" panel on the migration tab. No frontend or behavior changes here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ebbc1209e5
commit
bcd0970e35
@@ -75,6 +75,60 @@ func TestCheckIsMigrated_NoTelnetNoSSH(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_PerAxisBooleansArePopulated locks in that each
|
||||
// axis is reported individually so the UI can show partial-state cells.
|
||||
// The mock SSH client claims /etc/hosts has Bose redirects; XML is
|
||||
// unmigrated; resolv has no marker; telnet sees the redirected URL.
|
||||
// All four axis flags must reflect their independent verdicts and
|
||||
// IsMigrated must be the OR.
|
||||
func TestCheckIsMigrated_PerAxisBooleansArePopulated(t *testing.T) {
|
||||
m := &Manager{
|
||||
ServerURL: "http://example:8000",
|
||||
NewSSH: func(string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(cmd string) (string, error) {
|
||||
if cmd == "cat /etc/hosts" {
|
||||
return "192.0.2.1\tstreaming.bose.com\n", nil
|
||||
}
|
||||
return "", errors.New("not implemented in this mock")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true, // hosts migration requires CA trust
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
CurrentResolvConf: "nameserver 8.8.8.8\n",
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetMigrated {
|
||||
t.Error("TelnetMigrated = false, want true (verified config points at example)")
|
||||
}
|
||||
|
||||
if summary.XMLMigrated {
|
||||
t.Error("XMLMigrated = true, want false (parsed XML still points at streaming.bose.com)")
|
||||
}
|
||||
|
||||
if !summary.HostsMigrated {
|
||||
t.Error("HostsMigrated = false, want true (mock hosts content has Bose redirect + CA trusted)")
|
||||
}
|
||||
|
||||
if summary.ResolvMigrated {
|
||||
t.Error("ResolvMigrated = true, want false (no marker, no example hostname)")
|
||||
}
|
||||
|
||||
if !summary.IsMigrated {
|
||||
t.Error("IsMigrated = false, want true (TelnetMigrated || HostsMigrated)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal ensures we don't
|
||||
// false-positive when both transports report unmigrated state.
|
||||
func TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal(t *testing.T) {
|
||||
|
||||
@@ -18,11 +18,18 @@ import (
|
||||
// so the live-info call works; the SSH and telnet clients ignore the addr
|
||||
// and return whatever the fakes are scripted to return.
|
||||
func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) {
|
||||
return telnetSummaryEnvWithInfo(t, ssh, ft, `<info deviceID="123"><name>Test</name></info>`)
|
||||
}
|
||||
|
||||
// telnetSummaryEnvWithInfo is telnetSummaryEnv with a caller-supplied
|
||||
// :8090/info XML body, so individual tests can exercise device-info
|
||||
// fields that affect summary state (e.g. margeAccountUUID for IsPaired).
|
||||
func telnetSummaryEnvWithInfo(t *testing.T, ssh *mockSSH, ft *fakeTelnet, infoXML string) (*Manager, string, func()) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = fmt.Fprint(w, `<info deviceID="123"><name>Test</name></info>`)
|
||||
_, _ = fmt.Fprint(w, infoXML)
|
||||
}))
|
||||
|
||||
m := NewManager("http://example:8000", nil, nil)
|
||||
@@ -95,6 +102,46 @@ func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_IsPairedFromLiveInfo(t *testing.T) {
|
||||
ft := &fakeTelnet{dialErr: errors.New("not the focus of this test")}
|
||||
|
||||
t.Run("with margeAccountUUID", func(t *testing.T) {
|
||||
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
|
||||
`<info deviceID="123"><name>Test</name><margeAccountUUID>3230304</margeAccountUUID></info>`,
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if !summary.IsPaired {
|
||||
t.Errorf("IsPaired = false, want true (margeAccountUUID present in :8090/info)")
|
||||
}
|
||||
|
||||
if summary.AccountID != "3230304" {
|
||||
t.Errorf("AccountID = %q, want 3230304 (live info should populate)", summary.AccountID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without margeAccountUUID", func(t *testing.T) {
|
||||
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
|
||||
`<info deviceID="123"><name>Test</name><margeAccountUUID></margeAccountUUID></info>`,
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.IsPaired {
|
||||
t.Errorf("IsPaired = true, want false (factory-reset device with empty margeAccountUUID)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
ft := &fakeTelnet{
|
||||
|
||||
+49
-23
@@ -77,11 +77,24 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
PreferredSource string `json:"preferred_source,omitempty"`
|
||||
// Per-axis migration signals — IsMigrated is the OR of these. The UI
|
||||
// displays them individually so users can see partial states (e.g.
|
||||
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
|
||||
// DNS interception in place but no CA installed).
|
||||
XMLMigrated bool `json:"xml_migrated"`
|
||||
HostsMigrated bool `json:"hosts_migrated"`
|
||||
ResolvMigrated bool `json:"resolv_migrated"`
|
||||
TelnetMigrated bool `json:"telnet_migrated"`
|
||||
// IsPaired reports whether the device's live :8090/info advertises a
|
||||
// non-empty margeAccountUUID. Surfaced separately so the wizard can
|
||||
// flag pairing as a precondition independently of the URL flip.
|
||||
IsPaired bool `json:"is_paired"`
|
||||
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
PreferredSource string `json:"preferred_source,omitempty"`
|
||||
|
||||
// Telnet (port 17000) preflight state — populated when the user is about to
|
||||
// or has just used MigrationMethodTelnet.
|
||||
@@ -417,28 +430,35 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
||||
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
|
||||
}
|
||||
|
||||
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
||||
// checkIsMigrated determines if the device is already migrated to
|
||||
// AfterTouch and which mechanism is in place.
|
||||
//
|
||||
// The telnet-based check runs first and unconditionally, because it is the
|
||||
// only migration-state signal available on devices that do not expose SSH
|
||||
// (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable). The
|
||||
// SSH-based checks still run when SSH is reachable to cover the
|
||||
// /etc/hosts and /etc/resolv.conf migration variants, neither of which
|
||||
// shows up in `getpdo CurrentSystemConfiguration`.
|
||||
// Each axis is recorded as a separate boolean so the UI can show
|
||||
// partial-state cells (e.g. URLs flipped via telnet but the on-disk XML
|
||||
// hasn't been re-rendered, or DNS interception present but no CA
|
||||
// installed). IsMigrated is the OR — if any mechanism reports the
|
||||
// device pointing at our service, the device is "migrated."
|
||||
//
|
||||
// The telnet-based check runs unconditionally because it is the only
|
||||
// migration-state signal available on devices that do not expose SSH
|
||||
// (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable).
|
||||
// The SSH-based checks need a working shell and cover the /etc/hosts
|
||||
// and /etc/resolv.conf interception variants, neither of which shows
|
||||
// up in `getpdo CurrentSystemConfiguration`.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
if m.isTelnetMigrated(summary) {
|
||||
summary.IsMigrated = true
|
||||
summary.TelnetMigrated = m.isTelnetMigrated(summary)
|
||||
|
||||
if summary.SSHSuccess {
|
||||
client := m.NewSSH(deviceIP)
|
||||
summary.XMLMigrated = m.isXMLMigrated(summary)
|
||||
summary.HostsMigrated = m.isHostsMigrated(client, summary)
|
||||
summary.ResolvMigrated = m.isResolvConfMigrated(client, summary)
|
||||
}
|
||||
|
||||
if !summary.SSHSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
|
||||
summary.IsMigrated = true
|
||||
}
|
||||
summary.IsMigrated = summary.TelnetMigrated ||
|
||||
summary.XMLMigrated ||
|
||||
summary.HostsMigrated ||
|
||||
summary.ResolvMigrated
|
||||
}
|
||||
|
||||
// isTelnetMigrated reports whether the live device config (read via the
|
||||
@@ -589,6 +609,12 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
|
||||
summary.AccountID = infoXML.MargeAccountUUID
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing state is derived from the live :8090/info value above
|
||||
// (which clobbers the stale datastore copy if both are present).
|
||||
// An empty AccountID at this point means a fresh / factory-reset
|
||||
// device that needs pairing before presets and streaming work.
|
||||
summary.IsPaired = summary.AccountID != ""
|
||||
}
|
||||
|
||||
// checkCurrentConfig reads and validates the current speaker configuration
|
||||
|
||||
Reference in New Issue
Block a user