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>
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package amazon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuildAuthorizeURL(t *testing.T) {
|
|
svc := NewAmazonService("test-client-id", "test-secret", "ueberboese-login://amazon", t.TempDir())
|
|
|
|
state := "test-state"
|
|
gotURL := svc.BuildAuthorizeURL(state)
|
|
|
|
if !strings.Contains(gotURL, "client_id=test-client-id") {
|
|
t.Errorf("URL should contain client_id, got: %s", gotURL)
|
|
}
|
|
if !strings.Contains(gotURL, "redirect_uri=") {
|
|
t.Errorf("URL should contain redirect_uri, got: %s", gotURL)
|
|
}
|
|
if !strings.Contains(gotURL, "scope=") {
|
|
t.Errorf("URL should contain scope, got: %s", gotURL)
|
|
}
|
|
if !strings.Contains(gotURL, "response_type=code") {
|
|
t.Errorf("URL should contain response_type=code, got: %s", gotURL)
|
|
}
|
|
if !strings.Contains(gotURL, "state=test-state") {
|
|
t.Errorf("URL should contain state=test-state, got: %s", gotURL)
|
|
}
|
|
if !strings.HasPrefix(gotURL, AmazonAuthorizeURL) {
|
|
t.Errorf("URL should start with %s, got: %s", AmazonAuthorizeURL, gotURL)
|
|
}
|
|
}
|
|
|
|
func TestGetAccountsStripsTokens(t *testing.T) {
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
|
|
|
svc.mu.Lock()
|
|
svc.accounts["amzn1.account.EXAMPLE"] = &Account{
|
|
UserID: "amzn1.account.EXAMPLE",
|
|
DisplayName: "Test User",
|
|
Email: "test@example.com",
|
|
AccessToken: "secret-access-token",
|
|
RefreshToken: "secret-refresh-token",
|
|
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
accounts := svc.GetAccounts()
|
|
|
|
if len(accounts) != 1 {
|
|
t.Fatalf("expected 1 account, got %d", len(accounts))
|
|
}
|
|
|
|
if accounts[0].AccessToken != "" {
|
|
t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken)
|
|
}
|
|
if accounts[0].RefreshToken != "" {
|
|
t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken)
|
|
}
|
|
if accounts[0].UserID != "amzn1.account.EXAMPLE" {
|
|
t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID)
|
|
}
|
|
if accounts[0].DisplayName != "Test User" {
|
|
t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCodeAndStore(t *testing.T) {
|
|
// Mock token endpoint — Amazon uses POST body credentials, not Basic Auth.
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
switch r.Form.Get("grant_type") {
|
|
case "authorization_code":
|
|
if r.Form.Get("code") != "test-auth-code" {
|
|
t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code"))
|
|
}
|
|
|
|
// Amazon uses POST body credentials, not HTTP Basic Auth.
|
|
if r.Form.Get("client_id") != "cid" {
|
|
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
|
|
}
|
|
if r.Form.Get("client_secret") != "csecret" {
|
|
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
|
|
}
|
|
_, _, hasBasicAuth := r.BasicAuth()
|
|
if hasBasicAuth {
|
|
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"access_token": "new-at",
|
|
"refresh_token": "new-rt",
|
|
"expires_in": 3600,
|
|
})
|
|
default:
|
|
t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type"))
|
|
http.Error(w, "bad request", 400)
|
|
}
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Mock profile endpoint — LWA returns "user_id" and "name" (not "id" / "display_name").
|
|
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer new-at" {
|
|
t.Errorf("expected Bearer new-at, got %s", auth)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"user_id": "amzn1.account.TESTUSER123",
|
|
"name": "Amazon User",
|
|
"email": "user@amazon.com",
|
|
})
|
|
}))
|
|
defer profileServer.Close()
|
|
|
|
dir := t.TempDir()
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
|
svc.SetEndpoints(tokenServer.URL, profileServer.URL)
|
|
|
|
err := svc.ExchangeCodeAndStore("test-auth-code")
|
|
if err != nil {
|
|
t.Fatalf("ExchangeCodeAndStore failed: %v", err)
|
|
}
|
|
|
|
svc.mu.RLock()
|
|
account, ok := svc.accounts["amzn1.account.TESTUSER123"]
|
|
svc.mu.RUnlock()
|
|
|
|
if !ok {
|
|
t.Fatal("account not found after exchange")
|
|
}
|
|
if account.DisplayName != "Amazon User" {
|
|
t.Errorf("expected Amazon User, got %s", account.DisplayName)
|
|
}
|
|
if account.Email != "user@amazon.com" {
|
|
t.Errorf("expected user@amazon.com, got %s", account.Email)
|
|
}
|
|
if account.AccessToken != "new-at" {
|
|
t.Errorf("expected new-at, got %s", account.AccessToken)
|
|
}
|
|
if account.RefreshToken != "new-rt" {
|
|
t.Errorf("expected new-rt, got %s", account.RefreshToken)
|
|
}
|
|
|
|
// Verify saved to disk under amazon/ (not spotify/).
|
|
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
|
|
data, err := os.ReadFile(accountsFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read accounts file: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "amzn1.account.TESTUSER123") {
|
|
t.Error("accounts file should contain the user ID")
|
|
}
|
|
}
|
|
|
|
func TestRefreshAccessToken(t *testing.T) {
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.Form.Get("grant_type") != "refresh_token" {
|
|
t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type"))
|
|
}
|
|
if r.Form.Get("refresh_token") != "my-refresh-token" {
|
|
t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token"))
|
|
}
|
|
|
|
// Amazon uses POST body credentials.
|
|
if r.Form.Get("client_id") != "cid" {
|
|
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
|
|
}
|
|
if r.Form.Get("client_secret") != "csecret" {
|
|
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
|
|
}
|
|
_, _, hasBasicAuth := r.BasicAuth()
|
|
if hasBasicAuth {
|
|
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"access_token": "new-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "new-refresh-token",
|
|
})
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
|
svc.tokenURL = tokenServer.URL
|
|
|
|
account := &Account{
|
|
UserID: "amzn1.account.USER",
|
|
AccessToken: "old-expired-token",
|
|
RefreshToken: "my-refresh-token",
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
|
|
}
|
|
|
|
svc.mu.Lock()
|
|
svc.accounts[account.UserID] = account
|
|
svc.mu.Unlock()
|
|
|
|
if err := svc.RefreshAccessToken(account); err != nil {
|
|
t.Fatalf("RefreshAccessToken: %v", err)
|
|
}
|
|
|
|
if account.AccessToken != "new-access-token" {
|
|
t.Errorf("expected new-access-token, got %s", account.AccessToken)
|
|
}
|
|
if account.RefreshToken != "new-refresh-token" {
|
|
t.Errorf("expected new-refresh-token, got %s", account.RefreshToken)
|
|
}
|
|
}
|
|
|
|
func TestGetFreshTokenRefreshesExpired(t *testing.T) {
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"access_token": "new-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "new-refresh-token",
|
|
})
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
|
svc.tokenURL = tokenServer.URL
|
|
|
|
svc.mu.Lock()
|
|
svc.accounts["amzn1.account.USER"] = &Account{
|
|
UserID: "amzn1.account.USER",
|
|
AccessToken: "old-expired-token",
|
|
RefreshToken: "my-refresh-token",
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
accessToken, username, err := svc.GetFreshToken()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if accessToken != "new-access-token" {
|
|
t.Errorf("expected new-access-token, got %s", accessToken)
|
|
}
|
|
if username != "amzn1.account.USER" {
|
|
t.Errorf("expected amzn1.account.USER, got %s", username)
|
|
}
|
|
|
|
svc.mu.RLock()
|
|
account := svc.accounts["amzn1.account.USER"]
|
|
svc.mu.RUnlock()
|
|
|
|
if account.RefreshToken != "new-refresh-token" {
|
|
t.Errorf("refresh token should be updated, got %s", account.RefreshToken)
|
|
}
|
|
}
|
|
|
|
func TestGetFreshTokenNoAccounts(t *testing.T) {
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
|
|
|
_, _, err := svc.GetFreshToken()
|
|
if err == nil {
|
|
t.Error("expected error when no accounts exist")
|
|
}
|
|
}
|
|
|
|
func TestGetFreshTokenNotExpired(t *testing.T) {
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
|
|
|
svc.mu.Lock()
|
|
svc.accounts["amzn1.account.USER"] = &Account{
|
|
UserID: "amzn1.account.USER",
|
|
AccessToken: "valid-token",
|
|
RefreshToken: "rt",
|
|
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
token, username, err := svc.GetFreshToken()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if token != "valid-token" {
|
|
t.Errorf("expected valid-token, got %s", token)
|
|
}
|
|
if username != "amzn1.account.USER" {
|
|
t.Errorf("expected amzn1.account.USER, got %s", username)
|
|
}
|
|
}
|
|
|
|
func TestSaveAndLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
|
svc.mu.Lock()
|
|
svc.accounts["amzn1.account.USER1"] = &Account{
|
|
UserID: "amzn1.account.USER1",
|
|
DisplayName: "Test User",
|
|
Email: "test@example.com",
|
|
AccessToken: "at",
|
|
RefreshToken: "rt",
|
|
ExpiresAt: 1234567890,
|
|
}
|
|
svc.accounts["amzn1.account.USER2"] = &Account{
|
|
UserID: "amzn1.account.USER2",
|
|
DisplayName: "User Two",
|
|
Email: "two@example.com",
|
|
AccessToken: "at2",
|
|
RefreshToken: "rt2",
|
|
ExpiresAt: 9876543210,
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
if err := svc.save(); err != nil {
|
|
t.Fatalf("save failed: %v", err)
|
|
}
|
|
|
|
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
|
|
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
|
|
t.Fatal("amazon/accounts.json was not created")
|
|
}
|
|
|
|
svc2 := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
|
if err := svc2.Load(); err != nil {
|
|
t.Fatalf("load failed: %v", err)
|
|
}
|
|
|
|
svc2.mu.RLock()
|
|
defer svc2.mu.RUnlock()
|
|
|
|
if len(svc2.accounts) != 2 {
|
|
t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts))
|
|
}
|
|
|
|
u1, ok := svc2.accounts["amzn1.account.USER1"]
|
|
if !ok {
|
|
t.Fatal("USER1 not found after load")
|
|
}
|
|
if u1.DisplayName != "Test User" {
|
|
t.Errorf("expected Test User, got %s", u1.DisplayName)
|
|
}
|
|
if u1.AccessToken != "at" {
|
|
t.Errorf("expected at, got %s", u1.AccessToken)
|
|
}
|
|
if u1.ExpiresAt != 1234567890 {
|
|
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
|
|
}
|
|
}
|