Files
Bose-SoundTouch/pkg/service/datastore/repair_leaked_source_test.go
Tobias GesellchenandClaude Sonnet 4.6 f0a63f19f4 fix(datastore): self-heal legacy Audio leak on read, preserve speaker intent
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.

GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.

That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.

Also:
  - sourceKeyTypeFromFullSource now logs when the providerid isn't
    canonical and we fall back to upstream Type, so future leak
    signatures are visible instead of silent.
  - Removes the loadServiceView workaround that resolved Source via
    SourceID at consistency-check time — datastore now repairs at
    the layer where every consumer benefits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00

183 lines
5.8 KiB
Go

package datastore
import (
"os"
"path/filepath"
"testing"
)
// TestGetPresets_RepairsAudioLeakViaSourceID exercises the on-read repair:
// a persisted preset whose Source is the protocol-level "Audio" leak gets
// resolved to the speaker-perspective SourceKeyType through the device's
// Sources.xml. Speaker is the source of truth; this just un-rots data the
// legacy syncPresets path wrote.
func TestGetPresets_RepairsAudioLeakViaSourceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-repair-leak-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Sources.xml claims id=14774275 is a TUNEIN source.
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="14774275">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
// Presets.xml carries the leak: source="Audio" + sourceid=14774275.
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="4" createdOn="0" updatedOn="0">
<contentItem source="Audio" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
<itemName>SMOOTH JAZZ</itemName>
<containerArt></containerArt>
</contentItem>
<sourceid>14774275</sourceid>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
ds := NewDataStore(tempDir)
presets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets: %v", err)
}
if len(presets) != 1 {
t.Fatalf("expected 1 preset, got %d", len(presets))
}
if presets[0].Source != "TUNEIN" {
t.Errorf("expected repaired Source=TUNEIN, got %q", presets[0].Source)
}
}
// TestGetPresets_PreservesNonLeakedSource is the GH-343 protection: a
// preset persisted with Source=TUNEIN must stay TUNEIN even when
// Sources.xml has been re-classified to RADIOPLAYER. The speaker's
// previously-stored intent wins over a stale current Sources.xml entry.
func TestGetPresets_PreservesNonLeakedSource(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-preserve-source-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Sources.xml *currently* says id=14774275 is RADIOPLAYER (stale /
// drifted classification). The preset was stored earlier when the
// same id was understood as TUNEIN.
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="14774275">
<sourceKey type="RADIOPLAYER" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="4" createdOn="0" updatedOn="0">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
<itemName>SMOOTH JAZZ</itemName>
</contentItem>
<sourceid>14774275</sourceid>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
ds := NewDataStore(tempDir)
presets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets: %v", err)
}
if presets[0].Source != "TUNEIN" {
t.Errorf("expected speaker's previously-stored Source=TUNEIN to be preserved, got %q (GH-343 silent rewrite would substitute RADIOPLAYER)", presets[0].Source)
}
}
// TestGetRecents_RepairsAudioLeakViaSourceID applies the same load-time
// repair to recents — symmetric protection for the recents pipeline.
func TestGetRecents_RepairsAudioLeakViaSourceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-repair-recent-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="9330201">
<sourceKey type="INTERNET_RADIO" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="rec-1">
<contentItem source="Audio" type="stationurl" location="19059" sourceAccount="" isPresetable="true">
<itemName>Russkoe Radio Ukraine</itemName>
</contentItem>
<sourceid>9330201</sourceid>
</recent>
</recents>`
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
t.Fatalf("write Recents.xml: %v", err)
}
ds := NewDataStore(tempDir)
recents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents: %v", err)
}
if len(recents) != 1 {
t.Fatalf("expected 1 recent, got %d", len(recents))
}
if recents[0].Source != "INTERNET_RADIO" {
t.Errorf("expected repaired Source=INTERNET_RADIO, got %q", recents[0].Source)
}
}