mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-11 02:00:04 +08:00
- Initialize `util.SessionIDResolver` to resolve session IDs from request contexts, metadata, and headers. - Ensure canonical session metadata is injected into execution options across execution flows. - Propagate session context in executors to support `$CPA-SESSION-ID` expansion in custom headers. - Sync cleared or updated session identities back to execution contexts during conductor execution. Closes: #5690
272 lines
9.8 KiB
Go
272 lines
9.8 KiB
Go
package util
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestApplyCustomHeadersFromAttrs_StaticHeaders(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Custom-Static": "static-value",
|
|
"header:Host": "custom.host.com",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if got := req.Header.Get("X-Custom-Static"); got != "static-value" {
|
|
t.Errorf("X-Custom-Static = %q, want %q", got, "static-value")
|
|
}
|
|
if got := req.Host; got != "custom.host.com" {
|
|
t.Errorf("req.Host = %q, want %q", got, "custom.host.com")
|
|
}
|
|
}
|
|
|
|
func TestApplyCustomHeadersFromAttrs_MagicVariable(t *testing.T) {
|
|
t.Run("present in clientHeaders sets header", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Claude-Code-Session-Id": "$ABC",
|
|
"header:X-Target-Session": "$X-Claude-Code-Session-Id",
|
|
"header:Static-Header": "static-123",
|
|
}
|
|
clientHeaders := http.Header{
|
|
"Abc": []string{"session-abc-456"},
|
|
"X-Claude-Code-Session-Id": []string{"claude-code-uuid-789"},
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
|
|
|
if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "session-abc-456" {
|
|
t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "session-abc-456")
|
|
}
|
|
if got := req.Header.Get("X-Target-Session"); got != "claude-code-uuid-789" {
|
|
t.Errorf("X-Target-Session = %q, want %q", got, "claude-code-uuid-789")
|
|
}
|
|
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
|
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
|
}
|
|
})
|
|
|
|
t.Run("absent in clientHeaders does not set header", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Claude-Code-Session-Id": "$ABC",
|
|
"header:X-Other": "$NONEXISTENT",
|
|
"header:Static-Header": "static-123",
|
|
}
|
|
clientHeaders := http.Header{
|
|
"Other-Header": []string{"some-value"},
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
|
|
|
if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
|
|
t.Errorf("expected X-Claude-Code-Session-Id to be omitted when $ABC is absent in clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
|
|
}
|
|
if _, exists := req.Header["X-Other"]; exists {
|
|
t.Errorf("expected X-Other to be omitted when $NONEXISTENT is absent in clientHeaders, got %q", req.Header.Get("X-Other"))
|
|
}
|
|
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
|
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
|
}
|
|
})
|
|
|
|
t.Run("nil clientHeaders does not set variable headers", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Claude-Code-Session-Id": "$ABC",
|
|
"header:Static-Header": "static-123",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
|
|
t.Errorf("expected X-Claude-Code-Session-Id to be omitted with nil clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
|
|
}
|
|
if got := req.Header.Get("Static-Header"); got != "static-123" {
|
|
t.Errorf("Static-Header = %q, want %q", got, "static-123")
|
|
}
|
|
})
|
|
|
|
t.Run("fallback to gin context in request context", func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(w)
|
|
ginReq := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
ginReq.Header.Set("ABC", "from-gin-ctx-123")
|
|
ginCtx.Request = ginReq
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
req = req.WithContext(ginCtx)
|
|
|
|
attrs := map[string]string{
|
|
"header:X-Claude-Code-Session-Id": "$ABC",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "from-gin-ctx-123" {
|
|
t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "from-gin-ctx-123")
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id resolved from context", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
req = req.WithContext(WithSessionID(req.Context(), "claude:session-xyz-123"))
|
|
attrs := map[string]string{
|
|
"header:X-Session-ID": "$CPA-SESSION-ID",
|
|
"header:X-Auth-Token": "Bearer $CPA-SESSION-ID",
|
|
"header:X-Lower-Case": "$cpa-session-id",
|
|
"header:Static-Header": "static-val",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if got := req.Header.Get("X-Session-ID"); got != "claude:session-xyz-123" {
|
|
t.Errorf("X-Session-ID = %q, want %q", got, "claude:session-xyz-123")
|
|
}
|
|
if got := req.Header.Get("X-Auth-Token"); got != "Bearer claude:session-xyz-123" {
|
|
t.Errorf("X-Auth-Token = %q, want %q", got, "Bearer claude:session-xyz-123")
|
|
}
|
|
if got := req.Header.Get("X-Lower-Case"); got != "claude:session-xyz-123" {
|
|
t.Errorf("X-Lower-Case = %q, want %q", got, "claude:session-xyz-123")
|
|
}
|
|
if got := req.Header.Get("Static-Header"); got != "static-val" {
|
|
t.Errorf("Static-Header = %q, want %q", got, "static-val")
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id cleared via WithSessionID empty string", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
ctx := WithSessionID(req.Context(), "initial-session-123")
|
|
if got := SessionIDFromContext(ctx); got != "initial-session-123" {
|
|
t.Fatalf("SessionIDFromContext before clear = %q, want %q", got, "initial-session-123")
|
|
}
|
|
|
|
clearedCtx := WithSessionID(ctx, "")
|
|
if got := SessionIDFromContext(clearedCtx); got != "" {
|
|
t.Fatalf("SessionIDFromContext after clear = %q, want empty", got)
|
|
}
|
|
|
|
req = req.WithContext(clearedCtx)
|
|
attrs := map[string]string{
|
|
"header:X-Session-ID": "$CPA-SESSION-ID",
|
|
"header:X-Auth-Token": "Bearer $CPA-SESSION-ID",
|
|
"header:Static": "ok",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if _, exists := req.Header["X-Session-ID"]; exists {
|
|
t.Errorf("expected X-Session-ID to be omitted when cleared, got %q", req.Header.Get("X-Session-ID"))
|
|
}
|
|
if _, exists := req.Header["X-Auth-Token"]; exists {
|
|
t.Errorf("expected X-Auth-Token to be omitted when cleared, got %q", req.Header.Get("X-Auth-Token"))
|
|
}
|
|
if got := req.Header.Get("Static"); got != "ok" {
|
|
t.Errorf("Static = %q, want %q", got, "ok")
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id resolved from SessionIDResolver", func(t *testing.T) {
|
|
oldResolver := SessionIDResolver
|
|
defer func() { SessionIDResolver = oldResolver }()
|
|
|
|
SessionIDResolver = func(ctx context.Context, clientHeaders http.Header) string {
|
|
if clientHeaders != nil && clientHeaders.Get("X-Client-Sess") != "" {
|
|
return "resolved:" + clientHeaders.Get("X-Client-Sess")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Forwarded-Session": "$CPA-SESSION-ID",
|
|
}
|
|
clientHeaders := http.Header{
|
|
"X-Client-Sess": []string{"sess-999"},
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
|
|
|
if got := req.Header.Get("X-Forwarded-Session"); got != "resolved:sess-999" {
|
|
t.Errorf("X-Forwarded-Session = %q, want %q", got, "resolved:sess-999")
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id omitted when unresolvable", func(t *testing.T) {
|
|
oldResolver := SessionIDResolver
|
|
defer func() { SessionIDResolver = oldResolver }()
|
|
SessionIDResolver = nil
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Session-ID": "$CPA-SESSION-ID",
|
|
"header:X-Auth-Token": "Bearer $CPA-SESSION-ID",
|
|
"header:Static": "ok",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if _, exists := req.Header["X-Session-ID"]; exists {
|
|
t.Errorf("expected X-Session-ID to be omitted when unresolved, got %q", req.Header.Get("X-Session-ID"))
|
|
}
|
|
if _, exists := req.Header["X-Auth-Token"]; exists {
|
|
t.Errorf("expected X-Auth-Token to be omitted when unresolved, got %q", req.Header.Get("X-Auth-Token"))
|
|
}
|
|
if got := req.Header.Get("Static"); got != "ok" {
|
|
t.Errorf("Static = %q, want %q", got, "ok")
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id does not read literal client header CPA-SESSION-ID", func(t *testing.T) {
|
|
oldResolver := SessionIDResolver
|
|
defer func() { SessionIDResolver = oldResolver }()
|
|
SessionIDResolver = nil
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
attrs := map[string]string{
|
|
"header:X-Session-ID": "$CPA-SESSION-ID",
|
|
}
|
|
clientHeaders := http.Header{
|
|
"Cpa-Session-Id": []string{"attacker-spoofed-value"},
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
|
|
|
|
if _, exists := req.Header["X-Session-ID"]; exists {
|
|
t.Errorf("expected X-Session-ID not to inherit spoofed client header CPA-SESSION-ID, got %q", req.Header.Get("X-Session-ID"))
|
|
}
|
|
})
|
|
|
|
t.Run("cpa-session-id containing magic variable does not infinite loop", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
|
|
sessionWithToken := "header:$CPA-SESSION-ID"
|
|
req = req.WithContext(WithSessionID(req.Context(), sessionWithToken))
|
|
attrs := map[string]string{
|
|
"header:X-Session-ID": "$CPA-SESSION-ID",
|
|
"header:X-Auth-Token": "Bearer $CPA-SESSION-ID",
|
|
"header:X-Auth-Repeat": "Bearer $cpa-session-id-$CPA-SESSION-ID",
|
|
}
|
|
|
|
ApplyCustomHeadersFromAttrs(req, attrs)
|
|
|
|
if got := req.Header.Get("X-Session-ID"); got != sessionWithToken {
|
|
t.Errorf("X-Session-ID = %q, want %q", got, sessionWithToken)
|
|
}
|
|
wantAuth := "Bearer " + sessionWithToken
|
|
if got := req.Header.Get("X-Auth-Token"); got != wantAuth {
|
|
t.Errorf("X-Auth-Token = %q, want %q", got, wantAuth)
|
|
}
|
|
wantRepeat := "Bearer " + sessionWithToken + "-" + sessionWithToken
|
|
if got := req.Header.Get("X-Auth-Repeat"); got != wantRepeat {
|
|
t.Errorf("X-Auth-Repeat = %q, want %q", got, wantRepeat)
|
|
}
|
|
})
|
|
}
|