346 lines · 13.3 KB · Go
RawDownload
package executor import (	"context"	"errors"	"fmt"	"net/http"	"testing" 	claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"	"github.com/router-for-me/CLIProxyAPI/v7/internal/config"	cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"	cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"	sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator") func TestClaudeExecutorDuplicateMetadataIsRequestScoped(t *testing.T) {	testCases := []struct {		name string		run  func(context.Context, *ClaudeExecutor, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) error	}{		{			name: "execute",			run: func(ctx context.Context, executor *ClaudeExecutor, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) error {				_, errExecute := executor.Execute(ctx, auth, req, opts)				return errExecute			},		},		{			name: "stream",			run: func(ctx context.Context, executor *ClaudeExecutor, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) error {				_, errStream := executor.ExecuteStream(ctx, auth, req, opts)				return errStream			},		},	} 	for _, testCase := range testCases {		t.Run(testCase.name, func(t *testing.T) {			upstreamCalled := false			transport := roundTripperFunc(func(*http.Request) (*http.Response, error) {				upstreamCalled = true				return nil, errors.New("unexpected upstream request")			})			ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport))			auth := &cliproxyauth.Auth{				Provider:   "claude",				Attributes: map[string]string{"api_key": "sk-ant-oat-duplicate-metadata", "auth_kind": "oauth"},				Metadata: map[string]any{					"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",					claudeauth.ClaudeDeviceIDsMetadataKey: []string{						"0000000000000000000000000000000000000000000000000000000000000000",					},				},			}			req := cliproxyexecutor.Request{				Model: "claude-opus-5",				Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hello"}],` +					`"metadata":{"user_id":"{}"},"metadata":{"user_id":"{}"}}`),			}			errRun := testCase.run(ctx, NewClaudeExecutor(&config.Config{}), auth, req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})			if errRun == nil {				t.Fatal("duplicate metadata error = nil")			}			if upstreamCalled {				t.Fatal("duplicate metadata reached upstream")			}			var requestErr cliproxyexecutor.RequestScopedError			if !errors.As(errRun, &requestErr) || requestErr == nil || !requestErr.IsRequestScoped() {				t.Fatalf("duplicate metadata error = %T %v, want request-scoped", errRun, errRun)			}			var statusErr interface{ StatusCode() int }			if !errors.As(errRun, &statusErr) || statusErr.StatusCode() != http.StatusBadRequest {				t.Fatalf("duplicate metadata error = %T %v, want HTTP 400", errRun, errRun)			}		})	}} func TestClaudeExecutorPrepareRequestAuthPopulatesCredentialIdentity(t *testing.T) {	executor := NewClaudeExecutor(&config.Config{})	executor.oauthProfileFetcher = func(_ context.Context, _ *cliproxyauth.Auth, accessToken string) (*claudeauth.OAuthProfile, error) {		if accessToken != "sk-ant-oat-prepare" {			t.Fatalf("access token = %q, want selected credential token", accessToken)		}		profile := &claudeauth.OAuthProfile{}		profile.Account.UUID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"		profile.Account.Email = "user@example.com"		profile.Organization.UUID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"		profile.Organization.Name = "Example Org"		return profile, nil	}	auth := &cliproxyauth.Auth{		ID: "claude-old-credential",		Attributes: map[string]string{			"api_key": "sk-ant-oat-prepare",		},		Metadata: map[string]any{"type": "claude"},	} 	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false for missing credential identity")	}	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)	}	deviceIDs := claudeauth.NormalizeDeviceIDPool(prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey])	if len(deviceIDs) != claudeauth.ClaudeDevicePoolSize {		t.Fatalf("device pool length = %d, want %d", len(deviceIDs), claudeauth.ClaudeDevicePoolSize)	}	if got := prepared.Metadata["account_uuid"]; got != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {		t.Fatalf("account_uuid = %#v, want upstream profile account", got)	}	if got := prepared.Metadata["organization_uuid"]; got != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" {		t.Fatalf("organization_uuid = %#v, want upstream profile organization", got)	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after identity was populated")	}} func TestClaudeExecutorPrepareRequestAuthMigratesFiveDevicesToOne(t *testing.T) {	legacy := []string{		"0000000000000000000000000000000000000000000000000000000000000000",		"1111111111111111111111111111111111111111111111111111111111111111",		"2222222222222222222222222222222222222222222222222222222222222222",		"3333333333333333333333333333333333333333333333333333333333333333",		"4444444444444444444444444444444444444444444444444444444444444444",	}	executor := NewClaudeExecutor(&config.Config{})	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		t.Fatal("profile lookup should not run when account UUID is already present")		return nil, nil	}	auth := &cliproxyauth.Auth{		ID:         "claude-five-device-credential",		Attributes: map[string]string{"api_key": "sk-ant-oat-five-device"},		Metadata: map[string]any{			"account_uuid":                        "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",			claudeauth.ClaudeDeviceIDsMetadataKey: legacy,		},	}	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false for legacy five-device pool")	}	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)	}	deviceIDs, ok := prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey].([]string)	if !ok || len(deviceIDs) != 1 || deviceIDs[0] != legacy[0] {		t.Fatalf("prepared device IDs = %#v, want first legacy device only", prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey])	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after single-device migration")	}} func TestClaudeExecutorPrepareRequestAuthIgnoresFreshTimestampWithoutIdentity(t *testing.T) {	calls := 0	executor := NewClaudeExecutor(&config.Config{})	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		calls++		return nil, fmt.Errorf("profile unavailable")	}	const previousCheckedAt = "2999-01-01T00:00:00Z"	auth := &cliproxyauth.Auth{		ID:         "claude-profile-unavailable",		Attributes: map[string]string{"api_key": "sk-ant-oat-profile-unavailable"},		Metadata: map[string]any{			"type":                                "claude",			claudeAccountProfileCheckedAtKey:      previousCheckedAt,			claudeauth.ClaudeDeviceIDsMetadataKey: []string{"0000000000000000000000000000000000000000000000000000000000000000"},		},	} 	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare == nil {		t.Fatal("PrepareRequestAuth() error = nil, want missing account identity failure")	}	if prepared != nil {		t.Fatalf("PrepareRequestAuth() auth = %#v, want nil on missing account identity", prepared)	}	if calls != 1 {		t.Fatalf("profile calls = %d, want 1", calls)	}	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false after failed profile lookup; failure must remain retryable")	}	if got := claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey); got != previousCheckedAt {		t.Fatalf("profile checked timestamp = %q, want prior value preserved without suppressing retry", got)	}} func TestClaudeExecutorPrepareRequestAuthSetupTokenBypassesProfile(t *testing.T) {	executor := NewClaudeExecutor(&config.Config{})	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		t.Fatal("profile fetcher should NOT be called for setup-tokens")		return nil, nil	}	auth := &cliproxyauth.Auth{		ID: "claude-setuptoken.json",		Attributes: map[string]string{			"api_key": "sk-ant-oat01-test-setup-token-value",		},		Metadata: map[string]any{			"type":   "claude",			"scopes": "user:inference user:ccr_inference user:file_upload",		},	} 	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false for missing setup-token identity")	}	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)	}	if prepared == nil {		t.Fatal("prepared auth is nil")	}	accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")	if accountUUID == "" {		t.Fatal("account_uuid is empty after setup-token preparation")	}	deviceIDs := claudeauth.NormalizeDeviceIDPool(prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey])	if len(deviceIDs) != 1 {		t.Fatalf("device pool length = %d, want 1", len(deviceIDs))	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after setup-token identity was populated")	}} func TestClaudeExecutorPrepareRequestAuth403ScopeFallback(t *testing.T) {	executor := NewClaudeExecutor(&config.Config{})	fetchCalls := 0	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		fetchCalls++		return nil, fmt.Errorf("fetch Claude OAuth profile failed with status 403: permission_error: OAuth token does not meet scope requirement any_of(user:profile, user:office)")	}	auth := &cliproxyauth.Auth{		ID: "claude-scope-restricted-credential",		Attributes: map[string]string{			"api_key": "sk-ant-oat01-scope-restricted",		},		Metadata: map[string]any{			"type":          "claude",			"refresh_token": "dummy-refresh-token",		},	} 	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false for missing identity")	}	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() with 403 error = %v, want fallback success", errPrepare)	}	if prepared == nil {		t.Fatal("prepared auth is nil")	}	if fetchCalls != 1 {		t.Fatalf("fetchCalls = %d, want 1", fetchCalls)	}	accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")	if accountUUID == "" {		t.Fatal("account_uuid is empty after 403 fallback")	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after 403 identity was populated")	}} func TestClaudeExecutorPrepareRequestAuthSkipAccountProfileConfig(t *testing.T) {	executor := NewClaudeExecutor(&config.Config{})	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		t.Fatal("profile fetcher should NOT be called when skip_account_profile is true")		return nil, nil	}	auth := &cliproxyauth.Auth{		ID: "claude-skip-profile.json",		Attributes: map[string]string{			"api_key": "sk-ant-oat01-skip-profile",		},		Metadata: map[string]any{			"type":                 "claude",			"skip_account_profile": true,		},	} 	if !executor.ShouldPrepareRequestAuth(auth) {		t.Fatal("ShouldPrepareRequestAuth() = false for missing identity")	}	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)	}	if prepared == nil {		t.Fatal("prepared auth is nil")	}	accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")	if accountUUID == "" {		t.Fatal("account_uuid is empty after skip_account_profile preparation")	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after identity was populated")	}} func TestClaudeExecutorPrepareRequestAuthEmptyAccountUUIDInProfileFallback(t *testing.T) {	executor := NewClaudeExecutor(&config.Config{})	fetchCalls := 0	executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {		fetchCalls++		return &claudeauth.OAuthProfile{}, nil	}	auth := &cliproxyauth.Auth{		ID: "claude-empty-uuid-in-profile",		Attributes: map[string]string{			"api_key": "sk-ant-oat01-empty-uuid",		},		Metadata: map[string]any{			"type": "claude",		},	} 	prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)	if errPrepare != nil {		t.Fatalf("PrepareRequestAuth() error = %v, want fallback on empty UUID", errPrepare)	}	if prepared == nil {		t.Fatal("prepared auth is nil")	}	if fetchCalls != 1 {		t.Fatalf("fetchCalls = %d, want 1", fetchCalls)	}	accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")	if accountUUID == "" {		t.Fatal("account_uuid is empty after fallback")	}	if executor.ShouldPrepareRequestAuth(prepared) {		t.Fatal("ShouldPrepareRequestAuth() = true after identity was populated")	}}