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") {