mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Fix a complete flow for Spotify registration, preset
This commit is contained in:
@@ -119,6 +119,7 @@ test-http-client:
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
|
||||
@@ -159,6 +159,16 @@ The app notifies the physical SoundTouch speaker about the new source. This is u
|
||||
|
||||
---
|
||||
|
||||
## Implementation in SoundTouch-Service
|
||||
|
||||
This project implements the "Bose-mediated token" flow as follows:
|
||||
|
||||
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
|
||||
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
|
||||
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
|
||||
|
||||
---
|
||||
|
||||
## Placeholders and Constants
|
||||
|
||||
| Placeholder | Description |
|
||||
|
||||
@@ -709,6 +709,7 @@ type FullResponseSource struct {
|
||||
Username string `json:"username" xml:"username"`
|
||||
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
|
||||
SourceLabel string `json:"source_label" xml:"-"`
|
||||
SecretType string `json:"secret_type,omitempty" xml:"secretType,attr,omitempty"`
|
||||
}
|
||||
|
||||
// FullResponsePreset represents a preset specifically for the /full response.
|
||||
|
||||
@@ -122,4 +122,54 @@ func TestSpotifyBridge(t *testing.T) {
|
||||
if !speakerReceived {
|
||||
t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification")
|
||||
}
|
||||
|
||||
// 3. Verify Token Refresh via Surrogate
|
||||
// Now simulate the speaker asking for a fresh token using the surrogate secret it received.
|
||||
// We need to find the surrogate first.
|
||||
sources, _ = ds.GetConfiguredSources("acc123", "DEV123")
|
||||
var surrogate string
|
||||
for _, src := range sources {
|
||||
if src.SourceKey.Type == "SPOTIFY" {
|
||||
surrogate = src.Secret
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if surrogate == "" {
|
||||
t.Fatal("Could not find surrogate token in Marge sources")
|
||||
}
|
||||
|
||||
if len(surrogate) != 32 {
|
||||
t.Errorf("Expected surrogate to be 32 hex chars, got %s", surrogate)
|
||||
}
|
||||
|
||||
// Request refresh
|
||||
refreshReqBody := map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": surrogate,
|
||||
}
|
||||
body, err := json.Marshal(refreshReqBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal refresh request: %v", err)
|
||||
}
|
||||
|
||||
refreshReq := httptest.NewRequest("POST", "/oauth/device/DEV123/music/musicprovider/15/token/cs3", strings.NewReader(string(body)))
|
||||
refreshW := httptest.NewRecorder()
|
||||
|
||||
// Need to register the route for testing
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.ServeHTTP(refreshW, refreshReq)
|
||||
|
||||
if refreshW.Code != http.StatusOK {
|
||||
t.Fatalf("Token refresh failed: %d: %s", refreshW.Code, refreshW.Body.String())
|
||||
}
|
||||
|
||||
var refreshResp map[string]interface{}
|
||||
if err := json.Unmarshal(refreshW.Body.Bytes(), &refreshResp); err != nil {
|
||||
t.Fatalf("Failed to parse refresh response: %v", err)
|
||||
}
|
||||
|
||||
if refreshResp["access_token"] != "access-123" {
|
||||
t.Errorf("Expected access_token 'access-123', got '%v'", refreshResp["access_token"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,19 +457,25 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
presetNumber, err := strconv.Atoi(presetNumberStr)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Invalid preset number: %s", presetNumberStr)
|
||||
http.Error(w, "Invalid preset number", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Failed to read body: %v", err)
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] UpdatePreset failed for account=%s, device=%s, preset=%d: %v", account, device, presetNumber, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\">",
|
||||
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\" secretType=\"token\">",
|
||||
"displayName=\"\"", // for the other sources
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,14 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
|
||||
log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID)
|
||||
|
||||
// 1. Register in Marge (updates configuredsources.xml for all devices in the account)
|
||||
_, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", acc.AccessToken, "token_version_3", acc.DisplayName)
|
||||
// We use the BoseSecret as the credential instead of the AccessToken
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
// Fallback to AccessToken if BoseSecret is not available (for old accounts)
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
_, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", credential, "token_version_3", acc.DisplayName)
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err)
|
||||
continue
|
||||
@@ -240,7 +247,7 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
|
||||
continue
|
||||
}
|
||||
|
||||
creds := models.NewSpotifyOAuthCredentials(acc.UserID, acc.AccessToken, acc.DisplayName)
|
||||
creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
|
||||
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -114,12 +115,61 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// We use the first linked account.
|
||||
accessToken, _, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
// However, if the request provides a "secret" (which we use as our Bose surrogate token),
|
||||
// we should use that to find the specific account.
|
||||
var (
|
||||
account *spotify.Account
|
||||
accessToken string
|
||||
userID string
|
||||
)
|
||||
|
||||
return
|
||||
// Spotify registration/refresh often passes the secret in the body as "refresh_token"
|
||||
// or in the registration flow as "code".
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
|
||||
var tokenReq struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
_ = json.Unmarshal(body, &tokenReq)
|
||||
|
||||
secret := tokenReq.RefreshToken
|
||||
if secret == "" {
|
||||
secret = tokenReq.Code
|
||||
}
|
||||
|
||||
if secret != "" {
|
||||
if acc, ok := svc.GetAccountBySecret(secret); ok {
|
||||
account = acc
|
||||
log.Printf("[Spotify Proxy] Found account for secret %s: %s", secret, acc.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
if account != nil {
|
||||
if err := svc.RefreshAccessToken(account); err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
accessToken = account.AccessToken
|
||||
} else {
|
||||
// Fallback to first account for backward compatibility or when secret is missing
|
||||
var err error
|
||||
|
||||
accessToken, userID, err = svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Proxy] Using default account %s", userID)
|
||||
}
|
||||
|
||||
// Format response as expected by Bose firmware.
|
||||
|
||||
@@ -751,6 +751,7 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
|
||||
SourceSettings: "",
|
||||
UpdatedOn: s.UpdatedOn,
|
||||
Username: s.Username,
|
||||
SecretType: s.SecretType,
|
||||
}
|
||||
|
||||
mapToFullResponseCredential(s, &fullSource)
|
||||
@@ -1112,7 +1113,11 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
|
||||
var matchingSrc *models.ConfiguredSource
|
||||
|
||||
log.Printf("[Marge] Searching for source matching ID=%s in %d sources", newPresetElem.SourceID, len(sources))
|
||||
|
||||
for i := range sources {
|
||||
log.Printf("[Marge] Source[%d]: ID=%s, Type=%s, SourceKeyType=%s, SourceKeyAccount=%s", i, sources[i].ID, sources[i].Type, sources[i].SourceKeyType, sources[i].SourceKeyAccount)
|
||||
|
||||
if sources[i].ID == newPresetElem.SourceID {
|
||||
matchingSrc = &sources[i]
|
||||
break
|
||||
@@ -1120,7 +1125,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
}
|
||||
|
||||
if matchingSrc == nil {
|
||||
if newPresetElem.SourceID == "INTERNET_RADIO" || newPresetElem.SourceID == "TUNEIN" {
|
||||
if newPresetElem.SourceID == "INTERNET_RADIO" || newPresetElem.SourceID == "TUNEIN" || newPresetElem.SourceID == "SPOTIFY" {
|
||||
// Find by SourceKeyType instead of ID if it's a default source
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == newPresetElem.SourceID {
|
||||
@@ -1180,6 +1185,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential"`
|
||||
}{
|
||||
ID: matchingSrc.ID,
|
||||
SourceName: newPresetElem.Name,
|
||||
},
|
||||
})
|
||||
@@ -1238,6 +1244,15 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
if matchingSrc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if input.Source.ID != "" {
|
||||
matchingSrc.ID = input.Source.ID
|
||||
}
|
||||
|
||||
if input.Source.Type != "" {
|
||||
matchingSrc.Type = input.Source.Type
|
||||
}
|
||||
|
||||
// Ensure we use the latest secret from the input if it was just learned/updated
|
||||
if input.Source.Credential.Value != "" {
|
||||
matchingSrc.Secret = input.Source.Credential.Value
|
||||
|
||||
@@ -244,7 +244,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
|
||||
}
|
||||
|
||||
// Global Sources
|
||||
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user">`) {
|
||||
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user" secretType="token_version_3">`) {
|
||||
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -35,6 +37,7 @@ type Account struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BoseSecret string `json:"bose_secret,omitempty"`
|
||||
}
|
||||
|
||||
// Service manages Spotify OAuth flow and token lifecycle.
|
||||
@@ -121,6 +124,9 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
displayName, _ := profile["display_name"].(string)
|
||||
email, _ := profile["email"].(string)
|
||||
|
||||
// Generate a Bose surrogate secret (represented as a 32-char hex string)
|
||||
boseSecret := s.generateBoseSecret()
|
||||
|
||||
account := &Account{
|
||||
UserID: userID,
|
||||
DisplayName: displayName,
|
||||
@@ -128,6 +134,7 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(expiresIn),
|
||||
BoseSecret: boseSecret,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -320,6 +327,7 @@ func (s *Service) GetAccounts() []Account {
|
||||
DisplayName: a.DisplayName,
|
||||
Email: a.Email,
|
||||
ExpiresAt: a.ExpiresAt,
|
||||
BoseSecret: a.BoseSecret,
|
||||
// AccessToken and RefreshToken deliberately omitted
|
||||
})
|
||||
}
|
||||
@@ -327,6 +335,30 @@ func (s *Service) GetAccounts() []Account {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountBySecret retrieves a Spotify account by its Bose surrogate secret.
|
||||
func (s *Service) GetAccountBySecret(secret string) (*Account, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, a := range s.accounts {
|
||||
if a.BoseSecret == secret {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) generateBoseSecret() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to timestamp-based if RNG fails
|
||||
return fmt.Sprintf("bs-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ResolveEntity resolves a Spotify URI to a name and image URL.
|
||||
func (s *Service) ResolveEntity(uri string) (name, imageURL string, err error) {
|
||||
entityType, entityID, err := parseSpotifyURI(uri)
|
||||
|
||||
@@ -17,23 +17,23 @@ User-Agent: Bose_Lisa/27.0.6
|
||||
client.assert(presets !== null, "Response body does not contain <presets>");
|
||||
|
||||
const presetList = doc.getElementsByTagName("preset");
|
||||
// Based on the flow: set_preset_6, get_presets, delete_preset_6, set_preset_5
|
||||
// Based on the flow: spotify_full_flow, set_preset_6, get_presets, delete_preset_6, set_preset_5
|
||||
// The service is fresh, so only what we set is there.
|
||||
client.assert(presetList.length === 1, "Response body should contain exactly one <preset>, but found " + presetList.length);
|
||||
client.assert(presetList.length >= 2, "Response body should contain at least one <preset>, but found " + presetList.length);
|
||||
|
||||
const firstPreset = presetList[0];
|
||||
client.assert(firstPreset.getAttribute("buttonNumber") === "6", "First preset should have buttonNumber=\"6\"");
|
||||
const secondPreset = presetList[1];
|
||||
client.assert(secondPreset.getAttribute("buttonNumber") === "6", "Second preset should have buttonNumber=\"6\"");
|
||||
|
||||
// Check <name>
|
||||
const name = firstPreset.getElementsByTagName("name")[0];
|
||||
const name = secondPreset.getElementsByTagName("name")[0];
|
||||
client.assert(name.textContent === "SMOOTH JAZZ", "Preset name should be 'SMOOTH JAZZ'");
|
||||
|
||||
// Check <location>
|
||||
const location = firstPreset.getElementsByTagName("location")[0];
|
||||
const location = secondPreset.getElementsByTagName("location")[0];
|
||||
client.assert(location.textContent === "/v1/playback/station/s166521", "Preset location mismatch");
|
||||
|
||||
// Check <source> and its attributes/children
|
||||
const source = firstPreset.getElementsByTagName("source")[0];
|
||||
const source = secondPreset.getElementsByTagName("source")[0];
|
||||
client.assert(source !== null, "Preset should have a <source>");
|
||||
// The source ID is set in HandleMargeUpdatePreset based on matching source, or in UpdatePreset.
|
||||
// For TUNEIN it might be 10004 or similar in our mocks.
|
||||
@@ -44,9 +44,9 @@ User-Agent: Bose_Lisa/27.0.6
|
||||
client.assert(sourceproviderid.textContent === "25", "sourceproviderid should be '25'");
|
||||
|
||||
// Check <username> inside <preset> (not the one in <source> if present)
|
||||
const presetUsernames = firstPreset.getElementsByTagName("username");
|
||||
const presetUsernames = secondPreset.getElementsByTagName("username");
|
||||
// In the reference, there's one in <source> (empty) and one in <preset> (SMOOTH JAZZ)
|
||||
// Usually, getElementsByTagName on firstPreset returns all descendants.
|
||||
// Usually, getElementsByTagName on secondPreset returns all descendants.
|
||||
// Let's be careful about the index or use a more specific selector if possible,
|
||||
// but here we can check the values.
|
||||
let foundPresetUsername = false;
|
||||
@@ -59,7 +59,7 @@ User-Agent: Bose_Lisa/27.0.6
|
||||
client.assert(foundPresetUsername, "Preset should have <username>SMOOTH JAZZ</username>");
|
||||
|
||||
// Ensure <containerArt> is non-empty
|
||||
const containerArt = firstPreset.getElementsByTagName("containerArt")[0];
|
||||
const containerArt = secondPreset.getElementsByTagName("containerArt")[0];
|
||||
client.assert(containerArt.textContent.startsWith("https://"), "containerArt should be a valid URL");
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
### Spotify Full Flow: Registration, Preset, and Token Refresh
|
||||
|
||||
# 1. Initialize Spotify Flow
|
||||
# @name Init Spotify Flow
|
||||
POST {{host}}/mgmt/spotify/init
|
||||
Authorization: Basic admin change_me!
|
||||
|
||||
###
|
||||
|
||||
# 2. Spotify Callback
|
||||
# Simulates the user being redirected back from Spotify with a code
|
||||
# This should also trigger the Bridge (Marge registration + Speaker notification)
|
||||
# @name Spotify Callback
|
||||
GET {{host}}/mgmt/spotify/callback?code=mock-auth-code&account={{accountId}}
|
||||
|
||||
###
|
||||
|
||||
# 3. Verify Spotify Account and extract Bose Secret
|
||||
# @name Verify Accounts
|
||||
GET {{host}}/mgmt/spotify/accounts
|
||||
Authorization: Basic admin change_me!
|
||||
|
||||
> {%
|
||||
client.test("Account exists and has Bose Secret", function() {
|
||||
client.assert(response.body.accounts.length > 0, "No accounts found");
|
||||
var found = false;
|
||||
for (var i = 0; i < response.body.accounts.length; i++) {
|
||||
if (response.body.accounts[i].user_id === "spotify-user-id") {
|
||||
found = true;
|
||||
client.assert(response.body.accounts[i].bose_secret !== undefined, "Bose Secret missing");
|
||||
client.global.set("bose_secret", response.body.accounts[i].bose_secret);
|
||||
break;
|
||||
}
|
||||
}
|
||||
client.assert(found, "Account 'spotify-user-id' not found");
|
||||
});
|
||||
%}
|
||||
|
||||
###
|
||||
|
||||
# 4. Verify Marge Source Registration and extract Spotify Source ID
|
||||
# @name Get Marge Sources
|
||||
GET {{host}}/accounts/{{accountId}}/sources
|
||||
|
||||
> {%
|
||||
client.test("Spotify source registered in Marge", function() {
|
||||
const doc = response.body;
|
||||
|
||||
const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
|
||||
|
||||
function findSpotifySource(parent) {
|
||||
const children = parent.childNodes;
|
||||
if (!children) return null;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
const node = children[i];
|
||||
if (node.nodeName && (node.nodeName.toLowerCase() === "source")) {
|
||||
// Check sourceKey
|
||||
const sourceproviderids = node.getElementsByTagName("sourceproviderid");
|
||||
for (var j = 0; j < (sourceproviderids ? sourceproviderids.length : 0); j++) {
|
||||
const sourceproviderid = sourceproviderids[j].textContent;
|
||||
if (sourceproviderid == spotifySourceProviderID) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recurse
|
||||
const found = findSpotifySource(node);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const spotifySource = findSpotifySource(doc);
|
||||
client.assert(spotifySource !== null, "Spotify source not found in Marge for 'spotify-user-id'");
|
||||
|
||||
const id = spotifySource.getAttribute("id");
|
||||
client.assert(id !== undefined && id !== "", "Spotify source ID missing");
|
||||
client.global.set("spotify_source_id", id);
|
||||
|
||||
const secretType = spotifySource.getAttribute("secretType");
|
||||
client.assert(secretType === "token_version_3", "Wrong secret type in Marge: " + secretType);
|
||||
});
|
||||
%}
|
||||
|
||||
###
|
||||
|
||||
# 5. Add a Spotify Preset
|
||||
# @name Update Preset
|
||||
POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1
|
||||
Content-Type: application/xml
|
||||
|
||||
<preset id="1">
|
||||
<name>Test Track</name>
|
||||
<sourceid>{{spotify_source_id}}</sourceid>
|
||||
<contentItemType>track</contentItemType>
|
||||
<location>spotify:track:123</location>
|
||||
</preset>
|
||||
|
||||
> {%
|
||||
client.test("Preset added successfully", function() {
|
||||
client.assert(response.status === 200, "Failed to update preset");
|
||||
});
|
||||
%}
|
||||
|
||||
###
|
||||
|
||||
# 6. Verify Preset in Marge
|
||||
# @name Get Presets
|
||||
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets
|
||||
|
||||
> {%
|
||||
client.test("Verify preset content", function() {
|
||||
const doc = response.body;
|
||||
const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
|
||||
|
||||
function findSpotifyPreset(parent) {
|
||||
const children = parent.childNodes;
|
||||
if (!children) return null;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
const node = children[i];
|
||||
// Handle both uppercase and lowercase tag names just in case
|
||||
const nodeName = node.nodeName ? node.nodeName.toLowerCase() : "";
|
||||
if (nodeName === "preset") {
|
||||
// Check source provider inside the preset
|
||||
const providers = node.getElementsByTagName("sourceproviderid");
|
||||
for (var j = 0; j < (providers ? providers.length : 0); j++) {
|
||||
if (providers[j].textContent === spotifySourceProviderID) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recurse
|
||||
const found = findSpotifyPreset(node);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const spotifyPreset = findSpotifyPreset(doc);
|
||||
client.assert(spotifyPreset !== null, "Spotify preset not found in Marge for provider '" + spotifySourceProviderID + "'");
|
||||
|
||||
// Verify preset elements (flat structure)
|
||||
function getTagContent(parent, tagName) {
|
||||
const elements = parent.getElementsByTagName(tagName);
|
||||
if (elements && elements.length > 0) return elements[0].textContent;
|
||||
// Try case-insensitive fallback
|
||||
const all = parent.getElementsByTagName("*");
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].nodeName.toLowerCase() === tagName.toLowerCase()) return all[i].textContent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
client.assert(getTagContent(spotifyPreset, "contentItemType") === "track", "Wrong contentItemType");
|
||||
client.assert(getTagContent(spotifyPreset, "location") === "spotify:track:123", "Wrong location");
|
||||
client.assert(getTagContent(spotifyPreset, "name") === "Test Track", "Wrong name");
|
||||
|
||||
// Verify nested source info
|
||||
const sources = spotifyPreset.getElementsByTagName("source");
|
||||
var source = (sources && sources.length > 0) ? sources[0] : null;
|
||||
if (!source) {
|
||||
// Fallback search for 'Source'
|
||||
const all = spotifyPreset.getElementsByTagName("*");
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].nodeName.toLowerCase() === "source") {
|
||||
source = all[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
client.assert(source !== null, "Missing nested source");
|
||||
|
||||
const credentials = source.getElementsByTagName("credential");
|
||||
var credential = (credentials && credentials.length > 0) ? credentials[0] : null;
|
||||
if (!credential) {
|
||||
const all = source.getElementsByTagName("*");
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].nodeName.toLowerCase() === "credential") {
|
||||
credential = all[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
client.assert(credential !== null, "Missing source credential");
|
||||
client.assert(credential.getAttribute("type") === "token_version_3", "Wrong credential type in preset");
|
||||
client.assert(credential.textContent !== "", "Credential value (secret) missing");
|
||||
});
|
||||
%}
|
||||
|
||||
###
|
||||
|
||||
# 7. Token Refresh via Surrogate (Bose Secret)
|
||||
# Simulates the speaker requesting a fresh access token
|
||||
# @name Token Refresh
|
||||
POST {{host}}/oauth/device/{{deviceId}}/music/musicprovider/15/token/cs3
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "{{bose_secret}}"
|
||||
}
|
||||
|
||||
> {%
|
||||
client.test("Token refreshed successfully", function() {
|
||||
client.assert(response.status === 200, "Refresh failed");
|
||||
client.assert(response.body.access_token !== undefined, "No access token in response");
|
||||
client.assert(response.body.token_type === "Bearer", "Wrong token type");
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user