mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
fix(setup): verify account data before migration
This commit is contained in:
committed by
Tobias Gesellchen
parent
6c40d9a6cc
commit
b9f0c16275
@@ -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
|
||||
|
||||
@@ -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("<ContentItem"), []byte("<contentItem"))
|
||||
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
|
||||
|
||||
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
|
||||
|
||||
@@ -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("<presets>"), want: PresetSnapshotMalformed},
|
||||
{name: "valid empty", write: []byte("<presets></presets>"), 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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`<presets/>`)),
|
||||
}, 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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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, `<info deviceID="%s"><name>Test Speaker</name><margeAccountUUID>%s</margeAccountUUID></info>`, 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(`<presets deviceID="` + readinessDevice + `">`)
|
||||
for _, preset := range presets {
|
||||
fmt.Fprintf(&xml, `<preset id="%s"><ContentItem source="%s" type="%s" location="%s" isPresetable="true"><itemName>%s</itemName></ContentItem></preset>`,
|
||||
preset.ButtonNumber, preset.Source, preset.Type, preset.Location, preset.Name)
|
||||
}
|
||||
xml.WriteString(`</presets>`)
|
||||
|
||||
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, `<presets/>`)
|
||||
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, `<presets/>`)
|
||||
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, `<presets/>`)
|
||||
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, `<presets/>`)
|
||||
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(`<presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://radio.example/sibling"><itemName>Sibling Radio</itemName></ContentItem></preset></presets>`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(`<info deviceID="12345"><name>Test Speaker</name><type>ST10</type><maccAddress>00:11:22:33:44:55</maccAddress><margeAccountUUID>acc-123</margeAccountUUID></info>`))
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<presets/>`))
|
||||
}
|
||||
}))
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user