diff --git a/pkg/service/setup/migration_state_telnet_test.go b/pkg/service/setup/migration_state_telnet_test.go
index d354f49..4b91c58 100644
--- a/pkg/service/setup/migration_state_telnet_test.go
+++ b/pkg/service/setup/migration_state_telnet_test.go
@@ -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) {
diff --git a/pkg/service/setup/migration_summary_telnet_test.go b/pkg/service/setup/migration_summary_telnet_test.go
index b7651cd..c159728 100644
--- a/pkg/service/setup/migration_summary_telnet_test.go
+++ b/pkg/service/setup/migration_summary_telnet_test.go
@@ -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, `Test`)
+}
+
+// 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, `Test`)
+ _, _ = 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,
+ `Test3230304`,
+ )
+ 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,
+ `Test`,
+ )
+ 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{
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 0afa4aa..1772ada 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -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