mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
The diagnostic export captured the symptom of #345 (a TuneIn select escaping to the dead Bose Apigee gateway → BMX_HTTP_ERROR 4501 → INVALID_SOURCE) but none of the data that decides where a speaker sends its marge/BMX/streaming traffic, so we couldn't tell whether the request was ever redirected to AfterTouch. Collect that per speaker: - New collectSpeakerRedirectConfig prefers the on-device SoundTouchSdkPrivateCfg.xml over SSH (archives raw + parses marge/stats/swUpdate/bmxRegistry URLs), and falls back to `getpdo CurrentSystemConfiguration` over telnet when SSH is unavailable — the same channel the telnet migration uses. Parsed URLs and provenance land in diagnostic.json as redirect_config: source (ssh|telnet|none), ssh_reachable, and inferred_migration_method (telnet when only telnet answered, since xml/hosts/resolv all need SSH). - Pull redirection-relevant files over SSH: /etc/hosts(.original), /etc/resolv.conf, the resolv-method hook, /mnt/nv/remote_services, and the pre-migration .original backups (CA bundle and the URL config). - Dump the speaker firewall (iptables-save; ip6tables-save is empty on FW 27.0.6 but harmless) to catch self-inflicted DROP rules (cf. #354). Export ParseGetpdoConfig from pkg/service/setup and add a test pinning the field-name contract the export depends on. Diagnostic-collection only; does not change migration or playback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
package setup
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseGetpdoConfig_StandardLines(t *testing.T) {
|
|
in := "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n"
|
|
|
|
got := parseGetpdoConfig(in)
|
|
|
|
if got["margeServerUrl"] != "http://example:8000" {
|
|
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
|
}
|
|
|
|
if got["bmxRegistryUrl"] != "http://example:8000/bmx/registry/v1/services" {
|
|
t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"])
|
|
}
|
|
}
|
|
|
|
// TestParseGetpdoConfig_ExportedWrapper locks in the field-name contract the
|
|
// diagnostic export (handlers.collectSpeakerRedirectConfig) relies on: the
|
|
// exported wrapper must surface margeServerUrl / statsServerUrl / swUpdateUrl /
|
|
// bmxRegistryUrl from a protobuf-text reply. If the parser's output keys ever
|
|
// change, the export's telnet fallback would silently stop populating URLs —
|
|
// this test fails loudly instead.
|
|
func TestParseGetpdoConfig_ExportedWrapper(t *testing.T) {
|
|
in := `margeServerUrl {
|
|
text: "http://soundtouch.lan:8000"
|
|
}
|
|
statsServerUrl {
|
|
text: "http://soundtouch.lan:8000"
|
|
}
|
|
swUpdateUrl {
|
|
text: "http://soundtouch.lan:8000/updates"
|
|
}
|
|
bmxRegistryUrl {
|
|
text: "http://soundtouch.lan:8000/bmx/registry/v1/services"
|
|
}
|
|
->OK
|
|
`
|
|
|
|
got := ParseGetpdoConfig(in)
|
|
|
|
for _, key := range []string{"margeServerUrl", "statsServerUrl", "swUpdateUrl", "bmxRegistryUrl"} {
|
|
if got[key] == "" {
|
|
t.Errorf("ParseGetpdoConfig missing %q; export relies on this key. got=%v", key, got)
|
|
}
|
|
}
|
|
|
|
if got["bmxRegistryUrl"] != "http://soundtouch.lan:8000/bmx/registry/v1/services" {
|
|
t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"])
|
|
}
|
|
}
|
|
|
|
func TestParseGetpdoConfig_TolerantToNoise(t *testing.T) {
|
|
in := "BoseShell\n-> getpdo CurrentSystemConfiguration\nmargeServerUrl=http://example:8000\nrandom line without equals\n statsServerUrl = http://example:8000 \n-> "
|
|
|
|
got := parseGetpdoConfig(in)
|
|
|
|
if got["margeServerUrl"] != "http://example:8000" {
|
|
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
|
}
|
|
|
|
if got["statsServerUrl"] != "http://example:8000" {
|
|
t.Errorf("statsServerUrl = %q, want trimmed http://example:8000", got["statsServerUrl"])
|
|
}
|
|
|
|
if _, exists := got["random line without equals"]; exists {
|
|
t.Errorf("non-key=value line should not be parsed")
|
|
}
|
|
}
|
|
|
|
// TestParseGetpdoConfig_ProtobufTextRealDevice pins the parser to the
|
|
// live response captured from a SoundTouch 20 (FW 27.0.6.46330.5043500)
|
|
// against http://mac.fritz.box:8000/setup/summary. This is the format
|
|
// the parser actually has to handle in production — the prior
|
|
// key=value-only implementation returned an empty map for this input,
|
|
// which surfaced as empty "Current on Device" cells in the migration
|
|
// UI.
|
|
func TestParseGetpdoConfig_ProtobufTextRealDevice(t *testing.T) {
|
|
in := `margeServerUrl {
|
|
text: "https://streaming.bose.com"
|
|
}
|
|
statsServerUrl {
|
|
text: "https://events.api.bosecm.com"
|
|
}
|
|
swUpdateUrl {
|
|
text: "https://worldwide.bose.com/updates/soundtouch"
|
|
}
|
|
isZeroconfEnabled {
|
|
text: true
|
|
}
|
|
usePandoraProductionServer {
|
|
text: true
|
|
}
|
|
saveMargeCustomerReport {
|
|
text: false
|
|
}
|
|
bmxRegistryUrl {
|
|
text: "https://content.api.bose.io/bmx/registry/v1/services"
|
|
}
|
|
|
|
->OK
|
|
->`
|
|
|
|
got := parseGetpdoConfig(in)
|
|
|
|
want := map[string]string{
|
|
"margeServerUrl": "https://streaming.bose.com",
|
|
"statsServerUrl": "https://events.api.bosecm.com",
|
|
"swUpdateUrl": "https://worldwide.bose.com/updates/soundtouch",
|
|
"bmxRegistryUrl": "https://content.api.bose.io/bmx/registry/v1/services",
|
|
"isZeroconfEnabled": "true",
|
|
"usePandoraProductionServer": "true",
|
|
"saveMargeCustomerReport": "false",
|
|
}
|
|
|
|
for k, v := range want {
|
|
if got[k] != v {
|
|
t.Errorf("%s = %q, want %q", k, got[k], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossCheckPreflights_AgreementProducesNoWarnings(t *testing.T) {
|
|
m := &Manager{ServerURL: "http://example:8000"}
|
|
|
|
summary := &MigrationSummary{
|
|
ParsedCurrentConfig: &PrivateCfg{
|
|
MargeServerUrl: "http://example:8000",
|
|
StatsServerUrl: "http://example:8000",
|
|
SwUpdateUrl: "http://example:8000/updates/soundtouch",
|
|
BmxRegistryUrl: "http://example:8000/bmx/registry/v1/services",
|
|
},
|
|
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n" +
|
|
"statsServerUrl=http://example:8000\n" +
|
|
"swUpdateUrl=http://example:8000/updates/soundtouch\n" +
|
|
"bmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
|
}
|
|
|
|
m.crossCheckPreflights(summary)
|
|
|
|
if len(summary.Warnings) != 0 {
|
|
t.Errorf("Warnings = %v, want none when both transports agree", summary.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestCrossCheckPreflights_MismatchProducesWarning(t *testing.T) {
|
|
m := &Manager{ServerURL: "http://example:8000"}
|
|
|
|
// SSH-XML still shows the original cloud URL (envswitch wrote the
|
|
// runtime layer but the on-device file hasn't been re-rendered).
|
|
summary := &MigrationSummary{
|
|
ParsedCurrentConfig: &PrivateCfg{
|
|
MargeServerUrl: "https://streaming.bose.com",
|
|
},
|
|
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
|
}
|
|
|
|
m.crossCheckPreflights(summary)
|
|
|
|
if len(summary.Warnings) != 1 {
|
|
t.Fatalf("Warnings = %v, want exactly one warning", summary.Warnings)
|
|
}
|
|
|
|
w := summary.Warnings[0]
|
|
|
|
if !strings.Contains(w, "margeServerUrl") {
|
|
t.Errorf("warning %q should name the field", w)
|
|
}
|
|
|
|
if !strings.Contains(w, "streaming.bose.com") || !strings.Contains(w, "example:8000") {
|
|
t.Errorf("warning %q should quote both values", w)
|
|
}
|
|
}
|
|
|
|
func TestCrossCheckPreflights_NoWarningWhenTelnetMissesField(t *testing.T) {
|
|
m := &Manager{ServerURL: "http://example:8000"}
|
|
|
|
summary := &MigrationSummary{
|
|
ParsedCurrentConfig: &PrivateCfg{
|
|
MargeServerUrl: "http://example:8000",
|
|
StatsServerUrl: "http://example:8000",
|
|
},
|
|
// getpdo only echoes margeServerUrl — statsServerUrl is silently
|
|
// absent on this firmware. Absence is not a disagreement.
|
|
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
|
}
|
|
|
|
m.crossCheckPreflights(summary)
|
|
|
|
if len(summary.Warnings) != 0 {
|
|
t.Errorf("Warnings = %v, want none when a field is missing from one transport", summary.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestCrossCheckPreflights_OnlyOneTransportPresent(t *testing.T) {
|
|
m := &Manager{ServerURL: "http://example:8000"}
|
|
|
|
t.Run("telnet only", func(t *testing.T) {
|
|
summary := &MigrationSummary{
|
|
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
|
}
|
|
m.crossCheckPreflights(summary)
|
|
if len(summary.Warnings) != 0 {
|
|
t.Errorf("Warnings = %v, want none when SSH didn't read the XML", summary.Warnings)
|
|
}
|
|
})
|
|
|
|
t.Run("ssh only", func(t *testing.T) {
|
|
summary := &MigrationSummary{
|
|
ParsedCurrentConfig: &PrivateCfg{MargeServerUrl: "http://example:8000"},
|
|
}
|
|
m.crossCheckPreflights(summary)
|
|
if len(summary.Warnings) != 0 {
|
|
t.Errorf("Warnings = %v, want none when telnet didn't respond", summary.Warnings)
|
|
}
|
|
})
|
|
}
|