Files
Bose-SoundTouch/pkg/service/handlers/issue285_regression_test.go
Tobias GesellchenandClaude Opus 4.7 feadc478d5 test: sweep example data in test files to RFC-5737 + placeholders
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.

Mapping applied:
  192.168.178.[0-9]+   → 192.0.2.[same]
  192.168.1.[0-9]+     → 192.0.2.[same]
  Sound Machinechen    → Living Room SoundTouch
  A Sound Machine      → Kitchen SoundTouch
  A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
  A81B6A849D99         → AABBCCDDEE01
  A81B6A849D88         → AABBCCDDEE03
  A81B6A536A09         → AABBCCDDEE04
  884AEAEEBD27         → AABBCCDDEE02
  3230304              → 1000001
  9569497              → 1000002

Two semantic fixes alongside the bulk swap:

- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
  "strips query" cases pin acceptance of RFC-1918 192.168/16. They
  must use a real 192.168 value; doc-range IPs would (correctly) be
  rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
  enough not to match any home LAN default, real enough for the
  validator. Added a comment explaining why this single test still
  carries a 192.168 literal.

- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
  device's `od -An -tu1` byte output, which is space-separated
  octets ("192 168 1 100"). My sed only matched the dot-separated
  form, so the mock was returning the old IP while the test
  assertions had moved to the doc range. Updated to " 192 0 2 100".

go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00

354 lines
12 KiB
Go

