mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-07 00:24:17 +08:00
Stop refreshing Home-owned OAuth credentials after upstream 401s. Preserve marked upstream response bodies for direct responses, usage records, request logs, and websocket handshake failures.
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package live
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
|
|
)
|
|
|
|
func TestHandleDirectWebsocketRejectsClientSecretModelMismatch(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handler := NewHandler(auth.NewManager(nil, nil, nil), nil)
|
|
router := gin.New()
|
|
router.GET("/v1/realtime", func(c *gin.Context) {
|
|
c.Set(ClientSecretSessionContextKey, json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex"}`))
|
|
c.Set(ClientSecretPrincipalContextKey, "sess_123")
|
|
c.Next()
|
|
}, handler.HandleRealtimeWebsocket)
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=another-live-model", nil)
|
|
request.Header.Set("Connection", "Upgrade")
|
|
request.Header.Set("Upgrade", "websocket")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusForbidden, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleDirectWebsocketForwardsUnauthorizedHomeHandshakeWithoutRefresh(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
for _, tc := range []struct {
|
|
name string
|
|
upstreamBody string
|
|
truncatedResponse bool
|
|
}{
|
|
{name: "response body", upstreamBody: `{"error":{"message":"access token expired"}}`},
|
|
{name: "response body with read error", upstreamBody: `{"error":{"message":"access token expired"}}`, truncatedResponse: true},
|
|
{name: "empty response body"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
upstreamAuthorization := make(chan string, 1)
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
upstreamAuthorization <- request.Header.Get("Authorization")
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
if tc.truncatedResponse {
|
|
writer.Header().Set("Content-Length", strconv.Itoa(len(tc.upstreamBody)+1))
|
|
}
|
|
writer.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = writer.Write([]byte(tc.upstreamBody))
|
|
}))
|
|
defer upstreamServer.Close()
|
|
|
|
runtimeConfig := &config.Config{
|
|
Home: config.HomeConfig{Enabled: true},
|
|
SDKConfig: config.SDKConfig{RequestLog: true},
|
|
}
|
|
manager := auth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(runtimeConfig)
|
|
manager.PublishHomeDispatch(&homeDispatcher{}, executionregistry.New(), 1)
|
|
executor := &captureExecutor{}
|
|
manager.RegisterExecutor(executor)
|
|
handler := NewHandler(manager, runtimeConfig)
|
|
handler.sidebandAPIBaseURL = "ws" + strings.TrimPrefix(upstreamServer.URL, "http") + "/v1"
|
|
router := gin.New()
|
|
timelineCapture := make(chan []byte, 1)
|
|
router.Use(func(c *gin.Context) {
|
|
c.Next()
|
|
if raw, exists := c.Get("API_WEBSOCKET_TIMELINE"); exists {
|
|
timeline, _ := raw.([]byte)
|
|
timelineCapture <- append([]byte(nil), timeline...)
|
|
}
|
|
})
|
|
router.GET("/v1/realtime", handler.HandleRealtimeWebsocket)
|
|
downstreamServer := httptest.NewServer(router)
|
|
defer downstreamServer.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(downstreamServer.URL, "http") + "/v1/realtime?model=gpt-realtime"
|
|
connection, response, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if connection != nil {
|
|
_ = connection.Close()
|
|
}
|
|
if errDial == nil || response == nil {
|
|
t.Fatalf("dial downstream websocket = response %#v err %v, want rejected handshake", response, errDial)
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
responseBody, errRead := io.ReadAll(response.Body)
|
|
if errRead != nil {
|
|
t.Fatalf("read downstream rejection: %v", errRead)
|
|
}
|
|
if response.StatusCode != http.StatusUnauthorized || string(responseBody) != tc.upstreamBody {
|
|
t.Fatalf("downstream rejection = status %d body %q, want original upstream 401 body %q", response.StatusCode, responseBody, tc.upstreamBody)
|
|
}
|
|
if executor.refreshCalls.Load() != 0 {
|
|
t.Fatalf("refresh calls = %d, want 0", executor.refreshCalls.Load())
|
|
}
|
|
if got := <-upstreamAuthorization; got != "Bearer home-live-token" {
|
|
t.Fatalf("upstream Authorization = %q, want original Home token", got)
|
|
}
|
|
if tc.truncatedResponse {
|
|
select {
|
|
case timeline := <-timelineCapture:
|
|
if !strings.Contains(string(timeline), tc.upstreamBody) {
|
|
t.Fatalf("API_WEBSOCKET_TIMELINE = %q, want original upstream error", timeline)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for websocket request-log timeline")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleDirectWebsocketAppliesClientSecretSession(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
upstreamUpdate := make(chan []byte, 1)
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
connection, errUpgrade := upgrader.Upgrade(writer, request, nil)
|
|
if errUpgrade != nil {
|
|
return
|
|
}
|
|
defer func() { _ = connection.Close() }()
|
|
_, payload, errRead := connection.ReadMessage()
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
upstreamUpdate <- append([]byte(nil), payload...)
|
|
_ = connection.WriteMessage(websocket.TextMessage, []byte(`{"type":"session.created"}`))
|
|
}))
|
|
defer upstreamServer.Close()
|
|
|
|
manager := auth.NewManager(nil, nil, nil)
|
|
manager.RegisterExecutor(&captureExecutor{})
|
|
registerCredential(t, manager, &auth.Auth{
|
|
ID: "codex-oauth",
|
|
Provider: "codex",
|
|
Status: auth.StatusActive,
|
|
Metadata: map[string]any{"access_token": "oauth-token"},
|
|
})
|
|
handler := NewHandler(manager, nil)
|
|
handler.sidebandAPIBaseURL = "ws" + strings.TrimPrefix(upstreamServer.URL, "http") + "/v1"
|
|
router := gin.New()
|
|
router.GET("/v1/realtime", func(c *gin.Context) {
|
|
c.Set(ClientSecretSessionContextKey, json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex","instructions":"help"}`))
|
|
c.Set(ClientSecretPrincipalContextKey, "sess_123")
|
|
c.Next()
|
|
}, handler.HandleRealtimeWebsocket)
|
|
downstreamServer := httptest.NewServer(router)
|
|
defer downstreamServer.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(downstreamServer.URL, "http") + "/v1/realtime?model=gpt-realtime"
|
|
connection, _, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if errDial != nil {
|
|
t.Fatalf("dial downstream websocket: %v", errDial)
|
|
}
|
|
defer func() { _ = connection.Close() }()
|
|
_, _, _ = connection.ReadMessage()
|
|
|
|
select {
|
|
case update := <-upstreamUpdate:
|
|
var event struct {
|
|
Type string `json:"type"`
|
|
Session struct {
|
|
Model string `json:"model"`
|
|
Instructions string `json:"instructions"`
|
|
} `json:"session"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(update, &event); errUnmarshal != nil {
|
|
t.Fatalf("unmarshal session update: %v", errUnmarshal)
|
|
}
|
|
if event.Type != "session.update" || event.Session.Model != "" || event.Session.Instructions != "help" {
|
|
t.Fatalf("session update = %+v", event)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("session update not captured")
|
|
}
|
|
}
|
|
|
|
func TestHandleDirectWebsocketRelaysStandardRealtimeFrames(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
upstreamRequest := make(chan *http.Request, 1)
|
|
upstreamMessage := make(chan string, 1)
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
connection, errUpgrade := upgrader.Upgrade(writer, request, nil)
|
|
if errUpgrade != nil {
|
|
return
|
|
}
|
|
defer func() { _ = connection.Close() }()
|
|
upstreamRequest <- request.Clone(request.Context())
|
|
if errWrite := connection.WriteMessage(websocket.TextMessage, []byte(`{"type":"session.created"}`)); errWrite != nil {
|
|
return
|
|
}
|
|
messageType, payload, errRead := connection.ReadMessage()
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
upstreamMessage <- string(payload)
|
|
_ = connection.WriteMessage(messageType, append([]byte("echo:"), payload...))
|
|
}))
|
|
defer upstreamServer.Close()
|
|
|
|
manager := auth.NewManager(nil, nil, nil)
|
|
manager.RegisterExecutor(&captureExecutor{})
|
|
registerCredential(t, manager, &auth.Auth{
|
|
ID: "codex-oauth",
|
|
Provider: "codex",
|
|
Status: auth.StatusActive,
|
|
Metadata: map[string]any{
|
|
"access_token": "oauth-token",
|
|
"account_id": "account-123",
|
|
},
|
|
})
|
|
handler := NewHandler(manager, nil)
|
|
handler.sidebandAPIBaseURL = "ws" + strings.TrimPrefix(upstreamServer.URL, "http") + "/v1"
|
|
|
|
router := gin.New()
|
|
router.GET("/v1/realtime", handler.HandleRealtimeWebsocket)
|
|
downstreamServer := httptest.NewServer(router)
|
|
defer downstreamServer.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(downstreamServer.URL, "http") + "/v1/realtime?model=gpt-realtime"
|
|
downstreamHeaders := make(http.Header)
|
|
downstreamHeaders.Set("OpenAI-Alpha", "quicksilver=v2")
|
|
connection, _, errDial := websocket.DefaultDialer.Dial(wsURL, downstreamHeaders)
|
|
if errDial != nil {
|
|
t.Fatalf("dial downstream websocket: %v", errDial)
|
|
}
|
|
defer func() { _ = connection.Close() }()
|
|
|
|
_, created, errRead := connection.ReadMessage()
|
|
if errRead != nil {
|
|
t.Fatalf("read session.created: %v", errRead)
|
|
}
|
|
if string(created) != `{"type":"session.created"}` {
|
|
t.Fatalf("created event = %s", created)
|
|
}
|
|
const event = `{"type":"response.create"}`
|
|
if errWrite := connection.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
|
|
t.Fatalf("write downstream event: %v", errWrite)
|
|
}
|
|
_, echoed, errRead := connection.ReadMessage()
|
|
if errRead != nil {
|
|
t.Fatalf("read echoed event: %v", errRead)
|
|
}
|
|
if string(echoed) != "echo:"+event {
|
|
t.Fatalf("echoed event = %s", echoed)
|
|
}
|
|
|
|
select {
|
|
case request := <-upstreamRequest:
|
|
if request.Header.Get("Authorization") != "Bearer oauth-token" {
|
|
t.Fatalf("Authorization = %q", request.Header.Get("Authorization"))
|
|
}
|
|
if request.Header.Get("Chatgpt-Account-Id") != "account-123" {
|
|
t.Fatalf("Chatgpt-Account-Id = %q", request.Header.Get("Chatgpt-Account-Id"))
|
|
}
|
|
if request.Header.Get("OpenAI-Alpha") != "" {
|
|
t.Fatalf("OpenAI-Alpha must not be forwarded, got %q", request.Header.Get("OpenAI-Alpha"))
|
|
}
|
|
query, errParse := url.ParseQuery(request.URL.RawQuery)
|
|
if errParse != nil {
|
|
t.Fatalf("parse upstream query: %v", errParse)
|
|
}
|
|
if query.Get("model") != "gpt-realtime" || query.Has("intent") {
|
|
t.Fatalf("upstream query = %v", query)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upstream request not captured")
|
|
}
|
|
select {
|
|
case payload := <-upstreamMessage:
|
|
if payload != event {
|
|
t.Fatalf("upstream event = %s", payload)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upstream event not captured")
|
|
}
|
|
}
|