mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
- Add GetAccountByRefreshToken to amazon.Service — the speaker sends the bare Atzr| refresh token (extracted from AmazonSecret JSON), not a surrogate, so lookup must match against Account.RefreshToken - Add amazonService field, SetAmazonService and IsAmazonConfigured to Server (step 5 essentials required by the handler) - Replace HandleBoseAmazonToken 501 stub with full implementation: lookup by refresh token → RefreshAccessToken; fallback to GetFreshToken; fallback to HandleBoseProxy if no service configured; scope intentionally omitted from response - Add handler tests covering the by-refresh-token path (mock LWA server), the default-account path, and the no-service fallback - Unlock assertions in post_oauth_token_amazon.http integration test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
203 lines
5.5 KiB
Go
203 lines
5.5 KiB
Go
package spotify
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
|
)
|
|
|
|
// TestPushSpotifyCredentials_FullRoundTrip starts a mock "speaker" ZeroConf server,
|
|
// has it generate its own DH key pair, and verifies that the client correctly
|
|
// encrypts and delivers the Spotify credentials.
|
|
func TestPushSpotifyCredentials_FullRoundTrip(t *testing.T) {
|
|
speakerPrivate, speakerPublicBytes, err := zeroconf.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 := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes)
|
|
encKey, macKey := zeroconf.DeriveKeys(shared)
|
|
|
|
plaintext, err := zeroconf.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 = "spotifyuser@example.com"
|
|
const wantToken = "eyJhbGciOiJSUzI1NiJ9.access-token"
|
|
|
|
if err := PushSpotifyCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
|
t.Fatalf("PushSpotifyCredentials: %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 got.authType != 4 {
|
|
t.Errorf("authType = %d, want 4 (AUTHENTICATION_SPOTIFY_TOKEN)", got.authType)
|
|
}
|
|
}
|
|
|
|
// TestPushSpotifyCredentials_FallbackOnGetInfoFailure verifies that when getInfo
|
|
// returns a non-200 response (older firmware without DH support), PushSpotifyCredentials
|
|
// falls back to the simplified tokenType=accesstoken POST.
|
|
func TestPushSpotifyCredentials_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 = "spotifyuser@example.com"
|
|
const wantToken = "raw-access-token"
|
|
|
|
if err := PushSpotifyCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
|
t.Fatalf("PushSpotifyCredentials: %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"])
|
|
}
|
|
}
|
|
|
|
type parsedCredentials struct {
|
|
username string
|
|
authType int
|
|
authData []byte
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|