package handlers
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestIssue285_RenamePutAcceptedAndPersisted reproduces the rename
// loop documented in issue #285:
//
// https://github.com/gesellix/Bose-SoundTouch/issues/285
//
// When a user renames an ST10 via the Bose App or via
// `soundtouch-cli name set`, the speaker fires PUT
// /streaming/account/{accountID}/device/{deviceID} with a body of
// the form:
//
// <device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>
//
// Before this commit the router only registered POST for that path;
// PUT fell through to the chi router's default handling and the
// speaker observed HTTP 502 (captured verbatim in
// _/i285/Rename.log:38: "SimpleURLFetcher: retry needed, Curl 0,
// http 502, retries remaining 0"). The speaker retried in a loop
// and the Bose App showed the rename spinning indefinitely.
//
// The fixture at testdata/issue285/rename_request.xml is the exact
// payload from the log (line 36) — `deviceid="AABBCCDDEE02"`,
// `<name>Wohnzimmer SB</name>`. The test:
//
// 1. Pre-seeds the datastore with a device record under the
// reporter's accountID + deviceID so the PUT is updating, not
// creating.
// 2. Replays the rename PUT.
// 3. Asserts:
// - HTTP 200 (NOT 201; this is an update, not a create — speakers
// observed 502 before, so any 2xx is the headline fix, but
// pinning 200 protects against accidentally returning 201
// which would change the Location-header contract).
// - Response body carries the new name verbatim.
// - Persisted Sources/DeviceInfo on disk reflects the new name.
//
// When future work decides to preserve `createdOn` across updates
// (currently AddDeviceToAccount rewrites both timestamps), update
// the test to also assert that — the rename request from the log
// does NOT carry a createdOn, so any value our marge response
// emits is purely our choice and should be stable.
func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) {
tempDir, err := os.MkdirTemp("", "issue285-")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
const (
accountID = "3981561"
deviceID = "AABBCCDDEE02"
oldName = "Wohnzimmer"
newName = "Wohnzimmer SB"
preExistingIP = "192.168.0.109"
preExistingPaired = "2017-02-07T11:13:03.000+00:00"
)
// 1. Seed datastore with the device under its original name and
// a known pre-existing first-paired timestamp. The pre-existing
// data models a long-paired device the user is now renaming —
// CreatedOn must survive the PUT (real Bose preserves it
// across renames; see parity capture at
// data/parity_mismatches/1771797308__streaming_account_1000001_device_AABBCCDDEEFF.json).
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
Name: oldName,
IPAddress: preExistingIP,
CreatedOn: preExistingPaired,
}); err != nil {
t.Fatalf("seed datastore: %v", err)
}
// 2. Spin up the router and replay the captured rename PUT.
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
body, err := os.ReadFile(filepath.Join("testdata", "issue285", "rename_request.xml"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
// Sanity-check the fixture before trusting any downstream
// assertion against it.
if !bytes.Contains(body, []byte(`deviceid="`+deviceID+`"`)) {
t.Fatalf("fixture missing expected deviceid=%q; got:\n%s", deviceID, body)
}
if !bytes.Contains(body, []byte(`<name>`+newName+`</name>`)) {
t.Fatalf("fixture missing expected new name %q; got:\n%s", newName, body)
}
req, err := http.NewRequest(http.MethodPut,
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
bytes.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// 3. Headline assertion: the speaker observed 502 before — any
// 2xx fixes the loop. Pin 200 specifically so we don't drift
// into 201/Created (which would change the Location-header
// contract POST gets).
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
// Response shape: <device …><name>NEW</name>…</device>
if !bytes.Contains(respBody, []byte(`deviceid="`+deviceID+`"`)) {
t.Errorf("response missing deviceid=%q; body:\n%s", deviceID, respBody)
}
if !bytes.Contains(respBody, []byte(`<name>`+newName+`</name>`)) {
t.Errorf("response missing new name %q; body:\n%s", newName, respBody)
}
if strings.Contains(string(respBody), `<name>`+oldName+`</name>`) {
t.Errorf("response still carries old name %q; body:\n%s", oldName, respBody)
}
// Parity assertion: the pre-existing first-paired CreatedOn
// must survive the rename. This is the load-bearing fix versus
// the prior behaviour that rewrote `now()` on every PUT, and
// matches what real Bose's pre-shutdown 200 OK responses
// carried (see the parity capture referenced above).
if !bytes.Contains(respBody, []byte(`<createdOn>`+preExistingPaired+`</createdOn>`)) {
t.Errorf("response did not preserve pre-existing CreatedOn %q; body:\n%s", preExistingPaired, respBody)
}
// Parity assertion: the pre-existing IP address must survive
// the rename. The request body doesn't carry an `<ipaddress>`,
// so the datastore merge has to inject what was already on
// disk rather than writing back empty.
if !bytes.Contains(respBody, []byte(`<ipaddress>`+preExistingIP+`</ipaddress>`)) {
t.Errorf("response did not preserve pre-existing IPAddress %q; body:\n%s", preExistingIP, respBody)
}
// Parity assertion: UpdatedOn refreshes. Don't pin the exact
// value — it's "now()" — but assert it's present and
// non-empty.
if !bytes.Contains(respBody, []byte(`<updatedOn>`)) ||
bytes.Contains(respBody, []byte(`<updatedOn></updatedOn>`)) {
t.Errorf("response missing or empty <updatedOn>; body:\n%s", respBody)
}
// 4. Persistence assertion: the datastore now reflects the new
// name AND keeps the original CreatedOn. This is what the
// Bose App reads back on its next /streaming/account/.../full
// poll, which is what closes the visible rename loop.
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
if err != nil {
t.Fatalf("read persisted device info: %v", err)
}
if persisted.Name != newName {
t.Errorf("persisted Name = %q, want %q", persisted.Name, newName)
}
if persisted.CreatedOn != preExistingPaired {
t.Errorf("persisted CreatedOn = %q, want %q (preserved across rename)", persisted.CreatedOn, preExistingPaired)
}
if persisted.IPAddress != preExistingIP {
t.Errorf("persisted IPAddress = %q, want %q (preserved across rename)", persisted.IPAddress, preExistingIP)
}
if persisted.UpdatedOn == "" {
t.Errorf("persisted UpdatedOn is empty; want a fresh timestamp from the rename")
}
}
// TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps covers the
// "first-time registration" path on a PUT (which can happen if the
// speaker emits a rename before AfterTouch has ever heard of it).
// With no pre-existing datastore record:
//
// - CreatedOn must be a fresh timestamp (no record to preserve).
// - IPAddress must come from r.RemoteAddr (the inbound connection)
// since the request body doesn't carry one.
// - UpdatedOn must be the same fresh timestamp.
//
// Pairs with the parity-preservation assertions in the main test:
// existing records win, but new records seed sensibly instead of
// landing with empty CreatedOn / IPAddress.
func TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps(t *testing.T) {
tempDir, err := os.MkdirTemp("", "issue285-new-")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
const (
accountID = "1111111"
deviceID = "AABBCCDDEEFF"
newName = "Living Room SoundTouch"
)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
`<device deviceid="` + deviceID + `"><name>` + newName + `</name><macaddress>` + deviceID + `</macaddress></device>`)
req, err := http.NewRequest(http.MethodPut,
ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID,
bytes.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
// CreatedOn present and non-empty (will be "now()" since no
// prior record existed).
if !bytes.Contains(respBody, []byte(`<createdOn>`)) ||
bytes.Contains(respBody, []byte(`<createdOn></createdOn>`)) {
t.Errorf("first-registration response missing CreatedOn; body:\n%s", respBody)
}
// IPAddress should be the httptest connection's remote host
// (127.0.0.1) since the body didn't carry one and there was
// no existing record to preserve from.
if !bytes.Contains(respBody, []byte(`<ipaddress>127.0.0.1</ipaddress>`)) {
t.Errorf("first-registration response missing IPAddress from RemoteAddr; body:\n%s", respBody)
}
// Persistence: CreatedOn and IPAddress on disk too.
persisted, err := ds.GetDeviceInfo(accountID, deviceID)
if err != nil {
t.Fatalf("read persisted device info: %v", err)
}
if persisted.CreatedOn == "" {
t.Errorf("persisted CreatedOn is empty for new device; want a fresh timestamp")
}
if persisted.IPAddress != "127.0.0.1" {
t.Errorf("persisted IPAddress = %q, want %q (from RemoteAddr)", persisted.IPAddress, "127.0.0.1")
}
}
// TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
// check: if the speaker (or a bug elsewhere) ever sends a PUT with
// a body whose `deviceid="…"` doesn't match the URL's `{device}`
// segment, we refuse with 400 rather than silently re-key the
// persisted record under the wrong account/device.
func TestIssue285_RenamePutRejectsMismatchedDeviceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "issue285-mismatch-")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
const urlDeviceID = "AABBCCDDEE02"
// Body claims a different deviceID than the URL.
body := []byte(`<?xml version="1.0" encoding="UTF-8" ?>` +
`<device deviceid="DEADBEEFCAFE"><name>Rogue</name><macaddress>DEADBEEFCAFE</macaddress></device>`)
req, err := http.NewRequest(http.MethodPut,
ts.URL+"/streaming/account/3981561/device/"+urlDeviceID,
bytes.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("PUT status = %d, want 400; body:\n%s", resp.StatusCode, respBody)
}
// Mismatched body must be rejected *before* the upsert runs —
// otherwise the datastore ends up with a row keyed on the body's
// deviceID even though we return 400. Verify by reading both keys.
if got, _ := ds.GetDeviceInfo("3981561", "DEADBEEFCAFE"); got != nil {
t.Fatalf("body deviceID DEADBEEFCAFE was persisted despite 400 response: %+v", got)
}
if got, _ := ds.GetDeviceInfo("3981561", urlDeviceID); got != nil {
t.Fatalf("URL deviceID %s was persisted despite 400 response: %+v", urlDeviceID, got)
}
}