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