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:
Tobias Gesellchen
2026-05-11 00:37:11 +02:00
co-authored by Claude Opus 4.7
parent ebbc1209e5
commit bcd0970e35
3 changed files with 151 additions and 24 deletions
@@ -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
View File
@@ -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