From b9f0c16275d54a8adf399bba4dda0db9902ffc40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lipinsk=C3=BD?= <6032558+Mr-Tao@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:18:31 +0200 Subject: [PATCH] fix(setup): verify account data before migration --- docs/content/docs/guides/MIGRATION-GUIDE.md | 12 + pkg/service/datastore/datastore.go | 96 +++++++ pkg/service/datastore/preset_snapshot_test.go | 77 ++++++ .../handlers_migration_readiness_test.go | 70 +++++ pkg/service/handlers/handlers_setup.go | 13 +- pkg/service/handlers/handlers_setup_test.go | 7 + pkg/service/marge/marge.go | 24 +- pkg/service/setup/migration_readiness.go | 230 ++++++++++++++++ pkg/service/setup/migration_readiness_test.go | 254 ++++++++++++++++++ pkg/service/setup/setup.go | 8 + pkg/service/setup/setup_test.go | 18 +- 11 files changed, 802 insertions(+), 7 deletions(-) create mode 100644 pkg/service/datastore/preset_snapshot_test.go create mode 100644 pkg/service/handlers/handlers_migration_readiness_test.go create mode 100644 pkg/service/setup/migration_readiness.go create mode 100644 pkg/service/setup/migration_readiness_test.go diff --git a/docs/content/docs/guides/MIGRATION-GUIDE.md b/docs/content/docs/guides/MIGRATION-GUIDE.md index 9bf3eba6..52e7fdac 100644 --- a/docs/content/docs/guides/MIGRATION-GUIDE.md +++ b/docs/content/docs/guides/MIGRATION-GUIDE.md @@ -174,6 +174,18 @@ Once the speaker appears, click **Sync Data**. This connects to the speaker and Sync pulls the speaker's local state into AfterTouch's datastore, creating an off-device backup of its configuration. If you ran this before May 6, 2026, your account data from Bose's servers was also captured at that time. +Migration is refused until the service has a valid snapshot for that exact +account and device and verifies that its rendered account data preserves every +live preset slot. If the migration page asks for Data Sync, sync the device and +retry instead of bypassing the check. + +Migration is also refused while the rendered account contains another device. +Some speaker firmware wipes its presets after a reboot-triggered resync of a +shared account even when `/full` contains the correct data. Move the speaker to +a dedicated account, run Data Sync for it, and then retry migration. Merely +removing the other devices is not sufficient because discovery can add them +again before the speaker fetches `/full` after reboot. + --- ## Step 5: Migrate diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 057fbabf..d8cec1da 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -596,6 +596,22 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic return ds.getDeviceInfoNoLock(account, device) } +// GetExactDeviceInfo retrieves DeviceInfo.xml from the literal account/device +// directory, without applying legacy device-ID mappings. +func (ds *DataStore) GetExactDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + + path := ds.safeJoin("accounts", account, constants.DevicesDir, device, constants.DeviceInfoFile) + + data, err := ds.rootReadFile(path) + if err != nil { + return nil, err + } + + return decodeDeviceInfo(data, account) +} + func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) { path := ds.AccountDeviceDir(account, device) deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile) @@ -605,6 +621,10 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic return nil, err } + return decodeDeviceInfo(data, account) +} + +func decodeDeviceInfo(data []byte, account string) (*models.ServiceDeviceInfo, error) { var info struct { XMLName xml.Name `xml:"info"` DeviceID string `xml:"deviceID,attr"` @@ -982,6 +1002,70 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo return deviceInfo, nil } +// PresetSnapshotState describes whether Presets.xml is a usable persisted +// snapshot. GetPresets intentionally treats the non-valid states as empty for +// serving compatibility; migration readiness needs to distinguish them. +type PresetSnapshotState string + +// PresetSnapshotValid and related constants describe persisted preset snapshot states. +const ( + PresetSnapshotValid PresetSnapshotState = "valid" + PresetSnapshotMissing PresetSnapshotState = "missing" + PresetSnapshotEmpty PresetSnapshotState = "empty" + PresetSnapshotMalformed PresetSnapshotState = "malformed" +) + +// PresetSnapshot is a read-only view of the exact account/device Presets.xml. +type PresetSnapshot struct { + State PresetSnapshotState + Presets []models.ServicePreset + NeedsRewrite bool +} + +// ReadPresetSnapshot reads the literal account/device Presets.xml without +// rewriting legacy XML or collapsing missing/corrupt files into a valid empty +// snapshot. +func (ds *DataStore) ReadPresetSnapshot(account, device string) (PresetSnapshot, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + + path := ds.safeJoin("accounts", account, constants.DevicesDir, device, constants.PresetsFile) + + data, err := ds.rootReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return PresetSnapshot{State: PresetSnapshotMissing}, nil + } + + return PresetSnapshot{}, err + } + + if len(bytes.TrimSpace(data)) == 0 { + return PresetSnapshot{State: PresetSnapshotEmpty}, nil + } + + normalized := bytes.ReplaceAll(data, []byte(""), []byte("")) + + var root struct { + XMLName xml.Name `xml:"presets"` + } + if unmarshalErr := xml.Unmarshal(normalized, &root); unmarshalErr != nil { + return PresetSnapshot{State: PresetSnapshotMalformed}, nil + } + + presets, _, err := ds.readPresetsNoLock(account, device) + if err != nil { + return PresetSnapshot{}, err + } + + return PresetSnapshot{ + State: PresetSnapshotValid, + Presets: presets, + NeedsRewrite: !bytes.Equal(normalized, data), + }, nil +} + // GetPresets retrieves all presets for the specified account and device. func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) { ds.fileMutex.RLock() @@ -1003,6 +1087,18 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, return presets, nil } +// GetPresetsReadOnly retrieves presets without canonicalizing legacy XML on +// disk. It is intended for preflight paths which must not mutate datastore +// state while rendering the response they are about to validate. +func (ds *DataStore) GetPresetsReadOnly(account, device string) ([]models.ServicePreset, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + + presets, _, err := ds.readPresetsNoLock(account, device) + + return presets, err +} + // MutatePresets atomically reads the current preset list, transforms it via // mutate, and persists the result — holding a single write lock for the // entire read-mutate-write cycle. Calling GetPresets followed by a separate diff --git a/pkg/service/datastore/preset_snapshot_test.go b/pkg/service/datastore/preset_snapshot_test.go new file mode 100644 index 00000000..5edb74b4 --- /dev/null +++ b/pkg/service/datastore/preset_snapshot_test.go @@ -0,0 +1,77 @@ +package datastore + +import ( + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/constants" +) + +func TestReadPresetSnapshotStates(t *testing.T) { + account := "1234567" + device := "DEVICE01" + + tests := []struct { + name string + write []byte + want PresetSnapshotState + }{ + {name: "missing", want: PresetSnapshotMissing}, + {name: "empty", write: []byte(" \n"), want: PresetSnapshotEmpty}, + {name: "malformed", write: []byte(""), want: PresetSnapshotMalformed}, + {name: "valid empty", write: []byte(""), want: PresetSnapshotValid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := NewDataStore(t.TempDir()) + if tt.write != nil { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) + if err := ds.MkdirAllUnderBase(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAllUnderBase: %v", err) + } + if err := ds.WriteFileUnderBase(path, tt.write, 0o644); err != nil { + t.Fatalf("WriteFileUnderBase: %v", err) + } + } + + snapshot, err := ds.ReadPresetSnapshot(account, device) + if err != nil { + t.Fatalf("ReadPresetSnapshot: %v", err) + } + if snapshot.State != tt.want { + t.Fatalf("state = %q, want %q", snapshot.State, tt.want) + } + if tt.want == PresetSnapshotValid && len(snapshot.Presets) != 0 { + t.Fatalf("valid empty snapshot returned %d presets", len(snapshot.Presets)) + } + }) + } +} + +func TestReadPresetSnapshotReturnsPersistedPresets(t *testing.T) { + ds := NewDataStore(t.TempDir()) + account := "1234567" + device := "DEVICE01" + want := models.ServicePreset{ + ServiceContentItem: models.ServiceContentItem{Name: "Radio", Location: "http://radio.example/stream"}, + ID: "1", + ButtonNumber: "1", + } + + if err := ds.SavePresets(account, device, []models.ServicePreset{want}); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + snapshot, err := ds.ReadPresetSnapshot(account, device) + if err != nil { + t.Fatalf("ReadPresetSnapshot: %v", err) + } + if snapshot.State != PresetSnapshotValid { + t.Fatalf("state = %q, want %q", snapshot.State, PresetSnapshotValid) + } + if len(snapshot.Presets) != 1 || snapshot.Presets[0].Name != want.Name || snapshot.Presets[0].Location != want.Location { + t.Fatalf("presets = %+v, want Radio at %s", snapshot.Presets, want.Location) + } +} diff --git a/pkg/service/handlers/handlers_migration_readiness_test.go b/pkg/service/handlers/handlers_migration_readiness_test.go new file mode 100644 index 00000000..650650dd --- /dev/null +++ b/pkg/service/handlers/handlers_migration_readiness_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/go-chi/chi/v5" +) + +func TestHandleMigrateDeviceMapsMigrationDataNotReadyToConflict(t *testing.T) { + const ( + accountID = "1234567" + deviceID = "DEVICE01" + ) + + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/info" { + http.NotFound(w, r) + return + } + + _, _ = fmt.Fprintf(w, `Test Speaker%s`, deviceID, accountID) + })) + defer speaker.Close() + + ds := datastore.NewDataStore(t.TempDir()) + deviceIP := strings.TrimPrefix(speaker.URL, "http://") + if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + IPAddress: deviceIP, + Name: "Test Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo: %v", err) + } + + manager := setup.NewManager("http://aftertouch.example:8000", ds, nil) + server := NewServer(ds, manager, manager.ServerURL, false, false, false) + router := chi.NewRouter() + router.Post("/migrate/{deviceId}", server.HandleMigrateDevice) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/migrate/"+deviceID+"?method=telnet", nil) + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusConflict, recorder.Body.String()) + } + + var response struct { + OK bool `json:"ok"` + Message string `json:"message"` + } + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.OK { + t.Fatal("response ok = true, want false") + } + if !strings.Contains(response.Message, "Data Sync") { + t.Fatalf("message = %q, want actionable Data Sync guidance", response.Message) + } +} diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 3d745701..1d9f9418 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -3,6 +3,8 @@ package handlers import ( "context" "encoding/json" + "errors" + "fmt" "log" "net/http" "os" @@ -12,8 +14,6 @@ import ( "strings" "time" - "fmt" - "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" @@ -719,8 +719,15 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method) if err != nil { + status := http.StatusInternalServerError + + var notReady *setup.MigrationDataNotReadyError + if errors.As(err, ¬Ready) { + status = http.StatusConflict + } + w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(status) if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index 698326f8..2a7b1bba 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -714,6 +714,12 @@ func TestMigrationAndCA(t *testing.T) { Body: io.NopCloser(strings.NewReader(xml)), }, nil } + if strings.HasSuffix(url, "/presets") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(``)), + }, nil + } return &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("Not Found")), @@ -732,6 +738,7 @@ func TestMigrationAndCA(t *testing.T) { IPAddress: "192.0.2.10", AccountID: "default", }) + _ = ds.SavePresets("default", "192.0.2.10", nil) // 1. Test GET /setup/ca.crt res, err := http.Get(ts.URL + "/setup/ca.crt") diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 534dd850..1b73b96a 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -695,6 +695,10 @@ func APIVersionsToXML() ([]byte, error) { // CreateAccountDevice creates an AccountDevice model for the given account and device. func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (models.AccountDevice, error) { + return createAccountDevice(ds, account, deviceID, ds.GetPresets) +} + +func createAccountDevice(ds *datastore.DataStore, account, deviceID string, readPresets func(string, string) ([]models.ServicePreset, error)) (models.AccountDevice, error) { info, err := ds.GetDeviceInfo(account, deviceID) if err != nil { return models.AccountDevice{}, err @@ -746,7 +750,7 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod return models.AccountDevice{}, err } - presets, _ := ds.GetPresets(account, deviceID) + presets, _ := readPresets(account, deviceID) recents, _ := ds.GetRecents(account, deviceID) device.Presets = mapPresetsToFullResponse(presets, sources) @@ -1261,6 +1265,10 @@ func fillAccountInfo(ds *datastore.DataStore, account string, resp *models.Accou } func getAccountDevices(ds *datastore.DataStore, account string, entries []os.DirEntry) ([]models.AccountDevice, string) { + return getAccountDevicesWithPresetReader(ds, account, entries, ds.GetPresets) +} + +func getAccountDevicesWithPresetReader(ds *datastore.DataStore, account string, entries []os.DirEntry, readPresets func(string, string) ([]models.ServicePreset, error)) ([]models.AccountDevice, string) { var ( devices []models.AccountDevice lastDeviceID string @@ -1274,7 +1282,7 @@ func getAccountDevices(ds *datastore.DataStore, account string, entries []os.Dir deviceID := entry.Name() lastDeviceID = deviceID - dev, err := CreateAccountDevice(ds, account, deviceID) + dev, err := createAccountDevice(ds, account, deviceID, readPresets) if err != nil { continue } @@ -1469,6 +1477,16 @@ func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error // AccountFullToXML generates a complete account XML with devices, presets, and recents. func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { + return accountFullToXML(ds, account, ds.GetPresets) +} + +// AccountFullToXMLReadOnly generates the same account XML without rewriting +// legacy preset snapshots while traversing account devices. +func AccountFullToXMLReadOnly(ds *datastore.DataStore, account string) ([]byte, error) { + return accountFullToXML(ds, account, ds.GetPresetsReadOnly) +} + +func accountFullToXML(ds *datastore.DataStore, account string, readPresets func(string, string) ([]models.ServicePreset, error)) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) resp := models.AccountFullResponse{ @@ -1486,7 +1504,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { return nil, err } - devices, lastDeviceID := getAccountDevices(ds, account, entries) + devices, lastDeviceID := getAccountDevicesWithPresetReader(ds, account, entries, readPresets) resp.Devices = devices resp.Sources = getAccountSources(ds, account, lastDeviceID) diff --git a/pkg/service/setup/migration_readiness.go b/pkg/service/setup/migration_readiness.go new file mode 100644 index 00000000..2ae36ca5 --- /dev/null +++ b/pkg/service/setup/migration_readiness.go @@ -0,0 +1,230 @@ +package setup + +import ( + "encoding/xml" + "fmt" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/marge" +) + +// MigrationDataNotReadyError means migration was refused because the service +// cannot prove that its rendered account data preserves the speaker's presets. +type MigrationDataNotReadyError struct { + Reason string + Action string +} + +func (e *MigrationDataNotReadyError) Error() string { + action := e.Action + if action == "" { + action = "Run Data Sync for this device and retry migration." + } + + return fmt.Sprintf("Migration data is not ready: %s. %s", e.Reason, action) +} + +func migrationDataNotReadyf(format string, args ...any) error { + return &MigrationDataNotReadyError{Reason: fmt.Sprintf(format, args...)} +} + +func migrationDataNotReadyWithAction(reason, action string) error { + return &MigrationDataNotReadyError{Reason: reason, Action: action} +} + +// checkMigrationDataReady proves that redirecting the speaker to this service +// will not replace its live presets with missing, stale, or filtered account +// data. Every operation in this check is read-only. +func (m *Manager) checkMigrationDataReady(deviceIP string) error { + if m.DataStore == nil { + // CLI callers do not own the service datastore and cannot enforce this + // check. ExecuteInitPlan is a separate onboarding flow which establishes + // account state only after its intentional URL rewrite. + return nil + } + + info, err := m.GetLiveDeviceInfo(deviceIP) + if err != nil { + return migrationDataNotReadyf("cannot read live /info: %v", err) + } + + deviceID := strings.TrimSpace(info.DeviceID) + accountID := strings.TrimSpace(info.MargeAccountUUID) + + if deviceID == "" { + return migrationDataNotReadyf("live /info has no deviceID") + } + + if accountID == "" { + return migrationDataNotReadyf("live /info has no paired margeAccountUUID") + } + + if !datastore.IsSafeIdentifier(accountID) || !datastore.IsSafeIdentifier(deviceID) { + return migrationDataNotReadyf("live /info contains an invalid account or device identifier") + } + + persistedInfo, err := m.DataStore.GetExactDeviceInfo(accountID, deviceID) + if err != nil { + return migrationDataNotReadyf("DeviceInfo.xml is not persisted under account %q and device %q", accountID, deviceID) + } + + if persistedInfo.DeviceID != deviceID { + return migrationDataNotReadyf("persisted DeviceInfo.xml identifies device %q instead of %q", persistedInfo.DeviceID, deviceID) + } + + snapshot, err := m.DataStore.ReadPresetSnapshot(accountID, deviceID) + if err != nil { + return migrationDataNotReadyf("cannot read the persisted preset snapshot: %v", err) + } + + if snapshot.State != datastore.PresetSnapshotValid { + return migrationDataNotReadyf("persisted Presets.xml is %s", snapshot.State) + } + + if snapshot.NeedsRewrite { + return migrationDataNotReadyf("persisted Presets.xml uses a legacy format that must be refreshed") + } + + livePresets, err := m.fetchLivePresets(deviceIP) + if err != nil { + return migrationDataNotReadyf("cannot read live /presets: %v", err) + } + + fullXML, err := marge.AccountFullToXMLReadOnly(m.DataStore, accountID) + if err != nil { + return migrationDataNotReadyf("cannot render account /full: %v", err) + } + + fullPresets, accountDeviceCount, err := migrationFullPresets(fullXML, deviceID) + if err != nil { + return migrationDataNotReadyf("rendered account /full is incomplete: %v", err) + } + + if accountDeviceCount != 1 { + return migrationDataNotReadyWithAction( + fmt.Sprintf("rendered account /full contains %d devices; shared-account firmware resync can wipe presets after reboot", accountDeviceCount), + "Move this speaker to a dedicated account, run Data Sync for it, then retry migration.", + ) + } + + persisted := migrationPresetIdentities(snapshot.Presets) + live := migrationPresetIdentities(livePresets) + + if mismatch := compareMigrationPresets("persisted snapshot", persisted, "rendered /full", fullPresets); mismatch != "" { + return migrationDataNotReadyf("%s", mismatch) + } + + if mismatch := compareMigrationPresets("live /presets", live, "rendered /full", fullPresets); mismatch != "" { + return migrationDataNotReadyf("%s", mismatch) + } + + return nil +} + +type migrationPresetIdentity struct { + Slot string + Name string + Location string +} + +func migrationPresetIdentities(presets []models.ServicePreset) []migrationPresetIdentity { + result := make([]migrationPresetIdentity, 0, len(presets)) + for i := range presets { + slot := presets[i].ButtonNumber + if slot == "" { + slot = presets[i].ID + } + + result = append(result, migrationPresetIdentity{ + Slot: slot, + Name: presets[i].Name, + Location: presets[i].Location, + }) + } + + return result +} + +func migrationFullPresets(fullXML []byte, deviceID string) ([]migrationPresetIdentity, int, error) { + var full struct { + Devices []struct { + DeviceID string `xml:"deviceid,attr"` + Presets []struct { + Slot string `xml:"buttonNumber,attr"` + Name string `xml:"name"` + Location string `xml:"location"` + } `xml:"presets>preset"` + } `xml:"devices>device"` + } + + if err := xml.Unmarshal(fullXML, &full); err != nil { + return nil, 0, fmt.Errorf("malformed XML: %w", err) + } + + for i := range full.Devices { + if full.Devices[i].DeviceID != deviceID { + continue + } + + presets := make([]migrationPresetIdentity, 0, len(full.Devices[i].Presets)) + for _, preset := range full.Devices[i].Presets { + presets = append(presets, migrationPresetIdentity{ + Slot: preset.Slot, + Name: preset.Name, + Location: preset.Location, + }) + } + + return presets, len(full.Devices), nil + } + + return nil, len(full.Devices), fmt.Errorf("target device %q is missing", deviceID) +} + +func compareMigrationPresets(leftName string, left []migrationPresetIdentity, rightName string, right []migrationPresetIdentity) string { + if len(left) != len(right) { + return fmt.Sprintf("%s has %d preset(s), but %s has %d", leftName, len(left), rightName, len(right)) + } + + leftBySlot, problem := indexMigrationPresets(leftName, left) + if problem != "" { + return problem + } + + rightBySlot, problem := indexMigrationPresets(rightName, right) + if problem != "" { + return problem + } + + for slot, leftPreset := range leftBySlot { + rightPreset, ok := rightBySlot[slot] + if !ok { + return fmt.Sprintf("preset slot %s from %s is missing from %s", slot, leftName, rightName) + } + + if leftPreset.Name != rightPreset.Name || leftPreset.Location != rightPreset.Location { + return fmt.Sprintf("preset slot %s differs between %s and %s", slot, leftName, rightName) + } + } + + return "" +} + +func indexMigrationPresets(name string, presets []migrationPresetIdentity) (map[string]migrationPresetIdentity, string) { + bySlot := make(map[string]migrationPresetIdentity, len(presets)) + for _, preset := range presets { + if preset.Slot == "" { + return nil, fmt.Sprintf("%s contains a preset without a slot", name) + } + + if _, exists := bySlot[preset.Slot]; exists { + return nil, fmt.Sprintf("%s contains duplicate preset slot %s", name, preset.Slot) + } + + bySlot[preset.Slot] = preset + } + + return bySlot, "" +} diff --git a/pkg/service/setup/migration_readiness_test.go b/pkg/service/setup/migration_readiness_test.go new file mode 100644 index 00000000..767e13d5 --- /dev/null +++ b/pkg/service/setup/migration_readiness_test.go @@ -0,0 +1,254 @@ +package setup + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +const ( + readinessAccount = "1234567" + readinessDevice = "DEVICE01" +) + +func newMigrationReadinessFixture(t *testing.T, livePresetsXML string) (*Manager, *datastore.DataStore, string) { + t.Helper() + + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/info": + _, _ = fmt.Fprintf(w, `Test Speaker%s`, readinessDevice, readinessAccount) + case "/presets": + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(livePresetsXML)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(speaker.Close) + + ds := datastore.NewDataStore(t.TempDir()) + deviceIP := strings.TrimPrefix(speaker.URL, "http://") + if err := ds.SaveDeviceInfo(readinessAccount, readinessDevice, &models.ServiceDeviceInfo{ + DeviceID: readinessDevice, + AccountID: readinessAccount, + IPAddress: deviceIP, + Name: "Test Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo: %v", err) + } + + return NewManager("http://aftertouch.example:8000", ds, nil), ds, deviceIP +} + +func readinessPreset(slot, name, location string) models.ServicePreset { + return models.ServicePreset{ + ServiceContentItem: models.ServiceContentItem{ + Name: name, + Source: "LOCAL_INTERNET_RADIO", + Type: "stationurl", + ContentItemType: "stationurl", + Location: location, + SourceID: "10003", + IsPresetable: "true", + }, + ID: slot, + ButtonNumber: slot, + } +} + +func livePresetsXML(presets ...models.ServicePreset) string { + var xml strings.Builder + xml.WriteString(``) + for _, preset := range presets { + fmt.Fprintf(&xml, `%s`, + preset.ButtonNumber, preset.Source, preset.Type, preset.Location, preset.Name) + } + xml.WriteString(``) + + return xml.String() +} + +func requireMigrationNotReady(t *testing.T, err error) *MigrationDataNotReadyError { + t.Helper() + if err == nil { + t.Fatal("expected migration data readiness error") + } + + var notReady *MigrationDataNotReadyError + if !errors.As(err, ¬Ready) { + t.Fatalf("error type = %T, want *MigrationDataNotReadyError: %v", err, err) + } + if notReady.Action == "" && !strings.Contains(err.Error(), "Data Sync") { + t.Fatalf("default error is not actionable: %v", err) + } + if notReady.Action != "" && !strings.Contains(err.Error(), notReady.Action) { + t.Fatalf("custom action is missing from error: %v", err) + } + + return notReady +} + +func TestMigrateSpeakerMissingSnapshotBlocksBeforeTelnet(t *testing.T) { + m, _, deviceIP := newMigrationReadinessFixture(t, ``) + telnetCalls := 0 + m.NewTelnet = func(string) TelnetClient { + telnetCalls++ + return &fakeTelnet{} + } + + _, err := m.MigrateSpeaker(deviceIP, "", "", nil, MigrationMethodTelnet) + notReady := requireMigrationNotReady(t, err) + if !strings.Contains(notReady.Reason, "missing") { + t.Fatalf("reason = %q, want missing snapshot", notReady.Reason) + } + if telnetCalls != 0 { + t.Fatalf("telnet factory called %d times, want zero", telnetCalls) + } +} + +func TestMigrationDataReadinessBlocksPartialAndFilteredPresets(t *testing.T) { + t.Run("partial live list", func(t *testing.T) { + one := readinessPreset("1", "One", "http://radio.example/one") + two := readinessPreset("2", "Two", "http://radio.example/two") + m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(one)) + if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{one, two}); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + requireMigrationNotReady(t, m.checkMigrationDataReady(deviceIP)) + }) + + t.Run("preset filtered from full", func(t *testing.T) { + filtered := readinessPreset("1", "Spotify", "spotify:track:missing") + filtered.Source = "SPOTIFY" + filtered.SourceID = "missing-spotify-source" + m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(filtered)) + if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{filtered}); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + notReady := requireMigrationNotReady(t, m.checkMigrationDataReady(deviceIP)) + if !strings.Contains(notReady.Reason, "rendered /full") { + t.Fatalf("reason = %q, want rendered /full mismatch", notReady.Reason) + } + }) + + t.Run("shared account", func(t *testing.T) { + m, ds, deviceIP := newMigrationReadinessFixture(t, ``) + if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{ + DeviceID: "SIBLING01", + AccountID: readinessAccount, + Name: "Sibling Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo sibling: %v", err) + } + + notReady := requireMigrationNotReady(t, m.checkMigrationDataReady(deviceIP)) + if !strings.Contains(notReady.Reason, "2 devices") { + t.Fatalf("reason = %q, want shared-account device count", notReady.Reason) + } + if !strings.Contains(notReady.Action, "dedicated account") { + t.Fatalf("action = %q, want dedicated-account guidance", notReady.Action) + } + if !strings.Contains(notReady.Action, "Data Sync") { + t.Fatalf("action = %q, want Data Sync guidance", notReady.Action) + } + }) +} + +func TestMigrationDataReadinessAllowsValidEmptyAndSyncedPresets(t *testing.T) { + t.Run("valid empty", func(t *testing.T) { + m, ds, deviceIP := newMigrationReadinessFixture(t, ``) + if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + if err := m.checkMigrationDataReady(deviceIP); err != nil { + t.Fatalf("checkMigrationDataReady: %v", err) + } + }) + + t.Run("fully synced", func(t *testing.T) { + preset := readinessPreset("1", "Radio", "http://radio.example/stream") + m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset)) + if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil { + t.Fatalf("SavePresets: %v", err) + } + + if err := m.checkMigrationDataReady(deviceIP); err != nil { + t.Fatalf("checkMigrationDataReady: %v", err) + } + }) +} + +func TestMigrationDataReadinessDoesNotRewritePresetSnapshots(t *testing.T) { + t.Run("ready single device", func(t *testing.T) { + preset := readinessPreset("1", "Target Radio", "http://radio.example/target") + m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(preset)) + if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{preset}); err != nil { + t.Fatalf("SavePresets target: %v", err) + } + targetPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, readinessDevice), constants.PresetsFile) + before, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("read target snapshot: %v", err) + } + + if err = m.checkMigrationDataReady(deviceIP); err != nil { + t.Fatalf("checkMigrationDataReady: %v", err) + } + assertPresetSnapshotUnchanged(t, targetPath, before) + }) + + t.Run("rejected shared account", func(t *testing.T) { + m, ds, deviceIP := newMigrationReadinessFixture(t, ``) + if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil { + t.Fatalf("SavePresets target: %v", err) + } + + const siblingDevice = "SIBLING01" + if err := ds.SaveDeviceInfo(readinessAccount, siblingDevice, &models.ServiceDeviceInfo{ + DeviceID: siblingDevice, + AccountID: readinessAccount, + Name: "Sibling Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo sibling: %v", err) + } + + legacy := []byte(`Sibling Radio`) + siblingPath := filepath.Join(ds.AccountDeviceDir(readinessAccount, siblingDevice), constants.PresetsFile) + if err := ds.WriteFileUnderBase(siblingPath, legacy, 0o644); err != nil { + t.Fatalf("write legacy sibling snapshot: %v", err) + } + + requireMigrationNotReady(t, m.checkMigrationDataReady(deviceIP)) + assertPresetSnapshotUnchanged(t, siblingPath, legacy) + }) +} + +func assertPresetSnapshotUnchanged(t *testing.T, path string, want []byte) { + t.Helper() + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read preset snapshot: %v", err) + } + if !bytes.Equal(after, want) { + t.Fatalf("readiness preflight rewrote Presets.xml\n got: %s\nwant: %s", after, want) + } +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index e1381ea3..ad80483d 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -873,6 +873,10 @@ func (m *Manager) firstCACertBodyLine() (string, bool) { // MigrateSpeaker configures the speaker at the given IP to use this service. func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) { + if err := m.checkMigrationDataReady(deviceIP); err != nil { + return "", err + } + if targetURL == "" { targetURL = m.ServerURL } @@ -2868,6 +2872,10 @@ func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, err defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("GET %s returned %d", presetsURL, resp.StatusCode) + } + var ps models.Presets if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil { return nil, decodeErr diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index c3e852ea..8f634a30 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) @@ -2031,15 +2032,30 @@ func TestMigrateSpeaker_ResolvBlocking(t *testing.T) { // Mock HTTP server for device info ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/info" { + switch r.URL.Path { + case "/info": w.Header().Set("Content-Type", "application/xml") _, _ = w.Write([]byte(`Test SpeakerST1000:11:22:33:44:55acc-123`)) + case "/presets": + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) } })) defer ts.Close() // Use the test server address as device IP tsIP := strings.TrimPrefix(ts.URL, "http://") + if err := ds.SaveDeviceInfo("acc-123", "12345", &models.ServiceDeviceInfo{ + DeviceID: "12345", + AccountID: "acc-123", + IPAddress: tsIP, + Name: "Test Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo: %v", err) + } + if err := ds.SavePresets("acc-123", "12345", nil); err != nil { + t.Fatalf("SavePresets: %v", err) + } _, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf) if err == nil || !strings.Contains(err.Error(), "DNS discovery server is not enabled") {