271 lines
9.6 KiB
Go
271 lines
9.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
)
|
|
|
|
type failExecutor struct {
|
|
provider string
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (e *failExecutor) Identifier() string { return e.provider }
|
|
func (e *failExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
e.calls.Add(1)
|
|
return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusInternalServerError, Message: "upstream failure"}
|
|
}
|
|
func (e *failExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
|
e.calls.Add(1)
|
|
return nil, &Error{HTTPStatus: http.StatusInternalServerError, Message: "upstream failure"}
|
|
}
|
|
func (e *failExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) { return auth, nil }
|
|
func (e *failExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
return cliproxyexecutor.Response{}, nil
|
|
}
|
|
func (e *failExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type successExecutor struct {
|
|
provider string
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (e *successExecutor) Identifier() string { return e.provider }
|
|
func (e *successExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
e.calls.Add(1)
|
|
return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
|
|
}
|
|
func (e *successExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
|
e.calls.Add(1)
|
|
return nil, nil
|
|
}
|
|
func (e *successExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) { return auth, nil }
|
|
func (e *successExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
return cliproxyexecutor.Response{}, nil
|
|
}
|
|
func (e *successExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestManagerSessionAffinityMixedPoolNilMetadataPropagatesFailureCleanup(t *testing.T) {
|
|
ctx := context.Background()
|
|
p1 := "affinity-p1"
|
|
p2 := "affinity-p2"
|
|
model := "test-model"
|
|
auth1ID := "auth-1"
|
|
auth2ID := "auth-2"
|
|
|
|
manager := NewManager(nil, nil, nil)
|
|
affinity := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Hour,
|
|
})
|
|
defer affinity.Stop()
|
|
manager.SetSelector(affinity)
|
|
failExec := &failExecutor{provider: p1}
|
|
succExec := &successExecutor{provider: p2}
|
|
manager.RegisterExecutor(failExec)
|
|
manager.RegisterExecutor(succExec)
|
|
|
|
for _, auth := range []*Auth{
|
|
{
|
|
ID: auth1ID,
|
|
Provider: p1,
|
|
Status: StatusActive,
|
|
Metadata: map[string]any{"disable_cooling": true}, // Disable cooling so availability remains active, relying on session affinity unbind
|
|
},
|
|
{
|
|
ID: auth2ID,
|
|
Provider: p2,
|
|
Status: StatusActive,
|
|
Metadata: map[string]any{"disable_cooling": true},
|
|
},
|
|
} {
|
|
if _, errRegister := manager.Register(WithSkipPersist(ctx), auth); errRegister != nil {
|
|
t.Fatalf("Register(%s): %v", auth.ID, errRegister)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) })
|
|
}
|
|
|
|
// Inbound request with explicitly nil Metadata, only session header
|
|
req := cliproxyexecutor.Request{Model: model}
|
|
opts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Session-Id": []string{"sess-mixed-1"}},
|
|
}
|
|
if opts.Metadata != nil {
|
|
t.Fatalf("expected test initial opts.Metadata to be nil")
|
|
}
|
|
|
|
// 1. Execute request: auth-1 is selected, fails, Result carries propagated "mixed" affinity namespace,
|
|
// MarkResult unbinds "mixed::sess-mixed-1::test-model", and execution falls over to auth-2 which succeeds.
|
|
resp, errExec := manager.Execute(ctx, []string{p1, p2}, req, opts)
|
|
if errExec != nil {
|
|
t.Fatalf("first Execute failed: %v", errExec)
|
|
}
|
|
if string(resp.Payload) != `{"ok":true}` {
|
|
t.Fatalf("first Execute payload = %s, want ok", string(resp.Payload))
|
|
}
|
|
if failExec.calls.Load() != 1 {
|
|
t.Fatalf("expected failExec called 1 time, got %d", failExec.calls.Load())
|
|
}
|
|
if succExec.calls.Load() != 1 {
|
|
t.Fatalf("expected succExec called 1 time, got %d", succExec.calls.Load())
|
|
}
|
|
|
|
// Verify the affinity cache has auth-2 bound under the "mixed" namespace
|
|
cachedAuthID, ok := affinity.cache.Get("mixed::header:sess-mixed-1::" + model)
|
|
if !ok {
|
|
t.Fatalf("expected mixed cache key to be bound to auth-2, but not found in cache")
|
|
}
|
|
if cachedAuthID != auth2ID {
|
|
t.Fatalf("expected mixed cache key to be bound to %q, got %q", auth2ID, cachedAuthID)
|
|
}
|
|
|
|
// Verify mismatched provider cache key was NOT used
|
|
if _, okP1 := affinity.cache.Get("affinity-p1::header:sess-mixed-1::" + model); okP1 {
|
|
t.Fatalf("unexpected p1 provider cache key created")
|
|
}
|
|
|
|
// 2. Second Execute call with fresh request and nil Metadata for the SAME session
|
|
opts2 := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Session-Id": []string{"sess-mixed-1"}},
|
|
}
|
|
resp2, errExec2 := manager.Execute(ctx, []string{p1, p2}, req, opts2)
|
|
if errExec2 != nil {
|
|
t.Fatalf("second Execute failed: %v", errExec2)
|
|
}
|
|
if string(resp2.Payload) != `{"ok":true}` {
|
|
t.Fatalf("second Execute payload = %s, want ok", string(resp2.Payload))
|
|
}
|
|
// failExec call count must remain 1 because session affinity directly picked auth-2
|
|
if failExec.calls.Load() != 1 {
|
|
t.Fatalf("expected failExec to not be called on second request, call count = %d", failExec.calls.Load())
|
|
}
|
|
if succExec.calls.Load() != 2 {
|
|
t.Fatalf("expected succExec called 2 times, got %d", succExec.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinityAtomicCompareAndDeleteProtectsReboundSession(t *testing.T) {
|
|
cache := NewSessionCache(time.Hour)
|
|
defer cache.Stop()
|
|
|
|
sessionKey := "mixed::sess-rebound::model-x"
|
|
|
|
// 1. Initial binding to auth-A
|
|
cache.Set(sessionKey, "auth-A")
|
|
if got, ok := cache.Get(sessionKey); !ok || got != "auth-A" {
|
|
t.Fatalf("Get() = %q, %v; want %q, true", got, ok, "auth-A")
|
|
}
|
|
|
|
// 2. Session rebinds to auth-B
|
|
cache.Set(sessionKey, "auth-B")
|
|
if got, ok := cache.Get(sessionKey); !ok || got != "auth-B" {
|
|
t.Fatalf("Get() = %q, %v; want %q, true", got, ok, "auth-B")
|
|
}
|
|
|
|
// 3. Stale failure for auth-A tries to delete
|
|
deleted := cache.CompareAndDelete(sessionKey, "auth-A")
|
|
if deleted {
|
|
t.Fatalf("CompareAndDelete with stale auth-A unexpectedly returned true")
|
|
}
|
|
// Session must still be bound to auth-B
|
|
if got, ok := cache.Get(sessionKey); !ok || got != "auth-B" {
|
|
t.Fatalf("Get() after stale delete attempt = %q, %v; want %q, true", got, ok, "auth-B")
|
|
}
|
|
|
|
// 4. Valid failure for auth-B deletes
|
|
deletedValid := cache.CompareAndDelete(sessionKey, "auth-B")
|
|
if !deletedValid {
|
|
t.Fatalf("CompareAndDelete with active auth-B returned false")
|
|
}
|
|
if _, ok := cache.Get(sessionKey); ok {
|
|
t.Fatalf("sessionKey still present in cache after valid CompareAndDelete")
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinityDelayedSuccessDoesNotOverwriteReboundAuth(t *testing.T) {
|
|
affinity := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Hour,
|
|
})
|
|
defer affinity.Stop()
|
|
|
|
sessionKey := "mixed::header:sess-delay-success::model-x"
|
|
|
|
// 1. Initially auth-A is bound
|
|
affinity.cache.Set(sessionKey, "auth-A")
|
|
|
|
// 2. Session rebinds to auth-B
|
|
affinity.cache.Set(sessionKey, "auth-B")
|
|
|
|
// 3. A delayed success for auth-A arrives
|
|
opts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Session-Id": []string{"sess-delay-success"}},
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.SessionAffinityProviderMetadataKey: "mixed",
|
|
cliproxyexecutor.SessionAffinityModelMetadataKey: "model-x",
|
|
},
|
|
}
|
|
affinity.OnResult(Result{
|
|
AuthID: "auth-A",
|
|
Provider: "provider-a",
|
|
Model: "model-x",
|
|
Success: true,
|
|
Options: opts,
|
|
})
|
|
|
|
// 4. Cache must remain bound to auth-B, not overwritten by auth-A
|
|
got, ok := affinity.cache.Get(sessionKey)
|
|
if !ok || got != "auth-B" {
|
|
t.Fatalf("cache binding = %q, %v; want auth-B, true (delayed success of auth-A must not overwrite auth-B)", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinityOnResultWithMismatchedNamespaceFailsToUnbind(t *testing.T) {
|
|
affinity := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Hour,
|
|
})
|
|
defer affinity.Stop()
|
|
|
|
sessionID := "header:sess-ns-1"
|
|
model := "test-model"
|
|
authID := "auth-1"
|
|
|
|
// Bind under "mixed" namespace
|
|
mixedKey := "mixed::" + sessionID + "::" + model
|
|
affinity.cache.Set(mixedKey, authID)
|
|
|
|
// Call OnResult with options carrying the propagated "mixed" namespace
|
|
res := Result{
|
|
AuthID: authID,
|
|
Provider: "gemini", // actual provider
|
|
Model: model,
|
|
Success: false,
|
|
Error: &Error{HTTPStatus: http.StatusInternalServerError},
|
|
Options: cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Session-Id": []string{"sess-ns-1"}},
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.SessionAffinityProviderMetadataKey: "mixed",
|
|
cliproxyexecutor.SessionAffinityModelMetadataKey: model,
|
|
},
|
|
},
|
|
}
|
|
|
|
affinity.OnResult(res)
|
|
|
|
// Verify mixedKey is cleanly removed
|
|
if _, ok := affinity.cache.Get(mixedKey); ok {
|
|
t.Fatalf("expected mixed key to be removed after OnResult with propagated namespace")
|
|
}
|
|
}
|