remove: proxy forwarding to Bose upstream

This commit is contained in:
Marcin Mennemann
2026-05-17 21:53:33 +02:00
committed by Tobias Gesellchen
parent 0f0a96c0ce
commit 2747d95a8f
9 changed files with 32 additions and 361 deletions
-3
View File
@@ -1046,7 +1046,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
@@ -1098,8 +1097,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
-10
View File
@@ -1,12 +1,10 @@
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
@@ -53,8 +51,6 @@ GET /mgmt/devices/{deviceId}/events handlers.(
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
@@ -95,13 +91,10 @@ GET /v1/blacklist/{deviceId} setupRoute
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
@@ -126,7 +119,6 @@ POST /mgmt/spotify/confirm handlers.(
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
@@ -169,9 +161,7 @@ POST /v1/scmudc/{deviceId} handlers.(
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
-6
View File
@@ -2102,12 +2102,6 @@ type Settings struct {
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
// AllowInsecureUpstreamTLS, when true, disables TLS certificate verification
// for the upstream Bose-cloud proxy traffic. The default (false)
// keeps verification on; opt in only when the upstream certificate chain is
// broken (post end-of-service) and a temporary unblock is required.
AllowInsecureUpstreamTLS bool `json:"allow_insecure_upstream_tls,omitempty"`
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
// X-Forwarded-For / True-Client-IP headers are honoured and replace
+18 -17
View File
@@ -35,7 +35,8 @@ func (s *Server) HandleBoseToken(w http.ResponseWriter, r *http.Request) {
}
}
s.HandleBoseProxy(w, r)
log.Printf("[OAuth] Unknown music provider: %s", sourceID)
http.Error(w, "Unknown music provider", http.StatusNotFound)
}
// HandleBoseLegacyToken handles the Bose-specific token refresh request (legacy or variant).
@@ -111,16 +112,16 @@ func (s *Server) HandleBoseAmazonToken(w http.ResponseWriter, r *http.Request) {
s.mu.RUnlock()
if svc == nil {
log.Printf("[Amazon] Amazon service not configured, falling back to upstream")
s.HandleBoseProxy(w, r)
log.Printf("[Amazon] Amazon service not configured, returning 503")
http.Error(w, "Amazon service not configured", http.StatusServiceUnavailable)
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
log.Printf("[Amazon] No Amazon accounts linked, falling back to upstream")
s.HandleBoseProxy(w, r)
log.Printf("[Amazon] No Amazon accounts linked, returning 503")
http.Error(w, "No linked Amazon accounts", http.StatusServiceUnavailable)
return
}
@@ -157,8 +158,8 @@ func (s *Server) HandleBoseAmazonToken(w http.ResponseWriter, r *http.Request) {
if account != nil {
if err := svc.RefreshAccessToken(account); err != nil {
log.Printf("[Amazon] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err)
s.HandleBoseProxy(w, r)
log.Printf("[Amazon] Failed to refresh token for %s: %v. Returning 502", account.UserID, err)
http.Error(w, "Token refresh failed", http.StatusBadGateway)
return
}
@@ -169,8 +170,8 @@ func (s *Server) HandleBoseAmazonToken(w http.ResponseWriter, r *http.Request) {
accessToken, userID, err = svc.GetFreshToken()
if err != nil {
log.Printf("[Amazon] Failed to get fresh token: %v. Falling back to upstream", err)
s.HandleBoseProxy(w, r)
log.Printf("[Amazon] Failed to get fresh token: %v. Returning 502", err)
http.Error(w, "Failed to get fresh token", http.StatusBadGateway)
return
}
@@ -206,16 +207,16 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
s.mu.RUnlock()
if svc == nil {
log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream")
s.HandleBoseProxy(w, r)
log.Printf("[Spotify Proxy] Spotify service not configured, returning 503")
http.Error(w, "Spotify service not configured", http.StatusServiceUnavailable)
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream")
s.HandleBoseProxy(w, r)
log.Printf("[Spotify Proxy] No Spotify accounts linked, returning 503")
http.Error(w, "No linked Spotify accounts", http.StatusServiceUnavailable)
return
}
@@ -256,8 +257,8 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
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)
log.Printf("[Spotify Proxy] Failed to refresh token for %s: %v. Returning 502", account.UserID, err)
http.Error(w, "Token refresh failed", http.StatusBadGateway)
return
}
@@ -269,8 +270,8 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
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)
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Returning 502", err)
http.Error(w, "Failed to get fresh token", http.StatusBadGateway)
return
}
+6 -22
View File
@@ -84,33 +84,17 @@ func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
// Since there's no Spotify service, it should fall back to HandleBoseProxy.
// HandleBoseProxy will try to contact streaming.bose.com.
// We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose).
// Without a configured Spotify service, the handler returns 503.
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
req.Host = "localhost" // use localhost to avoid real network call
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// If it fell back to proxy, it should NOT have X-Proxy-Origin: self
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self")
}
// HandleBoseProxy sets X-Proxy-Origin: upstream
if w.Header().Get("X-Proxy-Origin") != "upstream" {
// It might fail before setting the header if the target host is invalid,
// but our HandleBoseProxy sets it in ModifyResponse.
// If it fails to connect, it might return 502 without the header.
if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound {
t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin"))
}
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503 without Spotify service, got %d", w.Code)
}
}
@@ -260,14 +244,14 @@ func TestHandleBoseAmazonToken_FallbackToProxy(t *testing.T) {
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
// Without a configured Amazon service, the handler returns 503.
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/20/token/cs1", nil)
req.Host = "localhost"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Error("Expected fallback to proxy, but got X-Proxy-Origin: self")
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503 without Amazon service, got %d", w.Code)
}
}
+1 -137
View File
@@ -2,114 +2,11 @@ package handlers
import (
"bytes"
"crypto/tls"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
// HandleProxyRequest handles requests to the logging proxy.
func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
http.Error(w, "Loop detected", http.StatusNotFound)
return
}
targetURLStr := strings.TrimPrefix(r.URL.Path, "/proxy/")
if targetURLStr == "" {
http.Error(w, "Target URL is required", http.StatusBadRequest)
return
}
// Reconstruct original URL (it might have lost its double slashes in the path)
if !strings.HasPrefix(targetURLStr, "http://") && !strings.HasPrefix(targetURLStr, "https://") {
// Try to fix it if it looks like http:/...
if strings.HasPrefix(targetURLStr, "http:/") {
targetURLStr = "http://" + strings.TrimPrefix(targetURLStr, "http:/")
} else if strings.HasPrefix(targetURLStr, "https:/") {
targetURLStr = "https://" + strings.TrimPrefix(targetURLStr, "https:/")
}
}
target, err := url.Parse(targetURLStr)
if err != nil {
http.Error(w, "Invalid target URL: "+err.Error(), http.StatusBadRequest)
return
}
s.ServeProxy(target)(w, r)
}
// ServeProxy returns a handler that proxies to the given target.
func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
// Capture request body for recording, as it will be consumed by the proxy
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
// AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to
// false so the upstream certificate chain is verified normally. The
// opt-in exists for deployments stuck behind a broken Bose-cloud
// chain post end-of-service.
settings, _ := s.ds.GetSettings()
insecure := settings.AllowInsecureUpstreamTLS
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
pr.Out.Host = target.Host
// If target has a path, we should probably append or replace.
// For Bose upstream, it's usually just the domain.
if target.Path != "" && target.Path != "/" {
pr.Out.URL.Path = target.Path
}
pr.Out.Header.Set("X-Bose-Proxy-Hop", "1")
lp.LogRequest(pr.Out)
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
}
rp.ModifyResponse = func(res *http.Response) error {
res.Header.Set("X-Proxy-Origin", "upstream")
// Generic Header Preservation
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
// Restore captured request body for the recorder
if reqBody != nil {
res.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
return nil
}
rp.ServeHTTP(w, r)
}
}
// HandleNotFound handles requests that don't match any route.
// It always logs [UNHANDLED] so unimplemented endpoints are visible in plain output.
// When proxyLogBody is enabled it also logs the request body (truncated to 512 bytes).
@@ -131,38 +28,5 @@ func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
log.Printf("[UNHANDLED] %s %s", r.Method, r.URL.Path)
}
s.HandleBoseProxy(w, r)
}
// HandleBoseProxy proxies the request to the Bose upstream.
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
http.Error(w, "Loop detected", http.StatusNotFound)
return
}
host := r.Host
if host == "" {
host = "streaming.bose.com"
}
// Default to HTTPS for Bose services
scheme := "https"
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "::1") {
scheme = "http"
}
targetURL := scheme + "://" + host
target, err := url.Parse(targetURL)
if err != nil {
log.Printf("[PROXY_ERR] Failed to parse target URL %s: %v", targetURL, err)
http.Error(w, "Invalid upstream host", http.StatusBadGateway)
return
}
s.ServeProxy(target)(w, r)
http.NotFound(w, r)
}
+7 -112
View File
@@ -2,28 +2,17 @@ package handlers
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func TestHandleNotFound_UnhandledLogging(t *testing.T) {
// backend absorbs proxied requests so the test doesn't hit the real Bose upstream
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
backendHost := strings.TrimPrefix(backend.URL, "http://")
captureLog := func(fn func()) string {
var buf bytes.Buffer
log.SetOutput(&buf)
@@ -37,7 +26,6 @@ func TestHandleNotFound_UnhandledLogging(t *testing.T) {
server := NewServer(ds, nil, "http://localhost", false, false, false)
req := httptest.NewRequest("GET", "/some/unknown/path", nil)
req.Host = backendHost
logged := captureLog(func() {
server.HandleNotFound(httptest.NewRecorder(), req)
@@ -56,7 +44,6 @@ func TestHandleNotFound_UnhandledLogging(t *testing.T) {
server := NewServer(ds, nil, "http://localhost", false, true, false)
req := httptest.NewRequest("POST", "/marge/unknown", bytes.NewBufferString("<payload/>"))
req.Host = backendHost
logged := captureLog(func() {
server.HandleNotFound(httptest.NewRecorder(), req)
@@ -72,7 +59,6 @@ func TestHandleNotFound_UnhandledLogging(t *testing.T) {
server := NewServer(ds, nil, "http://localhost", false, false, false)
req := httptest.NewRequest("POST", "/marge/unknown", bytes.NewBufferString("<secret/>"))
req.Host = backendHost
logged := captureLog(func() {
server.HandleNotFound(httptest.NewRecorder(), req)
@@ -86,108 +72,17 @@ func TestHandleNotFound_UnhandledLogging(t *testing.T) {
}
})
t.Run("body is still forwarded to proxy after being read for logging", func(t *testing.T) {
var receivedBody string
forwardCheck := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
receivedBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer forwardCheck.Close()
t.Run("returns 404 for unmatched routes", func(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
server := NewServer(ds, nil, "http://localhost", false, true, false)
server := NewServer(ds, nil, "http://localhost", false, false, false)
req := httptest.NewRequest("POST", "/marge/unknown", bytes.NewBufferString("<forwarded/>"))
req.Host = strings.TrimPrefix(forwardCheck.URL, "http://")
req := httptest.NewRequest("GET", "/some/unknown/path", nil)
w := httptest.NewRecorder()
captureLog(func() {
server.HandleNotFound(httptest.NewRecorder(), req)
})
server.HandleNotFound(w, req)
if receivedBody != "<forwarded/>" {
t.Errorf("expected body forwarded to proxy, got: %q", receivedBody)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for unmatched route, got %d", w.Code)
}
})
}
func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "proxy-request-body-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Start a backend server to receive the proxied request
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the body to ensure it's consumed
_, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte("<response>ok</response>"))
}))
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
// Create a proxy request to the backend
requestBody := "<request>data</request>"
targetURL := backend.URL
proxyPath := "/proxy/" + targetURL
req := httptest.NewRequest("POST", proxyPath, bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
server.HandleProxyRequest(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify that the interaction was recorded and contains the request body
sessionID := recorder.SessionID
// The recorder uses sanitized segments for the directory.
// Since the target URL is http://127.0.0.1:PORT, the path is empty,
// so it should be in the "root" directory under the category.
// We'll search recursively to be sure
foundBody := false
err = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
content, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(content), requestBody) {
foundBody = true
}
}
return nil
})
if err != nil {
t.Fatalf("failed to walk interactions dir: %v", err)
}
if !foundBody {
t.Errorf("request body %q not found in any recorded interaction file", requestBody)
// List all files found for debugging
_ = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
content, _ := os.ReadFile(path)
t.Logf("Found file %s with content:\n%s", path, string(content))
}
return nil
})
}
}
@@ -1,53 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestHandleBoseProxy_LoopPrevention(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "proxy-loop-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false)
t.Run("first hop should be allowed", func(t *testing.T) {
req := httptest.NewRequest("GET", "/unknown-endpoint", nil)
req.Host = "localhost"
w := httptest.NewRecorder()
server.HandleBoseProxy(w, req)
if w.Code == http.StatusNotFound {
t.Errorf("Expected first hop to be allowed (even if it fails later), but got 404")
}
})
t.Run("second hop should be blocked", func(t *testing.T) {
req := httptest.NewRequest("GET", "/unknown-endpoint", nil)
req.Host = "localhost"
req.Header.Set("X-Bose-Proxy-Hop", "1")
w := httptest.NewRecorder()
server.HandleBoseProxy(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected second hop to be blocked with 404, but got %d", w.Code)
}
})
t.Run("HandleProxyRequest loop detection", func(t *testing.T) {
req := httptest.NewRequest("GET", "/proxy/http://example.com", nil)
req.Header.Set("X-Bose-Proxy-Hop", "1")
w := httptest.NewRecorder()
server.HandleProxyRequest(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected HandleProxyRequest loop to be blocked with 404, but got %d", w.Code)
}
})
}
-1
View File
@@ -47,7 +47,6 @@ type Server struct {
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
UpstreamProxy http.Handler
Version string
Commit string
Date string