mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
On an on-device install, soundtouch-service defaulted its server URL to os.Hostname() when --server-url wasn't set. Since the service runs on the speaker's own Linux, that returns the speaker's internal variant codename (e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself -- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546). Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network, public-network) so the fallback is chosen deliberately instead of guessed: on-device defaults to localhost, public-network refuses to start rather than guess a public address, and the previous hostname-guessing behavior is kept for private-network/unset installs, now with a startup warning. The on-device init script sets DEPLOYMENT_MODE=on-device automatically and now auto-exports aftertouch.conf into the daemon's environment generally, which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD on-device) without any further code change. Verified end-to-end on real ST20 hardware: service now resolves http://localhost:8000, a re-migrate updates the speaker's own runtime config to match, and TuneIn playback works again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
405 lines
11 KiB
Go
405 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
|
|
// exact flags soundtouch-service registers), so loadConfig tests exercise the
|
|
// same parsing/env-var wiring production code does, instead of a hand-rolled
|
|
// stand-in that could silently drift from it.
|
|
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
|
|
t.Helper()
|
|
|
|
app := &cli.App{Flags: serviceFlags}
|
|
set := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
|
|
for _, f := range serviceFlags {
|
|
if err := f.Apply(set); err != nil {
|
|
t.Fatalf("apply flag %v: %v", f.Names(), err)
|
|
}
|
|
}
|
|
|
|
if err := set.Parse(args); err != nil {
|
|
t.Fatalf("parse args %v: %v", args, err)
|
|
}
|
|
|
|
return cli.NewContext(app, set, nil)
|
|
}
|
|
|
|
func TestResolveFallbackHost(t *testing.T) {
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = "localhost"
|
|
}
|
|
|
|
hostname = strings.ToLower(hostname)
|
|
|
|
cases := []struct {
|
|
name string
|
|
deploymentMode string
|
|
wantHost string
|
|
wantWarn bool
|
|
}{
|
|
{"on-device uses localhost, no warning", "on-device", "localhost", false},
|
|
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
|
|
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
|
|
{"unset/legacy behaves like private-network", "", hostname, true},
|
|
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
|
|
if gotHost != tc.wantHost {
|
|
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
|
|
}
|
|
|
|
if gotWarn != tc.wantWarn {
|
|
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
|
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
|
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: unexpected error: %v", err)
|
|
}
|
|
|
|
if config.serverURL != "http://localhost:8000" {
|
|
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
|
|
}
|
|
|
|
if config.httpsDefaultURL != "https://localhost:8443" {
|
|
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
|
|
}
|
|
})
|
|
|
|
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
|
|
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
|
|
if err == nil {
|
|
t.Fatal("expected an error, got nil")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "public-network") {
|
|
t.Errorf("expected error to mention public-network, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
|
|
config, err := loadConfig(newTestServiceContext(t,
|
|
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: unexpected error: %v", err)
|
|
}
|
|
|
|
if config.serverURL != "https://soundtouch.example.com" {
|
|
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
|
|
}
|
|
})
|
|
|
|
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = "localhost"
|
|
}
|
|
|
|
hostname = strings.ToLower(hostname)
|
|
|
|
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: unexpected error: %v", err)
|
|
}
|
|
|
|
want := "http://" + hostname + ":8000"
|
|
if config.serverURL != want {
|
|
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
|
|
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
|
|
config, err := loadConfig(newTestServiceContext(t,
|
|
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
|
|
if err != nil {
|
|
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
|
|
}
|
|
|
|
if config.serverURL != "http://198.51.100.7:8000" {
|
|
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyPersistedSettings(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "main-test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
ds := datastore.NewDataStore(tmpDir)
|
|
|
|
t.Run("overrides true with false", func(t *testing.T) {
|
|
config := &serviceConfig{
|
|
redact: true,
|
|
logBody: true,
|
|
record: true,
|
|
}
|
|
|
|
// Simulate the bug by using the old bitwise OR logic in the test,
|
|
// which should fail if we expect false.
|
|
// config.redact = config.redact || false -> stays true
|
|
|
|
settings := datastore.Settings{
|
|
RedactLogs: false,
|
|
LogBodies: false,
|
|
RecordInteractions: false,
|
|
}
|
|
err := ds.SaveSettings(settings)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save settings: %v", err)
|
|
}
|
|
|
|
applyPersistedSettings(ds, config)
|
|
|
|
if config.redact != false {
|
|
t.Errorf("Expected redact to be false, got true")
|
|
}
|
|
if config.logBody != false {
|
|
t.Errorf("Expected logBody to be false, got true")
|
|
}
|
|
if config.record != false {
|
|
t.Errorf("Expected record to be false, got true")
|
|
}
|
|
})
|
|
|
|
t.Run("retains false when settings are false", func(t *testing.T) {
|
|
settings := datastore.Settings{
|
|
RedactLogs: false,
|
|
}
|
|
err := ds.SaveSettings(settings)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save settings: %v", err)
|
|
}
|
|
|
|
config := &serviceConfig{
|
|
redact: false,
|
|
}
|
|
|
|
applyPersistedSettings(ds, config)
|
|
|
|
if config.redact != false {
|
|
t.Errorf("Expected redact to be false, got true")
|
|
}
|
|
})
|
|
|
|
t.Run("overrides false with true", func(t *testing.T) {
|
|
settings := datastore.Settings{
|
|
RedactLogs: true,
|
|
}
|
|
err := ds.SaveSettings(settings)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save settings: %v", err)
|
|
}
|
|
|
|
config := &serviceConfig{
|
|
redact: false,
|
|
}
|
|
|
|
applyPersistedSettings(ds, config)
|
|
|
|
if config.redact != true {
|
|
t.Errorf("Expected redact to be true, got false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMergeTLSExtraHosts(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cli []string
|
|
persisted []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "CLI only",
|
|
cli: []string{"a.example"},
|
|
persisted: nil,
|
|
want: []string{"a.example"},
|
|
},
|
|
{
|
|
name: "Persisted only",
|
|
cli: nil,
|
|
persisted: []string{"b.example"},
|
|
want: []string{"b.example"},
|
|
},
|
|
{
|
|
name: "CLI wins ordering, persisted appended",
|
|
cli: []string{"a.example"},
|
|
persisted: []string{"b.example"},
|
|
want: []string{"a.example", "b.example"},
|
|
},
|
|
{
|
|
name: "Dedupes overlap",
|
|
cli: []string{"a.example", "b.example"},
|
|
persisted: []string{"b.example", "c.example"},
|
|
want: []string{"a.example", "b.example", "c.example"},
|
|
},
|
|
{
|
|
name: "Drops empty + whitespace",
|
|
cli: []string{" ", "a.example", ""},
|
|
persisted: []string{"", " b.example "},
|
|
want: []string{"a.example", "b.example"},
|
|
},
|
|
{
|
|
name: "Both empty",
|
|
cli: nil,
|
|
persisted: nil,
|
|
want: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
|
|
}
|
|
|
|
for i := range got {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
|
|
// Hostname-based serverURL: the derived OAuth variant must end up
|
|
// in the served TLS cert SAN list, otherwise the speaker rejects
|
|
// the TLS handshake on Spotify / Amazon Music token refresh.
|
|
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
|
|
|
|
want := "macoauth.fritz.box"
|
|
if !contains(got, want) {
|
|
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
|
|
}
|
|
}
|
|
|
|
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
|
|
// IP-based serverURL deliberately yields no derivation (the speaker's
|
|
// `<first-label>oauth.<rest>` construction would be malformed for an
|
|
// IP and no DNS resolver can answer for it). The cert SAN list must
|
|
// not pretend to cover something that can never be queried.
|
|
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
|
|
|
|
for _, h := range got {
|
|
if h == "192oauth.168.0.30" {
|
|
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func TestSettingsFileExists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
if settingsFileExists(dir) {
|
|
t.Fatal("expected false for a dir without settings.json")
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o644); err != nil {
|
|
t.Fatalf("write settings.json: %v", err)
|
|
}
|
|
|
|
if !settingsFileExists(dir) {
|
|
t.Fatal("expected true once settings.json is present")
|
|
}
|
|
|
|
if settingsFileExists("") {
|
|
t.Fatal("expected false for an empty data dir")
|
|
}
|
|
}
|
|
|
|
// applyFirstRunSeed mirrors the startup gate in the CLI Action: a default
|
|
// settings.json is written only when none exists yet, so a hand-authored file
|
|
// is never clobbered.
|
|
func applyFirstRunSeed(ds *datastore.DataStore, config *serviceConfig) {
|
|
existed := settingsFileExists(config.dataDir)
|
|
|
|
applyPersistedSettings(ds, config)
|
|
|
|
if !existed {
|
|
createDefaultSettings(ds, *config)
|
|
}
|
|
}
|
|
|
|
func TestFirstRunSeed_PreservesHandAuthoredSettings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Operator pre-seeds proxy trust but leaves server_url to the --server-url
|
|
// flag. Before the fix this was treated as "first run" and overwritten.
|
|
if err := os.WriteFile(filepath.Join(dir, "settings.json"),
|
|
[]byte(`{"trust_forwarded_headers":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`), 0o644); err != nil {
|
|
t.Fatalf("write settings.json: %v", err)
|
|
}
|
|
|
|
ds := datastore.NewDataStore(dir)
|
|
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
|
|
|
|
applyFirstRunSeed(ds, config)
|
|
|
|
got, err := ds.GetSettings()
|
|
if err != nil {
|
|
t.Fatalf("GetSettings: %v", err)
|
|
}
|
|
|
|
if !got.TrustForwardedHeaders {
|
|
t.Error("trust_forwarded_headers was clobbered on startup")
|
|
}
|
|
|
|
if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
|
|
t.Errorf("trusted_proxy_cidrs was clobbered, got %v", got.TrustedProxyCIDRs)
|
|
}
|
|
}
|
|
|
|
func TestFirstRunSeed_WritesDefaultsWhenAbsent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
ds := datastore.NewDataStore(dir)
|
|
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
|
|
|
|
applyFirstRunSeed(ds, config)
|
|
|
|
got, err := ds.GetSettings()
|
|
if err != nil {
|
|
t.Fatalf("GetSettings: %v", err)
|
|
}
|
|
|
|
if got.ServerURL != "http://192.0.2.1:8000" {
|
|
t.Errorf("expected defaults to be written with server_url, got %q", got.ServerURL)
|
|
}
|
|
}
|