mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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>
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package zeroconf
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestGenerateDHKeyPair(t *testing.T) {
|
|
priv1, pub1, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatalf("GenerateDHKeyPair: %v", err)
|
|
}
|
|
if priv1 == nil || len(pub1) == 0 {
|
|
t.Fatal("expected non-nil private key and non-empty public key")
|
|
}
|
|
if len(pub1) != dhKeySize {
|
|
t.Errorf("public key length = %d, want %d", len(pub1), dhKeySize)
|
|
}
|
|
|
|
// Two calls must produce different key pairs.
|
|
_, pub2, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatalf("GenerateDHKeyPair second call: %v", err)
|
|
}
|
|
if string(pub1) == string(pub2) {
|
|
t.Error("two key-pair generations produced identical public keys")
|
|
}
|
|
}
|
|
|
|
func TestDHCommutativity(t *testing.T) {
|
|
// DH shared secret must be symmetric: A's secret == B's secret.
|
|
privA, pubA, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privB, pubB, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
secretA := ComputeSharedSecret(privA, pubB)
|
|
secretB := ComputeSharedSecret(privB, pubA)
|
|
|
|
if string(secretA) != string(secretB) {
|
|
t.Error("DH shared secrets are not equal (commutativity broken)")
|
|
}
|
|
}
|
|
|
|
func TestDeriveKeys(t *testing.T) {
|
|
sharedSecret := make([]byte, dhKeySize)
|
|
for i := range sharedSecret {
|
|
sharedSecret[i] = byte(i)
|
|
}
|
|
|
|
encKey, macKey := DeriveKeys(sharedSecret)
|
|
|
|
if len(encKey) != 16 {
|
|
t.Errorf("encKey length = %d, want 16", len(encKey))
|
|
}
|
|
if len(macKey) != 20 {
|
|
t.Errorf("macKey length = %d, want 20", len(macKey))
|
|
}
|
|
|
|
// Deterministic: same input → same output.
|
|
encKey2, macKey2 := DeriveKeys(sharedSecret)
|
|
if string(encKey) != string(encKey2) || string(macKey) != string(macKey2) {
|
|
t.Error("DeriveKeys is not deterministic")
|
|
}
|
|
|
|
// Different secrets → different keys.
|
|
other := make([]byte, dhKeySize)
|
|
encKeyOther, _ := DeriveKeys(other)
|
|
if string(encKey) == string(encKeyOther) {
|
|
t.Error("different secrets produced the same encKey")
|
|
}
|
|
}
|
|
|
|
func TestBuildCredentialsBlob(t *testing.T) {
|
|
blob := BuildCredentialsBlob("alice", "tok123", AuthTypeOAuthToken)
|
|
|
|
creds, err := parseCredentialsBlob(blob)
|
|
if err != nil {
|
|
t.Fatalf("parseCredentialsBlob: %v", err)
|
|
}
|
|
if creds.username != "alice" {
|
|
t.Errorf("username = %q, want %q", creds.username, "alice")
|
|
}
|
|
if string(creds.authData) != "tok123" {
|
|
t.Errorf("authData = %q, want %q", string(creds.authData), "tok123")
|
|
}
|
|
if uint64(creds.authType) != AuthTypeOAuthToken {
|
|
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", creds.authType, AuthTypeOAuthToken)
|
|
}
|
|
}
|
|
|
|
func TestEncryptDecryptBlob(t *testing.T) {
|
|
sharedSecret := make([]byte, dhKeySize)
|
|
for i := range sharedSecret {
|
|
sharedSecret[i] = byte(42 + i)
|
|
}
|
|
encKey, macKey := DeriveKeys(sharedSecret)
|
|
|
|
plaintext := []byte("hello zeroconf world")
|
|
|
|
encrypted, err := EncryptBlob(encKey, macKey, plaintext)
|
|
if err != nil {
|
|
t.Fatalf("EncryptBlob: %v", err)
|
|
}
|
|
|
|
decrypted, err := DecryptBlob(encKey, macKey, encrypted)
|
|
if err != nil {
|
|
t.Fatalf("DecryptBlob: %v", err)
|
|
}
|
|
|
|
if string(decrypted) != string(plaintext) {
|
|
t.Errorf("round-trip mismatch: got %q, want %q", decrypted, plaintext)
|
|
}
|
|
|
|
// Tampered checksum must fail.
|
|
tampered := make([]byte, len(encrypted))
|
|
copy(tampered, encrypted)
|
|
tampered[len(tampered)-1] ^= 0xff
|
|
if _, err := DecryptBlob(encKey, macKey, tampered); err == nil {
|
|
t.Error("expected error on tampered checksum, got nil")
|
|
}
|
|
}
|
|
|
|
func TestPushCredentials_FullRoundTrip(t *testing.T) {
|
|
speakerPrivate, speakerPublicBytes, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatalf("speaker keygen: %v", err)
|
|
}
|
|
|
|
type received struct {
|
|
username string
|
|
authData string
|
|
authType int
|
|
}
|
|
var got received
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("action") {
|
|
case "getInfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": 101,
|
|
"statusString": "OK",
|
|
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
|
})
|
|
|
|
case "addUser":
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob"))
|
|
if err != nil {
|
|
http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey"))
|
|
if err != nil {
|
|
http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
shared := ComputeSharedSecret(speakerPrivate, clientKeyBytes)
|
|
encKey, macKey := DeriveKeys(shared)
|
|
|
|
plaintext, err := DecryptBlob(encKey, macKey, blobBytes)
|
|
if err != nil {
|
|
http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
creds, err := parseCredentialsBlob(plaintext)
|
|
if err != nil {
|
|
http.Error(w, "parse failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
got.username = creds.username
|
|
got.authData = string(creds.authData)
|
|
got.authType = creds.authType
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
const wantUsername = "user@example.com"
|
|
const wantToken = "eyJhbGciOiJSUzI1NiJ9.access-token"
|
|
|
|
if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
|
t.Fatalf("PushCredentials: %v", err)
|
|
}
|
|
|
|
if got.username != wantUsername {
|
|
t.Errorf("username = %q, want %q", got.username, wantUsername)
|
|
}
|
|
if got.authData != wantToken {
|
|
t.Errorf("authData = %q, want %q", got.authData, wantToken)
|
|
}
|
|
if uint64(got.authType) != AuthTypeOAuthToken {
|
|
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, AuthTypeOAuthToken)
|
|
}
|
|
}
|
|
|
|
func TestPushCredentials_FallbackOnGetInfoFailure(t *testing.T) {
|
|
var receivedForm map[string]string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("action") {
|
|
case "getInfo":
|
|
http.Error(w, "not supported", http.StatusNotFound)
|
|
case "addUser":
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
receivedForm = map[string]string{
|
|
"userName": r.FormValue("userName"),
|
|
"blob": r.FormValue("blob"),
|
|
"clientKey": r.FormValue("clientKey"),
|
|
"tokenType": r.FormValue("tokenType"),
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
const wantUsername = "user@example.com"
|
|
const wantToken = "raw-access-token"
|
|
|
|
if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
|
t.Fatalf("PushCredentials: %v", err)
|
|
}
|
|
|
|
if receivedForm == nil {
|
|
t.Fatal("addUser was never called")
|
|
}
|
|
if receivedForm["userName"] != wantUsername {
|
|
t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername)
|
|
}
|
|
if receivedForm["blob"] != wantToken {
|
|
t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken)
|
|
}
|
|
if receivedForm["tokenType"] != "accesstoken" {
|
|
t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken")
|
|
}
|
|
if receivedForm["clientKey"] != "" {
|
|
t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"])
|
|
}
|
|
}
|
|
|
|
// parseCredentialsBlob is the inverse of BuildCredentialsBlob, used in tests.
|
|
func parseCredentialsBlob(data []byte) (*parsedCredentials, error) {
|
|
var r parsedCredentials
|
|
i := 0
|
|
for i < len(data) {
|
|
tag := data[i]
|
|
i++
|
|
fieldNum := tag >> 3
|
|
wireType := tag & 0x07
|
|
switch wireType {
|
|
case 0: // varint
|
|
val, n := readProtoVarint(data[i:])
|
|
i += n
|
|
if fieldNum == 5 {
|
|
r.authType = int(val)
|
|
}
|
|
case 2: // length-delimited
|
|
length, n := readProtoVarint(data[i:])
|
|
i += n
|
|
value := data[i : i+int(length)]
|
|
i += int(length)
|
|
switch fieldNum {
|
|
case 1:
|
|
r.username = string(value)
|
|
case 4:
|
|
r.authData = value
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported wire type %d at offset %d", wireType, i-1)
|
|
}
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
type parsedCredentials struct {
|
|
username string
|
|
authType int
|
|
authData []byte
|
|
}
|
|
|
|
func readProtoVarint(data []byte) (uint64, int) {
|
|
var val uint64
|
|
for i, b := range data {
|
|
val |= uint64(b&0x7f) << (7 * uint(i))
|
|
if b&0x80 == 0 {
|
|
return val, i + 1
|
|
}
|
|
}
|
|
return 0, len(data)
|
|
}
|
|
|
|
func TestValidateZcBaseURL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
wantOK bool
|
|
wantHost string // expected u.Host on success
|
|
wantPath string
|
|
}{
|
|
{"loopback", "http://127.0.0.1:8200/zc", true, "127.0.0.1:8200", "/zc"},
|
|
{"loopback no port", "http://127.0.0.1/zc", true, "127.0.0.1", "/zc"},
|
|
// The "private 192" and "strips query" cases must use an
|
|
// RFC-1918 192.168/16 value — validateZcBaseURL only accepts
|
|
// loopback, RFC-1918, and link-local. RFC-5737 doc IPs
|
|
// (which we use as placeholders elsewhere) would be rejected
|
|
// here, so the test uses a generic-but-real 192.168 value.
|
|
{"private 192", "http://192.168.10.10:8200/zc", true, "192.168.10.10:8200", "/zc"},
|
|
{"private 10", "http://10.0.0.5/zc", true, "10.0.0.5", "/zc"},
|
|
{"private 172", "http://172.16.0.1/zc", true, "172.16.0.1", "/zc"},
|
|
{"link-local v4", "http://169.254.10.20/zc", true, "169.254.10.20", "/zc"},
|
|
{"ipv6 loopback", "http://[::1]:8200/zc", true, "[::1]:8200", "/zc"},
|
|
{"ipv6 link-local", "http://[fe80::1]:8200/zc", true, "[fe80::1]:8200", "/zc"},
|
|
{"strips query", "http://192.168.10.10:8200/zc?foo=bar", true, "192.168.10.10:8200", "/zc"},
|
|
|
|
{"public IP rejected", "http://1.1.1.1/zc", false, "", ""},
|
|
{"public ipv6 rejected", "http://[2001:db8::1]/zc", false, "", ""},
|
|
{"hostname rejected", "http://myspeaker.local/zc", false, "", ""},
|
|
{"plain hostname rejected", "http://speaker/zc", false, "", ""},
|
|
{"ftp scheme rejected", "ftp://192.0.2.10/zc", false, "", ""},
|
|
{"file scheme rejected", "file:///etc/passwd", false, "", ""},
|
|
{"empty host rejected", "http:///zc", false, "", ""},
|
|
{"unparseable rejected", "::not a url::", false, "", ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := validateZcBaseURL(tc.input)
|
|
if tc.wantOK {
|
|
if err != nil {
|
|
t.Fatalf("validateZcBaseURL(%q) returned error %v, want success", tc.input, err)
|
|
}
|
|
if got.Host != tc.wantHost {
|
|
t.Errorf("Host = %q, want %q", got.Host, tc.wantHost)
|
|
}
|
|
if got.Path != tc.wantPath {
|
|
t.Errorf("Path = %q, want %q", got.Path, tc.wantPath)
|
|
}
|
|
if got.RawQuery != "" {
|
|
t.Errorf("RawQuery = %q, want empty (validator should strip query)", got.RawQuery)
|
|
}
|
|
} else if err == nil {
|
|
t.Errorf("validateZcBaseURL(%q) succeeded, want error", tc.input)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPushCredentials_AddUserNoOp covers the firmware quirk we observed in
|
|
// production: ?action=addUser sometimes returns 404 with an empty body when
|
|
// the speaker already has the requested user as its active one. That is NOT a
|
|
// failure — the speaker silently kept its state. PushCredentials must signal
|
|
// this via ErrAddUserNoOp so the watchdog can demote it from "Failed to prime"
|
|
// to a benign success.
|
|
func TestPushCredentials_AddUserNoOp(t *testing.T) {
|
|
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatalf("speaker keygen: %v", err)
|
|
}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("action") {
|
|
case "getInfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": 101,
|
|
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
|
})
|
|
case "addUser":
|
|
// Firmware no-op: 404 + empty body, no Server / Content-Type header.
|
|
w.WriteHeader(http.StatusNotFound)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err = PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
|
if !errors.Is(err, ErrAddUserNoOp) {
|
|
t.Fatalf("PushCredentials: got %v, want ErrAddUserNoOp", err)
|
|
}
|
|
}
|
|
|
|
// TestPushCredentials_AddUserNoOpInSimplifiedPath asserts the same narrow
|
|
// pattern is recognised on the simplified-token fallback (firmware that
|
|
// 404s getInfo entirely).
|
|
func TestPushCredentials_AddUserNoOpInSimplifiedPath(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("action") {
|
|
case "getInfo":
|
|
http.Error(w, "not supported", http.StatusNotFound)
|
|
case "addUser":
|
|
w.WriteHeader(http.StatusNotFound) // empty body
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err := PushCredentials(srv.URL+"/zc", "gesellix", "raw-access-token")
|
|
if !errors.Is(err, ErrAddUserNoOp) {
|
|
t.Fatalf("PushCredentials (simplified path): got %v, want ErrAddUserNoOp", err)
|
|
}
|
|
}
|
|
|
|
// TestPushCredentials_AddUserRealError_NotMisclassified guards the narrowness
|
|
// of isAddUserNoOp: a 404 *with* a body (or any non-404 error) must still
|
|
// surface as a regular error, not the benign sentinel. Otherwise we'd silently
|
|
// swallow genuine credential rejections that happen to come back as 4xx.
|
|
func TestPushCredentials_AddUserRealError_NotMisclassified(t *testing.T) {
|
|
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
|
if err != nil {
|
|
t.Fatalf("speaker keygen: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
}{
|
|
{"404 with body should NOT be no-op", http.StatusNotFound, "spotifyError=12 invalid_token"},
|
|
{"400 empty body should NOT be no-op", http.StatusBadRequest, ""},
|
|
{"500 empty body should NOT be no-op", http.StatusInternalServerError, ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("action") {
|
|
case "getInfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": 101,
|
|
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
|
})
|
|
case "addUser":
|
|
w.WriteHeader(tc.status)
|
|
if tc.body != "" {
|
|
_, _ = w.Write([]byte(tc.body))
|
|
}
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err := PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
if errors.Is(err, ErrAddUserNoOp) {
|
|
t.Errorf("got ErrAddUserNoOp, want a real failure for status=%d body=%q", tc.status, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|