fix(setup): verify account data before migration

This commit is contained in:
Lukáš Lipinský
2026-09-05 21:55:20 +02:00
committed by Tobias Gesellchen
parent 6c40d9a6cc
commit b9f0c16275
11 changed files with 802 additions and 7 deletions
@@ -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
+96
View File
@@ -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)
}
}
+10 -3
View File
@@ -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, &notReady) {
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")
+21 -3
View File
@@ -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)
+230
View File
@@ -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, &notReady) {
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)
}
}
+8
View File
@@ -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
+17 -1
View File
@@ -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") {