17839 lines
581 KiB
Go
17839 lines
581 KiB
Go
package lib
|
|
|
|
/*
|
|
===================================================================================
|
|
V1 COMPAT TESTS
|
|
===================================================================================
|
|
Tests for applyV1Compat, which normalizes ConfigData when config.json sets
|
|
version: 1, restoring v1.4.x semantics (empty arrays = allow all).
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestApplyV1Compat_ProviderKey_EmptyModels | nil/[] models → ["*"] |
|
|
| TestApplyV1Compat_ProviderKey_WildcardUnchanged | ["*"] models unchanged |
|
|
| TestApplyV1Compat_ProviderKey_ExplicitUnchanged | Specific model list unchanged |
|
|
| TestApplyV1Compat_VK_EmptyProviderConfigs | empty provider_configs → backfill providers |
|
|
| TestApplyV1Compat_VK_ProviderConfig_EmptyAllowedModels | allowed_models: [] → ["*"] |
|
|
| TestApplyV1Compat_VK_ProviderConfig_EmptyKeyIDs | key_ids: [] → AllowAllKeys=true |
|
|
| TestApplyV1Compat_VK_ProviderConfig_AlreadyAllowAll | AllowAllKeys=true unchanged |
|
|
| TestApplyV1Compat_VK_EmptyMCPConfigs | empty mcp_configs → backfill MCP clients |
|
|
| TestApplyV1Compat_VK_NonEmptyMCPConfigs | non-empty mcp_configs unchanged |
|
|
| TestApplyV1Compat_NoGovernance | nil governance section — no panic |
|
|
| TestApplyV1Compat_NoMCP | nil mcp section — no MCP backfill |
|
|
| TestApplyV1Compat_MultipleProviders | all providers normalized in one pass |
|
|
| TestVersionField_ParsedFromJSON | version field read from config JSON |
|
|
| TestVersionField_DefaultBehavior | omitted version → v2 semantics (no change) |
|
|
| TestVersionField_Version1_AppliesCompat | version: 1 → normalization applied |
|
|
| TestVersionField_Version2_NoCompat | version: 2 → normalization skipped |
|
|
|
|
===================================================================================
|
|
CONFIG HASH TEST SCENARIOS INDEX
|
|
===================================================================================
|
|
|
|
This file contains comprehensive tests for the Bifrost configuration system,
|
|
covering hash generation, config reconciliation between config.json and database,
|
|
and SQLite integration tests. The hash-based reconciliation ensures that:
|
|
- Config file changes override DB values (file is source of truth for defined items)
|
|
- Dashboard-added items (only in DB) are preserved
|
|
- Unchanged configs are not unnecessarily updated
|
|
|
|
===================================================================================
|
|
HASH GENERATION TESTS
|
|
===================================================================================
|
|
Tests that verify hash generation for different config types produces stable,
|
|
deterministic hashes that change when and only when relevant fields change.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestGenerateProviderConfigHash | Provider hash excludes keys, different |
|
|
| | fields → different hash |
|
|
| TestGenerateKeyHash | Key hash skips ID, detects content changes |
|
|
| TestGenerateKeyHash_StableOrdering | Key hash stable regardless of Models order |
|
|
| TestGenerateVirtualKeyHash | VK hash skips ID, detects content changes |
|
|
| TestGenerateVirtualKeyHash_WithProviderConfigs | VK hash includes provider configs |
|
|
| TestGenerateVirtualKeyHash_WithMCPConfigs | VK hash includes MCP configs |
|
|
| TestGenerateVirtualKeyHash_MCPConfigChanges | VK hash changes when MCP tools change |
|
|
| TestGenerateVirtualKeyHash_StableProviderConfigOrdering | VK hash stable across provider order |
|
|
| TestGenerateVirtualKeyHash_StableAllowedModelsOrdering | VK hash stable across model order |
|
|
| TestGenerateVirtualKeyHash_StableKeyIDsOrdering | VK hash stable across key ID order |
|
|
| TestGenerateVirtualKeyHash_StableMCPConfigOrdering | VK hash stable across MCP config order |
|
|
| TestGenerateVirtualKeyHash_StableToolsToExecuteOrdering | VK hash stable across tool order |
|
|
| TestGenerateVirtualKeyHash_StableCombinedOrdering | All orderings combined remain stable |
|
|
| TestGenerateBudgetHash | Budget hash from all budget fields |
|
|
| TestGenerateRateLimitHash | RateLimit hash from all rate limit fields |
|
|
| TestGenerateCustomerHash | Customer hash from all customer fields |
|
|
| TestGenerateTeamHash | Team hash from all team fields |
|
|
| TestGenerateMCPClientHash | MCP client hash from all MCP client fields |
|
|
| TestGeneratePluginHash | Plugin hash from all plugin fields |
|
|
| TestGenerateClientConfigHash | ClientConfig hash from all client fields |
|
|
|
|
===================================================================================
|
|
RUNTIME VS MIGRATION PARITY TESTS
|
|
===================================================================================
|
|
These tests verify that hash generation produces identical results whether
|
|
computed at runtime or via database migration, ensuring upgrade compatibility.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestGenerateMCPClientHash_RuntimeVsMigrationParity | MCP hash same at runtime & migration |
|
|
| TestGeneratePluginHash_RuntimeVsMigrationParity | Plugin hash same at runtime & migration |
|
|
| TestGenerateTeamHash_RuntimeVsMigrationParity | Team hash same at runtime & migration |
|
|
| TestGenerateProviderHash_RuntimeVsMigrationParity | Provider hash same at runtime & migration |
|
|
| TestGenerateKeyHash_RuntimeVsMigrationParity | Key hash same at runtime & migration |
|
|
| TestGenerateClientConfigHash_RuntimeVsMigrationParity | ClientConfig hash same at runtime & migration |
|
|
|
|
===================================================================================
|
|
PROVIDER HASH COMPARISON TESTS
|
|
===================================================================================
|
|
Tests for provider-level config reconciliation between file and database.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestProviderHashComparison_MatchingHash | Hash match → keep DB config unchanged |
|
|
| TestProviderHashComparison_DifferentHash | Hash differs → sync from file, keep DB keys |
|
|
| TestProviderHashComparison_NewProvider | New provider in file → add to DB |
|
|
| TestProviderHashComparison_ProviderOnlyInDB | Dashboard-added provider → preserved |
|
|
| TestProviderHashComparison_RoundTrip | JSON→DB→same JSON = no changes |
|
|
| TestProviderHashComparison_DashboardEditThenSameFile | Dashboard edits preserved on reload |
|
|
| TestProviderHashComparison_FullLifecycle | Complete lifecycle: add→edit→reload |
|
|
| TestProviderHashComparison_MultipleUpdates | Multiple file updates + revert to old config |
|
|
| TestProviderHashComparison_OptionalFieldsPresence | NetworkConfig, ProxyConfig, CustomProvider |
|
|
| TestProviderHashComparison_FieldValueChanges | BaseURL, ExtraHeaders, Concurrency changes |
|
|
| TestProviderHashComparison_FieldRemoved | Removing NetworkConfig, ProxyConfig, etc. |
|
|
| TestProviderHashComparison_PartialFieldChanges | Timeout, MaxRetries in nested structs |
|
|
| TestProviderHashComparison_ProviderChangedKeysUnchanged | Provider changes, keys stay same |
|
|
| TestProviderHashComparison_KeysChangedProviderUnchanged | Keys change, provider stays same |
|
|
| TestProviderHashComparison_BothChangedIndependently | Both provider and keys change |
|
|
| TestProviderHashComparison_NeitherChanged | No changes → no updates |
|
|
|
|
===================================================================================
|
|
AZURE/BEDROCK/VERTEX PROVIDER-SPECIFIC TESTS
|
|
===================================================================================
|
|
Tests for provider-specific key configurations (Azure, Bedrock, Vertex).
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestKeyHashComparison_AzureConfigSyncScenarios | Azure key config sync: endpoint, version |
|
|
| TestKeyHashComparison_BedrockConfigSyncScenarios | Bedrock key config sync: region, creds |
|
|
| TestKeyHashComparison_VertexConfigSyncScenarios | Vertex key config sync: project, region |
|
|
| TestProviderHashComparison_AzureProviderFullLifecycle | Azure provider full CRUD lifecycle |
|
|
| TestProviderHashComparison_BedrockProviderFullLifecycle | Bedrock provider full CRUD lifecycle |
|
|
| TestProviderHashComparison_VertexProviderFullLifecycle | Vertex provider full CRUD lifecycle |
|
|
| TestProviderHashComparison_AzureNewProviderFromConfig | New Azure provider from file |
|
|
| TestProviderHashComparison_BedrockNewProviderFromConfig | New Bedrock provider from file |
|
|
| TestProviderHashComparison_VertexNewProviderFromConfig | New Vertex provider from file |
|
|
| TestProviderHashComparison_AzureDBValuePreservedWhenHashMatches | Azure DB preserved on hash match |
|
|
| TestProviderHashComparison_BedrockDBValuePreservedWhenHashMatches | Bedrock DB preserved on hash match |
|
|
| TestProviderHashComparison_VertexDBValuePreservedWhenHashMatches | Vertex DB preserved on hash match |
|
|
| TestProviderHashComparison_AzureConfigChangedInFile | Azure config changed → file wins |
|
|
| TestProviderHashComparison_BedrockConfigChangedInFile | Bedrock config changed → file wins |
|
|
| TestProviderHashComparison_VertexConfigChangedInFile | Vertex config changed → file wins |
|
|
|
|
===================================================================================
|
|
KEY-LEVEL SYNC TESTS
|
|
===================================================================================
|
|
Tests for individual key reconciliation when provider hash matches.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestKeyHashComparison_OptionalFieldsPresence | Models, AzureKeyConfig, VertexKeyConfig |
|
|
| TestKeyHashComparison_FieldRemoved | Removing Models, AzureKeyConfig, Weight |
|
|
| TestKeyHashComparison_KeyContentChanged | Key Value, Models content changes |
|
|
| TestKeyLevelSync_ProviderHashMatch_SingleKeyChanged | One key changed → update that key |
|
|
| TestKeyLevelSync_ProviderHashMatch_NewKeyInFile | New key in file → add to merged keys |
|
|
| TestKeyLevelSync_ProviderHashMatch_KeyOnlyInDB | Key only in DB → preserve (dashboard-added) |
|
|
| TestKeyLevelSync_ProviderHashMatch_MixedScenario | Mixed: changed, new, DB-only, unchanged |
|
|
| TestKeyLevelSync_ProviderHashMatch_MultipleKeysChanged | Multiple keys changed at once |
|
|
|
|
===================================================================================
|
|
KEY WEIGHT TESTS
|
|
===================================================================================
|
|
Tests for key weight handling, including zero weight preservation.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestKeyWeight_ZeroPreserved | Weight=0 explicitly set is preserved |
|
|
| TestKeyWeight_DefaultToOneWhenNotSet | Nil weight defaults to 1.0 |
|
|
| TestKeyWeight_HashDiffersBetweenZeroAndOne | Weight 0 vs 1 produces different hash |
|
|
| TestSQLite_Key_WeightZero_RoundTrip | Weight=0 survives DB round-trip |
|
|
| TestVKProviderConfig_WeightZeroPreserved | VK provider config weight=0 preserved |
|
|
| TestSQLite_VKProviderConfig_WeightZero_RoundTrip | VK provider config weight=0 DB round-trip |
|
|
|
|
===================================================================================
|
|
KEY ENABLED/BATCH API TESTS
|
|
===================================================================================
|
|
Tests for key Enabled and UseForBatchAPI field handling.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestGenerateKeyHash_EnabledField | Enabled field affects hash (true/false/nil) |
|
|
| TestSQLite_Key_EnabledChange_Detected | Enabled change detected during sync |
|
|
| TestGenerateKeyHash_UseForBatchAPIField | UseForBatchAPI field affects hash |
|
|
| TestSQLite_Key_UseForBatchAPIChange_Detected | UseForBatchAPI change detected during sync |
|
|
|
|
===================================================================================
|
|
DEPLOYMENT MAP TESTS
|
|
===================================================================================
|
|
Tests for deployment map changes in provider-specific configs.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestKeyHashComparison_AzureDeploymentsChange | Azure deployments: add, remove, modify |
|
|
| TestKeyHashComparison_BedrockDeploymentsChange | Bedrock deployments: add, remove, modify |
|
|
| TestKeyHashComparison_VertexDeploymentsChange | Vertex deployments: add, remove, modify |
|
|
|
|
===================================================================================
|
|
VIRTUAL KEY HASH COMPARISON TESTS
|
|
===================================================================================
|
|
Tests for virtual key reconciliation between file and database.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestVirtualKeyHashComparison_MatchingHash | Hash match → keep DB config |
|
|
| TestVirtualKeyHashComparison_DifferentHash | Hash differs → sync from file |
|
|
| TestVirtualKeyHashComparison_VirtualKeyOnlyInDB | Dashboard-added VK → preserved |
|
|
| TestVirtualKeyHashComparison_NewVirtualKey | New VK in file → add to DB |
|
|
| TestVirtualKeyHashComparison_OptionalFieldsPresence | team_id, customer_id, budget_id, rate_limit_id |
|
|
| TestVirtualKeyHashComparison_FieldValueChanges | Field value changes detected |
|
|
| TestVirtualKeyHashComparison_RoundTrip | JSON→DB→same JSON = no changes |
|
|
|
|
===================================================================================
|
|
MERGE LOGIC TESTS (LoadConfig)
|
|
===================================================================================
|
|
Tests for config merging logic when loading from file with existing DB data.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestLoadConfig_ClientConfig_Merge | Client config merge: DB + file |
|
|
| TestLoadConfig_Providers_Merge | Provider keys merge: DB + file |
|
|
| TestLoadConfig_MCP_Merge | MCP config merge: DB + file |
|
|
| TestLoadConfig_Governance_Merge | Governance config merge: DB + file |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - PROVIDERS
|
|
===================================================================================
|
|
End-to-end tests with real SQLite database for provider operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_Provider_NewProviderFromFile | New provider from file creates in DB |
|
|
| TestSQLite_Provider_HashMatch_DBPreserved | Hash match → DB values preserved |
|
|
| TestSQLite_Provider_HashMismatch_FileSync | Hash mismatch → file values sync to DB |
|
|
| TestSQLite_Provider_DBOnlyProvider_Preserved | Dashboard-added provider preserved |
|
|
| TestSQLite_Provider_RoundTrip | Full provider round-trip test |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - KEYS
|
|
===================================================================================
|
|
End-to-end tests with real SQLite database for key operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_Key_NewKeyFromFile | New key from file creates in DB |
|
|
| TestSQLite_Key_HashMatch_DBKeyPreserved | Hash match → DB key preserved |
|
|
| TestSQLite_Key_DashboardAddedKey_Preserved | Dashboard-added key preserved on reload |
|
|
| TestSQLite_Key_KeyValueChange_Detected | Key value change detected and synced |
|
|
| TestSQLite_Key_MultipleKeys_MergeLogic | Multiple keys merge correctly |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - VIRTUAL KEYS
|
|
===================================================================================
|
|
End-to-end tests with real SQLite database for virtual key operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_VirtualKey_NewFromFile | New VK from file creates in DB |
|
|
| TestSQLite_VirtualKey_HashMatch_DBPreserved | Hash match → DB VK preserved |
|
|
| TestSQLite_VirtualKey_HashMismatch_FileSync | Hash mismatch → file VK syncs to DB |
|
|
| TestSQLite_VirtualKey_DBOnlyVK_Preserved | Dashboard-added VK preserved |
|
|
| TestSQLite_VirtualKey_WithProviderConfigs | VK with provider configs created correctly |
|
|
| TestSQLite_VirtualKey_MergePath_WithProviderConfigs | VK provider configs merge correctly |
|
|
| TestSQLite_VirtualKey_MergePath_WithProviderConfigKeys | VK provider config keys merge correctly |
|
|
| TestSQLite_VirtualKey_ProviderConfigKeyIDs | VK provider config key IDs handled correctly |
|
|
| TestSQLite_VirtualKey_WithMCPConfigs | VK with MCP configs created correctly |
|
|
| TestSQLite_VirtualKey_DashboardProviderConfig_DeletedOnFileChange | Dashboard provider config deleted on file change |
|
|
| TestSQLite_VirtualKey_DashboardMCPConfig_DeletedOnFileChange | Dashboard MCP config deleted on file change |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - VK PROVIDER CONFIGS
|
|
===================================================================================
|
|
End-to-end tests for virtual key provider configuration operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_VKProviderConfig_NewConfig | New VK provider config created |
|
|
| TestSQLite_VKProviderConfig_KeyReference | VK provider config key references work |
|
|
| TestSQLite_VKProviderConfig_HashChangesOnKeyIDChange | Hash changes when key ID changes |
|
|
| TestSQLite_VKProviderConfig_WeightAndAllowedModels | Weight and allowed models handled correctly |
|
|
| TestGenerateVirtualKeyHash_ProviderConfigRateLimit | VK hash includes provider config rate limit |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - VK MCP CONFIGS
|
|
===================================================================================
|
|
End-to-end tests for virtual key MCP configuration operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_VKMCPConfig_Reconciliation | VK MCP config reconciliation works |
|
|
| TestSQLite_VKMCPConfig_AddRemove | Adding and removing VK MCP configs |
|
|
| TestSQLite_VKMCPConfig_UpdateTools | Updating VK MCP config tools |
|
|
| TestSQLite_VK_ProviderAndMCPConfigs_Combined | Combined provider and MCP configs |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - GOVERNANCE (Budget, RateLimit, Customer, Team)
|
|
===================================================================================
|
|
End-to-end tests for governance entity operations.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_Budget_NewFromFile | New budget from file creates in DB |
|
|
| TestSQLite_Budget_HashMatch_DBPreserved | Hash match → DB budget preserved |
|
|
| TestSQLite_Budget_HashMismatch_FileSync | Hash mismatch → file budget syncs to DB |
|
|
| TestSQLite_Budget_DBOnly_Preserved | Dashboard-added budget preserved |
|
|
| TestSQLite_RateLimit_NewFromFile | New rate limit from file creates in DB |
|
|
| TestSQLite_RateLimit_HashMismatch_FileSync | Hash mismatch → file rate limit syncs to DB |
|
|
| TestSQLite_Customer_NewFromFile | New customer from file creates in DB |
|
|
| TestSQLite_Customer_HashMismatch_FileSync | Hash mismatch → file customer syncs to DB |
|
|
| TestSQLite_Team_NewFromFile | New team from file creates in DB |
|
|
| TestSQLite_Team_HashMismatch_FileSync | Hash mismatch → file team syncs to DB |
|
|
| TestSQLite_Governance_FullReconciliation | Full governance reconciliation test |
|
|
| TestSQLite_Governance_DBOnly_AllPreserved | All dashboard-added governance items preserved |
|
|
|
|
===================================================================================
|
|
SQLITE INTEGRATION TESTS - FULL LIFECYCLE
|
|
===================================================================================
|
|
Complete lifecycle tests covering multiple load/reload scenarios.
|
|
|
|
| Test Name | What It Tests |
|
|
|--------------------------------------------------|----------------------------------------------|
|
|
| TestSQLite_FullLifecycle_InitialLoad | Initial load creates all configs in DB |
|
|
| TestSQLite_FullLifecycle_SecondLoadNoChanges | Second load with same file → no DB updates |
|
|
| TestSQLite_FullLifecycle_FileChange_Selective | File change → only changed items updated |
|
|
| TestSQLite_FullLifecycle_DashboardEdits_ThenFileUnchanged | Dashboard edits preserved on reload |
|
|
|
|
===================================================================================
|
|
EXPECTED BEHAVIORS SUMMARY
|
|
===================================================================================
|
|
|
|
1. HASH-BASED RECONCILIATION:
|
|
- Hash computed from config content (excluding auto-generated IDs)
|
|
- Same hash = no change needed (DB value preserved)
|
|
- Different hash = file value takes precedence (source of truth)
|
|
- Missing hash in DB = item was added via dashboard (preserve it)
|
|
|
|
2. FILE vs DATABASE PRIORITY:
|
|
- Items defined in file: file is source of truth (hash-based sync)
|
|
- Items only in DB: dashboard-added, always preserved
|
|
- Items only in file: new items, created in DB
|
|
|
|
3. KEY WEIGHT HANDLING:
|
|
- nil weight → defaults to 1.0
|
|
- weight = 0 → explicitly set, must be preserved (not treated as nil)
|
|
- Weight affects hash calculation
|
|
|
|
4. KEY ENABLED/BATCH API HANDLING:
|
|
- Enabled: nil = default true, explicit true/false affects hash
|
|
- UseForBatchAPI: nil = default false, explicit true/false affects hash
|
|
- Both fields are included in key hash for change detection
|
|
|
|
5. PROVIDER-SPECIFIC CONFIGS:
|
|
- Azure: Endpoint, APIVersion, Deployments in AzureKeyConfig
|
|
- Bedrock: Region, AuthCredentials, Deployments in BedrockKeyConfig
|
|
- Vertex: ProjectID, Region, AuthCredentials, Deployments in VertexKeyConfig
|
|
- All fields including Deployments maps affect key hash and must sync correctly
|
|
|
|
6. VIRTUAL KEY ASSOCIATIONS:
|
|
- VK can have provider configs (provider + weight + allowed models + keys + budget_id + rate_limit_id)
|
|
- VK can have MCP configs (client_id + tools_to_execute)
|
|
- Changes in associations affect VK hash
|
|
- Dashboard-added associations preserved unless file VK changes
|
|
|
|
===================================================================================
|
|
*/
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
"github.com/maximhq/bifrost/framework"
|
|
"github.com/maximhq/bifrost/framework/configstore"
|
|
"github.com/maximhq/bifrost/framework/configstore/tables"
|
|
"github.com/maximhq/bifrost/framework/encrypt"
|
|
"github.com/maximhq/bifrost/framework/logstore"
|
|
"github.com/maximhq/bifrost/framework/modelcatalog"
|
|
"github.com/maximhq/bifrost/framework/vectorstore"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// MockConfigStore implements the ConfigStore interface for testing
|
|
type MockConfigStore struct {
|
|
clientConfig *configstore.ClientConfig
|
|
providers map[schemas.ModelProvider]configstore.ProviderConfig
|
|
mcpConfig *schemas.MCPConfig
|
|
governanceConfig *configstore.GovernanceConfig
|
|
authConfig *configstore.AuthConfig
|
|
frameworkConfig *tables.TableFrameworkConfig
|
|
vectorConfig *vectorstore.Config
|
|
logsConfig *logstore.Config
|
|
plugins []*tables.TablePlugin
|
|
|
|
// Track update calls for verification
|
|
clientConfigUpdated bool
|
|
providersConfigUpdated bool
|
|
mcpConfigsCreated []*schemas.MCPClientConfig
|
|
mcpClientConfigUpdates []struct {
|
|
ID string
|
|
Config tables.TableMCPClient
|
|
}
|
|
governanceItemsCreated struct {
|
|
budgets []tables.TableBudget
|
|
rateLimits []tables.TableRateLimit
|
|
customers []tables.TableCustomer
|
|
teams []tables.TableTeam
|
|
virtualKeys []tables.TableVirtualKey
|
|
}
|
|
flushSessionsCalled bool
|
|
}
|
|
|
|
// NewMockConfigStore creates a new mock config store
|
|
func NewMockConfigStore() *MockConfigStore {
|
|
return &MockConfigStore{
|
|
providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
}
|
|
}
|
|
|
|
// Implement ConfigStore interface methods
|
|
func (m *MockConfigStore) RefreshConnectionPool(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
func (m *MockConfigStore) Ping(ctx context.Context) error { return nil }
|
|
func (m *MockConfigStore) EncryptPlaintextRows(ctx context.Context) error { return nil }
|
|
func (m *MockConfigStore) Close(ctx context.Context) error { return nil }
|
|
func (m *MockConfigStore) DB() *gorm.DB { return nil }
|
|
func (m *MockConfigStore) ExecuteTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
|
return fn(nil)
|
|
}
|
|
|
|
func (m *MockConfigStore) RunMigration(context.Context, func(context.Context, *gorm.DB) error) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) RetryOnNotFound(ctx context.Context, fn func(ctx context.Context) (any, error), maxRetries int, retryDelay time.Duration) (any, error) {
|
|
return fn(ctx)
|
|
}
|
|
|
|
// Client config
|
|
func (m *MockConfigStore) UpdateClientConfig(ctx context.Context, config *configstore.ClientConfig) error {
|
|
m.clientConfig = config
|
|
m.clientConfigUpdated = true
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetClientConfig(ctx context.Context) (*configstore.ClientConfig, error) {
|
|
return m.clientConfig, nil
|
|
}
|
|
|
|
// Provider config
|
|
func (m *MockConfigStore) UpdateProvidersConfig(ctx context.Context, providers map[schemas.ModelProvider]configstore.ProviderConfig, tx ...*gorm.DB) error {
|
|
m.providers = providers
|
|
m.providersConfigUpdated = true
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetProvidersConfig(ctx context.Context) (map[schemas.ModelProvider]configstore.ProviderConfig, error) {
|
|
if len(m.providers) == 0 {
|
|
return nil, nil
|
|
}
|
|
return m.providers, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) AddProvider(ctx context.Context, provider schemas.ModelProvider, config configstore.ProviderConfig, tx ...*gorm.DB) error {
|
|
m.providers[provider] = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateProvider(ctx context.Context, provider schemas.ModelProvider, config configstore.ProviderConfig, tx ...*gorm.DB) error {
|
|
m.providers[provider] = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteProvider(ctx context.Context, provider schemas.ModelProvider, tx ...*gorm.DB) error {
|
|
delete(m.providers, provider)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetProviderKeys(ctx context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) {
|
|
config, ok := m.providers[provider]
|
|
if !ok {
|
|
return nil, configstore.ErrNotFound
|
|
}
|
|
return append([]schemas.Key(nil), config.Keys...), nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetProviderKey(ctx context.Context, provider schemas.ModelProvider, keyID string) (*schemas.Key, error) {
|
|
config, ok := m.providers[provider]
|
|
if !ok {
|
|
return nil, configstore.ErrNotFound
|
|
}
|
|
for _, key := range config.Keys {
|
|
if key.ID == keyID {
|
|
keyCopy := key
|
|
return &keyCopy, nil
|
|
}
|
|
}
|
|
return nil, configstore.ErrNotFound
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateProviderKey(ctx context.Context, provider schemas.ModelProvider, key schemas.Key, tx ...*gorm.DB) error {
|
|
config, ok := m.providers[provider]
|
|
if !ok {
|
|
return configstore.ErrNotFound
|
|
}
|
|
config.Keys = append(config.Keys, key)
|
|
m.providers[provider] = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateProviderKey(ctx context.Context, provider schemas.ModelProvider, keyID string, key schemas.Key, tx ...*gorm.DB) error {
|
|
config, ok := m.providers[provider]
|
|
if !ok {
|
|
return configstore.ErrNotFound
|
|
}
|
|
for i := range config.Keys {
|
|
if config.Keys[i].ID == keyID {
|
|
config.Keys[i] = key
|
|
m.providers[provider] = config
|
|
return nil
|
|
}
|
|
}
|
|
return configstore.ErrNotFound
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteProviderKey(ctx context.Context, provider schemas.ModelProvider, keyID string, tx ...*gorm.DB) error {
|
|
config, ok := m.providers[provider]
|
|
if !ok {
|
|
return configstore.ErrNotFound
|
|
}
|
|
for i := range config.Keys {
|
|
if config.Keys[i].ID == keyID {
|
|
config.Keys = append(config.Keys[:i], config.Keys[i+1:]...)
|
|
m.providers[provider] = config
|
|
return nil
|
|
}
|
|
}
|
|
return configstore.ErrNotFound
|
|
}
|
|
|
|
// MCP config
|
|
func (m *MockConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, error) {
|
|
return m.mcpConfig, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetMCPClientByID(ctx context.Context, id string) (*tables.TableMCPClient, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetMCPClientConfigByID(ctx context.Context, id string) (*schemas.MCPClientConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetMCPClientByName(ctx context.Context, name string) (*tables.TableMCPClient, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateMCPClientConfig(ctx context.Context, clientConfig *schemas.MCPClientConfig) error {
|
|
m.mcpConfig.ClientConfigs = append(m.mcpConfig.ClientConfigs, clientConfig)
|
|
m.mcpConfigsCreated = append(m.mcpConfigsCreated, clientConfig)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, clientConfig *tables.TableMCPClient) error {
|
|
m.mcpClientConfigUpdates = append(m.mcpClientConfigUpdates, struct {
|
|
ID string
|
|
Config tables.TableMCPClient
|
|
}{
|
|
ID: id,
|
|
Config: *clientConfig,
|
|
})
|
|
|
|
// Initialize m.mcpConfig if nil (same pattern as CreateMCPClientConfig)
|
|
if m.mcpConfig == nil {
|
|
m.mcpConfig = &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{},
|
|
}
|
|
}
|
|
|
|
// Update the in-memory state to ensure GetMCPConfig returns updated data
|
|
for i := range m.mcpConfig.ClientConfigs {
|
|
if m.mcpConfig.ClientConfigs[i].ID == id {
|
|
// Found the entry, update it with the new config
|
|
m.mcpConfig.ClientConfigs[i] = &schemas.MCPClientConfig{
|
|
ID: clientConfig.ClientID,
|
|
Name: clientConfig.Name,
|
|
IsCodeModeClient: clientConfig.IsCodeModeClient,
|
|
ConnectionType: schemas.MCPConnectionType(clientConfig.ConnectionType),
|
|
ConnectionString: clientConfig.ConnectionString,
|
|
StdioConfig: clientConfig.StdioConfig,
|
|
Headers: clientConfig.Headers,
|
|
ToolsToExecute: clientConfig.ToolsToExecute,
|
|
ToolsToAutoExecute: clientConfig.ToolsToAutoExecute,
|
|
AllowedExtraHeaders: clientConfig.AllowedExtraHeaders,
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
// If not found, create a new entry (similar to CreateMCPClientConfig behavior)
|
|
m.mcpConfig.ClientConfigs = append(m.mcpConfig.ClientConfigs, &schemas.MCPClientConfig{
|
|
ID: clientConfig.ClientID,
|
|
Name: clientConfig.Name,
|
|
IsCodeModeClient: clientConfig.IsCodeModeClient,
|
|
ConnectionType: schemas.MCPConnectionType(clientConfig.ConnectionType),
|
|
ConnectionString: clientConfig.ConnectionString,
|
|
StdioConfig: clientConfig.StdioConfig,
|
|
Headers: clientConfig.Headers,
|
|
ToolsToExecute: clientConfig.ToolsToExecute,
|
|
ToolsToAutoExecute: clientConfig.ToolsToAutoExecute,
|
|
AllowedExtraHeaders: clientConfig.AllowedExtraHeaders,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetMCPClientsPaginated(ctx context.Context, params configstore.MCPClientsQueryParams) ([]tables.TableMCPClient, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteMCPClientConfig(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
// Governance config
|
|
func (m *MockConfigStore) GetGovernanceConfig(ctx context.Context) (*configstore.GovernanceConfig, error) {
|
|
return m.governanceConfig, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil {
|
|
m.governanceConfig = &configstore.GovernanceConfig{}
|
|
}
|
|
m.governanceConfig.Budgets = append(m.governanceConfig.Budgets, *budget)
|
|
m.governanceItemsCreated.budgets = append(m.governanceItemsCreated.budgets, *budget)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateBudgets(ctx context.Context, budgets []*tables.TableBudget, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetBudget(ctx context.Context, id string, tx ...*gorm.DB) (*tables.TableBudget, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetBudgets(ctx context.Context) ([]tables.TableBudget, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil {
|
|
m.governanceConfig = &configstore.GovernanceConfig{}
|
|
}
|
|
m.governanceConfig.RateLimits = append(m.governanceConfig.RateLimits, *rateLimit)
|
|
m.governanceItemsCreated.rateLimits = append(m.governanceItemsCreated.rateLimits, *rateLimit)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateRateLimits(ctx context.Context, rateLimits []*tables.TableRateLimit, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRateLimit(ctx context.Context, id string, tx ...*gorm.DB) (*tables.TableRateLimit, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteRateLimit(ctx context.Context, id string, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil || len(m.governanceConfig.RateLimits) == 0 {
|
|
return nil
|
|
}
|
|
filtered := make([]tables.TableRateLimit, 0, len(m.governanceConfig.RateLimits))
|
|
for _, rl := range m.governanceConfig.RateLimits {
|
|
if rl.ID != id {
|
|
filtered = append(filtered, rl)
|
|
}
|
|
}
|
|
m.governanceConfig.RateLimits = filtered
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteBudget(ctx context.Context, id string, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil || len(m.governanceConfig.Budgets) == 0 {
|
|
return nil
|
|
}
|
|
filtered := make([]tables.TableBudget, 0, len(m.governanceConfig.Budgets))
|
|
for _, b := range m.governanceConfig.Budgets {
|
|
if b.ID != id {
|
|
filtered = append(filtered, b)
|
|
}
|
|
}
|
|
m.governanceConfig.Budgets = filtered
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRateLimits(ctx context.Context) ([]tables.TableRateLimit, error) {
|
|
return []tables.TableRateLimit{}, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil {
|
|
m.governanceConfig = &configstore.GovernanceConfig{}
|
|
}
|
|
m.governanceConfig.Customers = append(m.governanceConfig.Customers, *customer)
|
|
m.governanceItemsCreated.customers = append(m.governanceItemsCreated.customers, *customer)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteCustomer(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetCustomer(ctx context.Context, id string) (*tables.TableCustomer, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetCustomers(ctx context.Context) ([]tables.TableCustomer, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetCustomersPaginated(ctx context.Context, params configstore.CustomersQueryParams) ([]tables.TableCustomer, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil {
|
|
m.governanceConfig = &configstore.GovernanceConfig{}
|
|
}
|
|
m.governanceConfig.Teams = append(m.governanceConfig.Teams, *team)
|
|
m.governanceItemsCreated.teams = append(m.governanceItemsCreated.teams, *team)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteTeam(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetTeam(ctx context.Context, id string) (*tables.TableTeam, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetTeams(ctx context.Context, customerID string) ([]tables.TableTeam, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetTeamsPaginated(ctx context.Context, params configstore.TeamsQueryParams) ([]tables.TableTeam, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error {
|
|
if m.governanceConfig == nil {
|
|
m.governanceConfig = &configstore.GovernanceConfig{}
|
|
}
|
|
m.governanceConfig.VirtualKeys = append(m.governanceConfig.VirtualKeys, *virtualKey)
|
|
m.governanceItemsCreated.virtualKeys = append(m.governanceItemsCreated.virtualKeys, *virtualKey)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteVirtualKey(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKey(ctx context.Context, id string) (*tables.TableVirtualKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeys(ctx context.Context) ([]tables.TableVirtualKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeysPaginated(ctx context.Context, params configstore.VirtualKeyQueryParams) ([]tables.TableVirtualKey, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRedactedVirtualKeys(ctx context.Context, ids []string) ([]tables.TableVirtualKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeyByValue(ctx context.Context, value string) (*tables.TableVirtualKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeyQuotaByValue(ctx context.Context, value string) (*tables.TableVirtualKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeyMCPConfigsByMCPClientID(ctx context.Context, mcpClientID uint) ([]tables.TableVirtualKeyMCPConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeyMCPConfigsByMCPClientIDs(ctx context.Context, mcpClientIDs []uint) ([]tables.TableVirtualKeyMCPConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVirtualKeyMCPConfigsByMCPClientStringIDs(ctx context.Context, clientIDs []string) ([]tables.TableVirtualKeyMCPConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Virtual key provider config
|
|
func (m *MockConfigStore) GetVirtualKeyProviderConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyProviderConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteVirtualKeyProviderConfig(ctx context.Context, id uint, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Virtual key MCP config
|
|
func (m *MockConfigStore) GetVirtualKeyMCPConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyMCPConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteVirtualKeyMCPConfig(ctx context.Context, id uint, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Auth config
|
|
func (m *MockConfigStore) GetAuthConfig(ctx context.Context) (*configstore.AuthConfig, error) {
|
|
return m.authConfig, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateAuthConfig(ctx context.Context, config *configstore.AuthConfig) error {
|
|
m.authConfig = config
|
|
return nil
|
|
}
|
|
|
|
// Framework config
|
|
func (m *MockConfigStore) UpdateFrameworkConfig(ctx context.Context, config *tables.TableFrameworkConfig) error {
|
|
m.frameworkConfig = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetFrameworkConfig(ctx context.Context) (*tables.TableFrameworkConfig, error) {
|
|
return m.frameworkConfig, nil
|
|
}
|
|
|
|
// Vector store config
|
|
func (m *MockConfigStore) UpdateVectorStoreConfig(ctx context.Context, config *vectorstore.Config) error {
|
|
m.vectorConfig = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetVectorStoreConfig(ctx context.Context) (*vectorstore.Config, error) {
|
|
return m.vectorConfig, nil
|
|
}
|
|
|
|
// Logs store config
|
|
func (m *MockConfigStore) UpdateLogsStoreConfig(ctx context.Context, config *logstore.Config) error {
|
|
m.logsConfig = config
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetLogsStoreConfig(ctx context.Context) (*logstore.Config, error) {
|
|
return m.logsConfig, nil
|
|
}
|
|
|
|
// Config
|
|
func (m *MockConfigStore) GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Plugins
|
|
func (m *MockConfigStore) GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) {
|
|
return m.plugins, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPlugin(ctx context.Context, name string) (*tables.TablePlugin, error) {
|
|
for _, p := range m.plugins {
|
|
if p.Name == name {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error {
|
|
m.plugins = append(m.plugins, plugin)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeletePlugin(ctx context.Context, name string, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Key management
|
|
func (m *MockConfigStore) GetKeysByIDs(ctx context.Context, ids []string) ([]tables.TableKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetAllRedactedKeys(ctx context.Context, ids []string) ([]schemas.Key, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateStatus(ctx context.Context, provider schemas.ModelProvider, keyID string, status, errorMsg string) error {
|
|
return nil
|
|
}
|
|
|
|
// Session
|
|
func (m *MockConfigStore) GetSession(ctx context.Context, token string) (*tables.SessionsTable, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateSession(ctx context.Context, session *tables.SessionsTable) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteSession(ctx context.Context, token string) error {
|
|
return nil
|
|
}
|
|
|
|
// Model pricing
|
|
func (m *MockConfigStore) GetModelPrices(ctx context.Context) ([]tables.TableModelPricing, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpsertModelPrices(ctx context.Context, pricing *tables.TableModelPricing, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteModelPrices(ctx context.Context, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPricingOverrides(ctx context.Context, filter configstore.PricingOverrideFilters) ([]tables.TablePricingOverride, error) {
|
|
return []tables.TablePricingOverride{}, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPricingOverridesPaginated(ctx context.Context, params configstore.PricingOverridesQueryParams) ([]tables.TablePricingOverride, int64, error) {
|
|
return []tables.TablePricingOverride{}, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPricingOverrideByID(ctx context.Context, id string) (*tables.TablePricingOverride, error) {
|
|
return nil, configstore.ErrNotFound
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePricingOverride(ctx context.Context, override *tables.TablePricingOverride, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePricingOverride(ctx context.Context, override *tables.TablePricingOverride, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeletePricingOverride(ctx context.Context, id string, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Model parameters
|
|
|
|
func (m *MockConfigStore) GetModelParameters(ctx context.Context) ([]tables.TableModelParameters, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetModelParametersByModel(ctx context.Context, model string) (*tables.TableModelParameters, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpsertModelParameters(ctx context.Context, params *tables.TableModelParameters, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Provider methods
|
|
func (m *MockConfigStore) GetProvider(ctx context.Context, provider schemas.ModelProvider) (*tables.TableProvider, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetProviders(ctx context.Context) ([]tables.TableProvider, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetProviderConfig(ctx context.Context, provider schemas.ModelProvider) (*configstore.ProviderConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Proxy config
|
|
func (m *MockConfigStore) GetProxyConfig(ctx context.Context) (*tables.GlobalProxyConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateProxyConfig(ctx context.Context, config *tables.GlobalProxyConfig) error {
|
|
return nil
|
|
}
|
|
|
|
// Restart required config
|
|
func (m *MockConfigStore) GetRestartRequiredConfig(ctx context.Context) (*tables.RestartRequiredConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) SetRestartRequiredConfig(ctx context.Context, config *tables.RestartRequiredConfig) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) ClearRestartRequiredConfig(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Model config
|
|
func (m *MockConfigStore) GetModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetModelConfigsPaginated(ctx context.Context, params configstore.ModelConfigsQueryParams) ([]tables.TableModelConfig, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetModelConfigByID(ctx context.Context, id string) (*tables.TableModelConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateModelConfigs(ctx context.Context, modelConfigs []*tables.TableModelConfig, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteModelConfig(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
// Budget/Rate limit usage
|
|
func (m *MockConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error {
|
|
return nil
|
|
}
|
|
|
|
// Distributed locks
|
|
func (m *MockConfigStore) TryAcquireLock(ctx context.Context, lock *tables.TableDistributedLock) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetLock(ctx context.Context, lockKey string) (*tables.TableDistributedLock, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateLockExpiry(ctx context.Context, lockKey, holderID string, expiresAt time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) ReleaseLock(ctx context.Context, lockKey, holderID string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CleanupExpiredLocks(ctx context.Context) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CleanupExpiredLockByKey(ctx context.Context, lockKey string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
// Key management
|
|
func (m *MockConfigStore) GetKeysByProvider(ctx context.Context, provider string) ([]tables.TableKey, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Sessions
|
|
func (m *MockConfigStore) FlushSessions(ctx context.Context) error {
|
|
m.flushSessionsCalled = true
|
|
return nil
|
|
}
|
|
|
|
// Plugins
|
|
func (m *MockConfigStore) UpsertPlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// OAuth config
|
|
func (m *MockConfigStore) GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthConfigByState(ctx context.Context, state string) (*tables.TableOauthConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthConfigByTokenID(ctx context.Context, tokenID string) (*tables.TableOauthConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error {
|
|
return nil
|
|
}
|
|
|
|
// OAuth token
|
|
func (m *MockConfigStore) GetOauthTokenByID(ctx context.Context, id string) (*tables.TableOauthToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetExpiringOauthTokens(ctx context.Context, before time.Time) ([]*tables.TableOauthToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateOauthToken(ctx context.Context, token *tables.TableOauthToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateOauthToken(ctx context.Context, token *tables.TableOauthToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteOauthToken(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
// Per-user OAuth session CRUD
|
|
func (m *MockConfigStore) GetOauthUserSessionByID(ctx context.Context, id string) (*tables.TableOauthUserSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthUserSessionByState(ctx context.Context, state string) (*tables.TableOauthUserSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) ClaimOauthUserSessionByState(ctx context.Context, state string) (*tables.TableOauthUserSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthUserSessionBySessionToken(ctx context.Context, sessionToken string) (*tables.TableOauthUserSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateOauthUserSession(ctx context.Context, session *tables.TableOauthUserSession) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateOauthUserSession(ctx context.Context, session *tables.TableOauthUserSession) error {
|
|
return nil
|
|
}
|
|
|
|
// Per-user OAuth token CRUD
|
|
func (m *MockConfigStore) GetOauthUserTokenByIdentity(ctx context.Context, virtualKeyID, userID, sessionToken, mcpClientID string) (*tables.TableOauthUserToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthUserTokenBySessionToken(ctx context.Context, sessionToken string) (*tables.TableOauthUserToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateOauthUserToken(ctx context.Context, token *tables.TableOauthUserToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateOauthUserToken(ctx context.Context, token *tables.TableOauthUserToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteOauthUserToken(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteOauthUserTokensByMCPClient(ctx context.Context, mcpClientID string) error {
|
|
return nil
|
|
}
|
|
|
|
// Per-user OAuth Authorization Server CRUD
|
|
func (m *MockConfigStore) GetPerUserOAuthClientByClientID(ctx context.Context, clientID string) (*tables.TablePerUserOAuthClient, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePerUserOAuthClient(ctx context.Context, client *tables.TablePerUserOAuthClient) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPerUserOAuthSessionByAccessToken(ctx context.Context, accessToken string) (*tables.TablePerUserOAuthSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPerUserOAuthSessionByID(ctx context.Context, id string) (*tables.TablePerUserOAuthSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePerUserOAuthSession(ctx context.Context, session *tables.TablePerUserOAuthSession) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePerUserOAuthSession(ctx context.Context, session *tables.TablePerUserOAuthSession) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeletePerUserOAuthSession(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPerUserOAuthCodeByCode(ctx context.Context, code string) (*tables.TablePerUserOAuthCode, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) ClaimPerUserOAuthCode(ctx context.Context, code string) (*tables.TablePerUserOAuthCode, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePerUserOAuthCode(ctx context.Context, code *tables.TablePerUserOAuthCode) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePerUserOAuthCode(ctx context.Context, code *tables.TablePerUserOAuthCode) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPerUserOAuthPendingFlow(ctx context.Context, id string) (*tables.TablePerUserOAuthPendingFlow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePerUserOAuthPendingFlow(ctx context.Context, flow *tables.TablePerUserOAuthPendingFlow) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePerUserOAuthPendingFlow(ctx context.Context, flow *tables.TablePerUserOAuthPendingFlow) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeletePerUserOAuthPendingFlow(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) ConsumePerUserOAuthPendingFlow(ctx context.Context, id string) (int64, error) {
|
|
return 1, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetOauthUserTokensByGatewaySessionID(ctx context.Context, gatewaySessionID string) ([]tables.TableOauthUserToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) TransferOauthUserTokensFromGatewaySession(ctx context.Context, gatewaySessionID, realSessionToken, virtualKeyID, userID string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) FinalizePerUserOAuthConsent(ctx context.Context, flowID string, session *tables.TablePerUserOAuthSession, code *tables.TablePerUserOAuthCode) (int64, error) {
|
|
return 1, nil
|
|
}
|
|
|
|
// Routing rules
|
|
func (m *MockConfigStore) GetRoutingRules(ctx context.Context) ([]tables.TableRoutingRule, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRoutingRulesByScope(ctx context.Context, scope string, scopeID string) ([]tables.TableRoutingRule, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRoutingRule(ctx context.Context, id string) (*tables.TableRoutingRule, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRedactedRoutingRules(ctx context.Context, ids []string) ([]tables.TableRoutingRule, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetRoutingRulesPaginated(ctx context.Context, params configstore.RoutingRulesQueryParams) ([]tables.TableRoutingRule, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateRoutingRule(ctx context.Context, rule *tables.TableRoutingRule, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateRoutingRule(ctx context.Context, rule *tables.TableRoutingRule, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) DeleteRoutingRule(ctx context.Context, id string, tx ...*gorm.DB) error {
|
|
return nil
|
|
}
|
|
|
|
// Prompt Repository - Folders
|
|
func (m *MockConfigStore) GetFolders(ctx context.Context) ([]tables.TableFolder, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetFolderByID(ctx context.Context, id string) (*tables.TableFolder, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreateFolder(ctx context.Context, folder *tables.TableFolder) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdateFolder(ctx context.Context, folder *tables.TableFolder) error {
|
|
return nil
|
|
}
|
|
func (m *MockConfigStore) DeleteFolder(ctx context.Context, id string) error { return nil }
|
|
|
|
// Prompt Repository - Prompts
|
|
func (m *MockConfigStore) GetPrompts(ctx context.Context, folderID *string) ([]tables.TablePrompt, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPromptByID(ctx context.Context, id string) (*tables.TablePrompt, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePrompt(ctx context.Context, prompt *tables.TablePrompt) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePrompt(ctx context.Context, prompt *tables.TablePrompt) error {
|
|
return nil
|
|
}
|
|
func (m *MockConfigStore) DeletePrompt(ctx context.Context, id string) error { return nil }
|
|
|
|
// Prompt Repository - Versions
|
|
func (m *MockConfigStore) GetPromptVersions(ctx context.Context, promptID string) ([]tables.TablePromptVersion, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetAllPromptVersions(ctx context.Context) ([]tables.TablePromptVersion, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPromptVersionByID(ctx context.Context, id uint) (*tables.TablePromptVersion, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetLatestPromptVersion(ctx context.Context, promptID string) (*tables.TablePromptVersion, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePromptVersion(ctx context.Context, version *tables.TablePromptVersion) error {
|
|
return nil
|
|
}
|
|
func (m *MockConfigStore) DeletePromptVersion(ctx context.Context, id uint) error { return nil }
|
|
|
|
// Prompt Repository - Sessions
|
|
func (m *MockConfigStore) GetPromptSessions(ctx context.Context, promptID string) ([]tables.TablePromptSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) GetPromptSessionByID(ctx context.Context, id uint) (*tables.TablePromptSession, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConfigStore) CreatePromptSession(ctx context.Context, session *tables.TablePromptSession) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) UpdatePromptSession(ctx context.Context, session *tables.TablePromptSession) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MockConfigStore) RenamePromptSession(ctx context.Context, id uint, name string) error {
|
|
return nil
|
|
}
|
|
func (m *MockConfigStore) DeletePromptSession(ctx context.Context, id uint) error { return nil }
|
|
|
|
// Helper functions for tests
|
|
|
|
// createTempDir creates a temporary directory for test files
|
|
func createTempDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "bifrost-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
os.RemoveAll(dir)
|
|
})
|
|
return dir
|
|
}
|
|
|
|
// createConfigFile creates a config.json file with the given data
|
|
func createConfigFile(t *testing.T, dir string, data *ConfigData) {
|
|
t.Helper()
|
|
configPath := filepath.Join(dir, "config.json")
|
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal config data: %v", err)
|
|
}
|
|
if err := os.WriteFile(configPath, jsonData, 0644); err != nil {
|
|
t.Fatalf("failed to write config file: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test fixtures
|
|
|
|
func makeClientConfig(initialPoolSize int, enableLogging bool) *configstore.ClientConfig {
|
|
return &configstore.ClientConfig{
|
|
InitialPoolSize: initialPoolSize,
|
|
EnableLogging: schemas.Ptr(enableLogging),
|
|
MaxRequestBodySizeMB: 10,
|
|
PrometheusLabels: []string{"label1"},
|
|
AllowedOrigins: []string{"http://localhost:3000"},
|
|
}
|
|
}
|
|
|
|
func makeProviderConfig(keyName, keyValue string) configstore.ProviderConfig {
|
|
return configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: uuid.NewString(),
|
|
Name: keyName,
|
|
Value: *schemas.NewEnvVar(keyValue),
|
|
Weight: 1,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func makeMCPClientConfig(id, name string) schemas.MCPClientConfig {
|
|
return schemas.MCPClientConfig{
|
|
ID: id,
|
|
Name: name,
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Test Helpers
|
|
// =============================================================================
|
|
|
|
// testLogger is a minimal logger implementation for testing
|
|
type testLogger struct{}
|
|
|
|
func (l *testLogger) Debug(msg string, args ...any) {}
|
|
func (l *testLogger) Info(msg string, args ...any) {}
|
|
func (l *testLogger) Warn(msg string, args ...any) {}
|
|
func (l *testLogger) Error(msg string, args ...any) {}
|
|
func (l *testLogger) Fatal(msg string, args ...any) {}
|
|
func (l *testLogger) SetLevel(level schemas.LogLevel) {}
|
|
func (l *testLogger) SetOutputType(outputType schemas.LoggerOutputType) {}
|
|
func (l *testLogger) LogHTTPRequest(level schemas.LogLevel, msg string) schemas.LogEventBuilder {
|
|
return schemas.NoopLogEvent
|
|
}
|
|
|
|
// initTestLogger initializes the global logger for SQLite integration tests
|
|
func initTestLogger() {
|
|
SetLogger(&testLogger{})
|
|
}
|
|
|
|
// createTestSQLiteConfigStore creates a real SQLite-backed config store for integration tests.
|
|
// It initializes all tables and runs migrations automatically.
|
|
func createTestSQLiteConfigStore(t *testing.T, dir string) configstore.ConfigStore {
|
|
t.Helper()
|
|
dbPath := filepath.Join(dir, "test-config.db")
|
|
store, err := configstore.NewConfigStore(context.Background(), &configstore.Config{
|
|
Enabled: true,
|
|
Type: configstore.ConfigStoreTypeSQLite,
|
|
Config: &configstore.SQLiteConfig{
|
|
Path: dbPath,
|
|
},
|
|
}, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("failed to create SQLite config store: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if store != nil {
|
|
store.Close(context.Background())
|
|
}
|
|
})
|
|
return store
|
|
}
|
|
|
|
// makeConfigDataWithProviders creates a ConfigData with only providers configured
|
|
func makeConfigDataWithProviders(providers map[string]configstore.ProviderConfig) *ConfigData {
|
|
return makeConfigDataWithProvidersAndDir(providers, "")
|
|
}
|
|
|
|
// makeConfigDataWithProvidersAndDir creates a ConfigData with providers and a specific temp directory for the DB
|
|
func makeConfigDataWithProvidersAndDir(providers map[string]configstore.ProviderConfig, tempDir string) *ConfigData {
|
|
dbPath := filepath.Join(tempDir, "config.db")
|
|
return &ConfigData{
|
|
Client: &configstore.ClientConfig{
|
|
InitialPoolSize: 10,
|
|
EnableLogging: new(true),
|
|
MaxRequestBodySizeMB: 100,
|
|
AllowedOrigins: []string{"*"},
|
|
},
|
|
ConfigStoreConfig: &configstore.Config{
|
|
Enabled: true,
|
|
Type: configstore.ConfigStoreTypeSQLite,
|
|
Config: &configstore.SQLiteConfig{
|
|
Path: dbPath,
|
|
},
|
|
},
|
|
Providers: providers,
|
|
}
|
|
}
|
|
|
|
// makeConfigDataWithVirtualKeys creates a ConfigData with providers and virtual keys
|
|
func makeConfigDataWithVirtualKeys(providers map[string]configstore.ProviderConfig, vks []tables.TableVirtualKey) *ConfigData {
|
|
return makeConfigDataWithVirtualKeysAndDir(providers, vks, "")
|
|
}
|
|
|
|
// makeConfigDataWithVirtualKeysAndDir creates a ConfigData with providers, virtual keys, and a specific temp directory
|
|
func makeConfigDataWithVirtualKeysAndDir(providers map[string]configstore.ProviderConfig, vks []tables.TableVirtualKey, tempDir string) *ConfigData {
|
|
dbPath := filepath.Join(tempDir, "config.db")
|
|
return &ConfigData{
|
|
Client: &configstore.ClientConfig{
|
|
InitialPoolSize: 10,
|
|
EnableLogging: new(true),
|
|
MaxRequestBodySizeMB: 100,
|
|
AllowedOrigins: []string{"*"},
|
|
},
|
|
ConfigStoreConfig: &configstore.Config{
|
|
Enabled: true,
|
|
Type: configstore.ConfigStoreTypeSQLite,
|
|
Config: &configstore.SQLiteConfig{
|
|
Path: dbPath,
|
|
},
|
|
},
|
|
Providers: providers,
|
|
Governance: &configstore.GovernanceConfig{
|
|
VirtualKeys: vks,
|
|
},
|
|
}
|
|
}
|
|
|
|
// makeConfigDataFull creates a full ConfigData with all configurations
|
|
func makeConfigDataFull(client *configstore.ClientConfig, providers map[string]configstore.ProviderConfig, governance *configstore.GovernanceConfig) *ConfigData {
|
|
return makeConfigDataFullWithDir(client, providers, governance, "")
|
|
}
|
|
|
|
// makeConfigDataFullWithDir creates a full ConfigData with all configurations and a specific temp directory
|
|
func makeConfigDataFullWithDir(client *configstore.ClientConfig, providers map[string]configstore.ProviderConfig, governance *configstore.GovernanceConfig, tempDir string) *ConfigData {
|
|
if client == nil {
|
|
client = &configstore.ClientConfig{
|
|
InitialPoolSize: 10,
|
|
EnableLogging: new(true),
|
|
MaxRequestBodySizeMB: 100,
|
|
AllowedOrigins: []string{"*"},
|
|
}
|
|
}
|
|
dbPath := filepath.Join(tempDir, "config.db")
|
|
return &ConfigData{
|
|
Client: client,
|
|
ConfigStoreConfig: &configstore.Config{
|
|
Enabled: true,
|
|
Type: configstore.ConfigStoreTypeSQLite,
|
|
Config: &configstore.SQLiteConfig{
|
|
Path: dbPath,
|
|
},
|
|
},
|
|
Providers: providers,
|
|
Governance: governance,
|
|
}
|
|
}
|
|
|
|
// makeProviderConfigWithNetwork creates a provider config with network settings
|
|
func makeProviderConfigWithNetwork(keyName, keyValue, baseURL string) configstore.ProviderConfig {
|
|
return configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: uuid.NewString(),
|
|
Name: keyName,
|
|
Value: *schemas.NewEnvVar(keyValue),
|
|
Weight: 1,
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: baseURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
// makeProviderConfigWithMultipleKeys creates a provider config with multiple keys
|
|
func makeProviderConfigWithMultipleKeys(keys []schemas.Key, baseURL string) configstore.ProviderConfig {
|
|
return configstore.ProviderConfig{
|
|
Keys: keys,
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: baseURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
// makeVirtualKey creates a virtual key for testing
|
|
func makeVirtualKey(id, name, value string) tables.TableVirtualKey {
|
|
return tables.TableVirtualKey{
|
|
ID: id,
|
|
Name: name,
|
|
Description: "Test virtual key",
|
|
Value: value,
|
|
IsActive: true,
|
|
}
|
|
}
|
|
|
|
// makeVirtualKeyWithTeam creates a virtual key with team association
|
|
func makeVirtualKeyWithTeam(id, name, value, teamID string) tables.TableVirtualKey {
|
|
return tables.TableVirtualKey{
|
|
ID: id,
|
|
Name: name,
|
|
Description: "Test virtual key with team",
|
|
Value: value,
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
}
|
|
|
|
// makeVirtualKeyWithCustomer creates a virtual key with customer association
|
|
func makeVirtualKeyWithCustomer(id, name, value, customerID string) tables.TableVirtualKey {
|
|
return tables.TableVirtualKey{
|
|
ID: id,
|
|
Name: name,
|
|
Description: "Test virtual key with customer",
|
|
Value: value,
|
|
IsActive: true,
|
|
CustomerID: &customerID,
|
|
}
|
|
}
|
|
|
|
// makeVirtualKeyWithProviderConfigs creates a virtual key with provider configurations
|
|
func makeVirtualKeyWithProviderConfigs(id, name, value string, providerConfigs []tables.TableVirtualKeyProviderConfig) tables.TableVirtualKey {
|
|
return tables.TableVirtualKey{
|
|
ID: id,
|
|
Name: name,
|
|
Description: "Test virtual key with provider configs",
|
|
Value: value,
|
|
IsActive: true,
|
|
ProviderConfigs: providerConfigs,
|
|
}
|
|
}
|
|
|
|
// makeVirtualKeyProviderConfig creates a provider config for virtual keys
|
|
func makeVirtualKeyProviderConfig(provider string, weight float64, allowedModels []string, keys []tables.TableKey) tables.TableVirtualKeyProviderConfig {
|
|
return tables.TableVirtualKeyProviderConfig{
|
|
Provider: provider,
|
|
Weight: &weight,
|
|
AllowedModels: allowedModels,
|
|
Keys: keys,
|
|
}
|
|
}
|
|
|
|
// makeTableKey creates a TableKey for use in virtual key provider configs
|
|
func makeTableKey(keyID, name, value, provider string) tables.TableKey {
|
|
defaultWeight := 1.0
|
|
return tables.TableKey{
|
|
KeyID: keyID,
|
|
Name: name,
|
|
Value: *schemas.NewEnvVar(value),
|
|
Provider: provider,
|
|
Weight: &defaultWeight,
|
|
}
|
|
}
|
|
|
|
// verifyProviderInDB checks that a provider exists in the database with expected config
|
|
func verifyProviderInDB(t *testing.T, store configstore.ConfigStore, provider schemas.ModelProvider, expectedKeyCount int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
providers, err := store.GetProvidersConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get providers from DB: %v", err)
|
|
}
|
|
cfg, exists := providers[provider]
|
|
if !exists {
|
|
t.Fatalf("provider %s not found in DB", provider)
|
|
}
|
|
if len(cfg.Keys) != expectedKeyCount {
|
|
t.Errorf("expected %d keys for provider %s, got %d", expectedKeyCount, provider, len(cfg.Keys))
|
|
}
|
|
}
|
|
|
|
// verifyVirtualKeyInDB checks that a virtual key exists in the database
|
|
func verifyVirtualKeyInDB(t *testing.T, store configstore.ConfigStore, vkID string) *tables.TableVirtualKey {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
vk, err := store.GetVirtualKey(ctx, vkID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get virtual key %s from DB: %v", vkID, err)
|
|
}
|
|
if vk == nil {
|
|
t.Fatalf("virtual key %s not found in DB", vkID)
|
|
}
|
|
return vk
|
|
}
|
|
|
|
// verifyVirtualKeyNotInDB checks that a virtual key does NOT exist in the database
|
|
func verifyVirtualKeyNotInDB(t *testing.T, store configstore.ConfigStore, vkID string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
vk, err := store.GetVirtualKey(ctx, vkID)
|
|
if err == nil && vk != nil {
|
|
t.Fatalf("virtual key %s should not exist in DB but was found", vkID)
|
|
}
|
|
}
|
|
|
|
// Tests
|
|
|
|
// TestLoadConfig_ClientConfig_Merge tests client config merge from DB and file
|
|
func TestLoadConfig_ClientConfig_Merge(t *testing.T) {
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config file with client config
|
|
fileClientConfig := &configstore.ClientConfig{
|
|
InitialPoolSize: 20,
|
|
EnableLogging: new(true),
|
|
PrometheusLabels: []string{"file-label"},
|
|
AllowedOrigins: []string{"http://file-origin.com"},
|
|
MaxRequestBodySizeMB: 15,
|
|
DisableContentLogging: true,
|
|
}
|
|
|
|
configData := &ConfigData{
|
|
Client: fileClientConfig,
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Setup mock config store with existing client config
|
|
mockStore := NewMockConfigStore()
|
|
mockStore.clientConfig = &configstore.ClientConfig{
|
|
InitialPoolSize: 10,
|
|
EnableLogging: new(false),
|
|
PrometheusLabels: []string{"db-label"},
|
|
MaxRequestBodySizeMB: 5,
|
|
// AllowedOrigins is empty in DB
|
|
}
|
|
|
|
// Override the config store creation to use our mock
|
|
originalConfigStore := mockStore
|
|
|
|
// Load config (we need to test the merge logic manually since LoadConfig creates its own store)
|
|
// For now, let's test the merge logic by simulating what happens
|
|
|
|
// Simulate merge: DB takes priority, file fills in empty values
|
|
mergedConfig := *mockStore.clientConfig
|
|
|
|
// InitialPoolSize: DB has 10, file has 20 -> keep DB (10)
|
|
if mergedConfig.InitialPoolSize == 0 && fileClientConfig.InitialPoolSize != 0 {
|
|
mergedConfig.InitialPoolSize = fileClientConfig.InitialPoolSize
|
|
}
|
|
|
|
// PrometheusLabels: DB has value, file has value -> keep DB
|
|
if len(mergedConfig.PrometheusLabels) == 0 && len(fileClientConfig.PrometheusLabels) > 0 {
|
|
mergedConfig.PrometheusLabels = fileClientConfig.PrometheusLabels
|
|
}
|
|
|
|
// AllowedOrigins: DB empty, file has value -> use file
|
|
if len(mergedConfig.AllowedOrigins) == 0 && len(fileClientConfig.AllowedOrigins) > 0 {
|
|
mergedConfig.AllowedOrigins = fileClientConfig.AllowedOrigins
|
|
}
|
|
|
|
// MaxRequestBodySizeMB: DB has 5, file has 15 -> keep DB (5)
|
|
if mergedConfig.MaxRequestBodySizeMB == 0 && fileClientConfig.MaxRequestBodySizeMB != 0 {
|
|
mergedConfig.MaxRequestBodySizeMB = fileClientConfig.MaxRequestBodySizeMB
|
|
}
|
|
|
|
// Boolean fields: file true overrides DB false
|
|
mergedLogging := mergedConfig.EnableLogging == nil || *mergedConfig.EnableLogging
|
|
fileLogging := fileClientConfig.EnableLogging != nil && *fileClientConfig.EnableLogging
|
|
if !mergedLogging && fileLogging {
|
|
mergedConfig.EnableLogging = fileClientConfig.EnableLogging
|
|
}
|
|
if !mergedConfig.DisableContentLogging && fileClientConfig.DisableContentLogging {
|
|
mergedConfig.DisableContentLogging = fileClientConfig.DisableContentLogging
|
|
}
|
|
|
|
// Verify merge results
|
|
if mergedConfig.InitialPoolSize != 10 {
|
|
t.Errorf("Expected InitialPoolSize to be 10 (from DB), got %d", mergedConfig.InitialPoolSize)
|
|
}
|
|
|
|
if len(mergedConfig.PrometheusLabels) != 1 || mergedConfig.PrometheusLabels[0] != "db-label" {
|
|
t.Errorf("Expected PrometheusLabels to be [db-label] (from DB), got %v", mergedConfig.PrometheusLabels)
|
|
}
|
|
|
|
if len(mergedConfig.AllowedOrigins) != 1 || mergedConfig.AllowedOrigins[0] != "http://file-origin.com" {
|
|
t.Errorf("Expected AllowedOrigins to be [http://file-origin.com] (from file), got %v", mergedConfig.AllowedOrigins)
|
|
}
|
|
|
|
if mergedConfig.MaxRequestBodySizeMB != 5 {
|
|
t.Errorf("Expected MaxRequestBodySizeMB to be 5 (from DB), got %d", mergedConfig.MaxRequestBodySizeMB)
|
|
}
|
|
|
|
if mergedConfig.EnableLogging == nil || !*mergedConfig.EnableLogging {
|
|
t.Error("Expected EnableLogging to be true (file true overrides DB false)")
|
|
}
|
|
|
|
if !mergedConfig.DisableContentLogging {
|
|
t.Error("Expected DisableContentLogging to be true (file true overrides DB false)")
|
|
}
|
|
|
|
_ = originalConfigStore
|
|
}
|
|
|
|
// TestLoadConfig_Providers_Merge tests provider keys merge from DB and file
|
|
func TestLoadConfig_Providers_Merge(t *testing.T) {
|
|
// Setup DB providers
|
|
dbProviders := make(map[schemas.ModelProvider]configstore.ProviderConfig)
|
|
dbProviders[schemas.OpenAI] = configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "key-1",
|
|
Name: "openai-db-key-1",
|
|
Value: *schemas.NewEnvVar("sk-db-123"),
|
|
Weight: 1,
|
|
},
|
|
{
|
|
ID: "key-2",
|
|
Name: "openai-db-key-2",
|
|
Value: *schemas.NewEnvVar("sk-db-456"),
|
|
Weight: 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Setup file providers with some overlapping and some new keys
|
|
fileProviders := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "key-1", // Same ID as DB - should be skipped
|
|
Name: "openai-db-key-1",
|
|
Value: *schemas.NewEnvVar("sk-different"),
|
|
Weight: 1,
|
|
},
|
|
{
|
|
ID: "key-3", // New key
|
|
Name: "openai-file-key-3",
|
|
Value: *schemas.NewEnvVar("sk-file-789"),
|
|
Weight: 1,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Simulate merge logic
|
|
for providerName, fileCfg := range fileProviders {
|
|
provider := schemas.ModelProvider(providerName)
|
|
if existingCfg, exists := dbProviders[provider]; exists {
|
|
// Merge keys
|
|
keysToAdd := make([]schemas.Key, 0)
|
|
for _, newKey := range fileCfg.Keys {
|
|
found := false
|
|
for _, existingKey := range existingCfg.Keys {
|
|
if existingKey.Name == newKey.Name || existingKey.ID == newKey.ID || existingKey.Value == newKey.Value {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
keysToAdd = append(keysToAdd, newKey)
|
|
}
|
|
}
|
|
existingCfg.Keys = append(existingCfg.Keys, keysToAdd...)
|
|
dbProviders[provider] = existingCfg
|
|
}
|
|
}
|
|
|
|
// Verify merge results
|
|
openaiCfg := dbProviders[schemas.OpenAI]
|
|
if len(openaiCfg.Keys) != 3 {
|
|
t.Errorf("Expected 3 keys after merge (2 from DB + 1 new from file), got %d", len(openaiCfg.Keys))
|
|
}
|
|
|
|
// Verify the keys
|
|
keyNames := make(map[string]bool)
|
|
for _, key := range openaiCfg.Keys {
|
|
keyNames[key.Name] = true
|
|
}
|
|
|
|
if !keyNames["openai-db-key-1"] {
|
|
t.Error("Expected openai-db-key-1 to be present")
|
|
}
|
|
if !keyNames["openai-db-key-2"] {
|
|
t.Error("Expected openai-db-key-2 to be present")
|
|
}
|
|
if !keyNames["openai-file-key-3"] {
|
|
t.Error("Expected openai-file-key-3 to be present (new from file)")
|
|
}
|
|
}
|
|
|
|
// TestLoadConfig_MCP_Merge tests MCP config merge from DB and file
|
|
func TestLoadConfig_MCP_Merge(t *testing.T) {
|
|
// Setup DB MCP config
|
|
dbMCPConfig := &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{
|
|
ID: "mcp-1",
|
|
Name: "db-client-1",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
},
|
|
{
|
|
ID: "mcp-2",
|
|
Name: "db-client-2",
|
|
ConnectionType: schemas.MCPConnectionTypeSTDIO,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Setup file MCP config with some overlapping and some new
|
|
fileMCPConfig := &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{
|
|
ID: "mcp-1", // Same ID - should be skipped
|
|
Name: "different-name",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
},
|
|
{
|
|
ID: "mcp-3", // New ID
|
|
Name: "file-client-3",
|
|
ConnectionType: schemas.MCPConnectionTypeSSE,
|
|
},
|
|
{
|
|
ID: "mcp-4", // New
|
|
Name: "db-client-2", // Same name as existing - should be skipped
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Simulate merge logic
|
|
clientConfigsToAdd := make([]*schemas.MCPClientConfig, 0)
|
|
for _, newClientConfig := range fileMCPConfig.ClientConfigs {
|
|
found := false
|
|
for _, existingClientConfig := range dbMCPConfig.ClientConfigs {
|
|
if (newClientConfig.ID != "" && existingClientConfig.ID == newClientConfig.ID) ||
|
|
(newClientConfig.Name != "" && existingClientConfig.Name == newClientConfig.Name) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
clientConfigsToAdd = append(clientConfigsToAdd, newClientConfig)
|
|
}
|
|
}
|
|
|
|
mergedMCPConfig := &schemas.MCPConfig{
|
|
ClientConfigs: append(dbMCPConfig.ClientConfigs, clientConfigsToAdd...),
|
|
}
|
|
|
|
// Verify merge results
|
|
if len(mergedMCPConfig.ClientConfigs) != 3 {
|
|
t.Errorf("Expected 3 client configs after merge (2 from DB + 1 new from file), got %d", len(mergedMCPConfig.ClientConfigs))
|
|
}
|
|
|
|
// Verify the client configs
|
|
ids := make(map[string]bool)
|
|
names := make(map[string]bool)
|
|
for _, cc := range mergedMCPConfig.ClientConfigs {
|
|
ids[cc.ID] = true
|
|
names[cc.Name] = true
|
|
}
|
|
|
|
if !ids["mcp-1"] {
|
|
t.Error("Expected mcp-1 to be present")
|
|
}
|
|
if !ids["mcp-2"] {
|
|
t.Error("Expected mcp-2 to be present")
|
|
}
|
|
if !ids["mcp-3"] {
|
|
t.Error("Expected mcp-3 to be present (new from file)")
|
|
}
|
|
if ids["mcp-4"] {
|
|
t.Error("Expected mcp-4 to be skipped (same name as existing)")
|
|
}
|
|
}
|
|
|
|
// TestLoadConfig_Governance_Merge tests governance config merge from DB and file
|
|
func TestLoadConfig_Governance_Merge(t *testing.T) {
|
|
// Setup DB governance config
|
|
dbGovernanceConfig := &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1"},
|
|
{ID: "budget-2"},
|
|
},
|
|
RateLimits: []tables.TableRateLimit{
|
|
{ID: "ratelimit-1"},
|
|
},
|
|
Customers: []tables.TableCustomer{
|
|
{ID: "customer-1"},
|
|
},
|
|
Teams: []tables.TableTeam{
|
|
{ID: "team-1"},
|
|
},
|
|
VirtualKeys: []tables.TableVirtualKey{
|
|
{ID: "vkey-1"},
|
|
},
|
|
}
|
|
|
|
// Setup file governance config with some overlapping and some new
|
|
fileGovernanceConfig := &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1"}, // Duplicate
|
|
{ID: "budget-3"}, // New
|
|
},
|
|
RateLimits: []tables.TableRateLimit{
|
|
{ID: "ratelimit-2"}, // New
|
|
},
|
|
Customers: []tables.TableCustomer{
|
|
{ID: "customer-1"}, // Duplicate
|
|
{ID: "customer-2"}, // New
|
|
},
|
|
Teams: []tables.TableTeam{
|
|
{ID: "team-2"}, // New
|
|
},
|
|
VirtualKeys: []tables.TableVirtualKey{
|
|
{ID: "vkey-1"}, // Duplicate
|
|
{ID: "vkey-2"}, // New
|
|
},
|
|
}
|
|
|
|
// Simulate merge logic for Budgets
|
|
budgetsToAdd := make([]tables.TableBudget, 0)
|
|
for _, newBudget := range fileGovernanceConfig.Budgets {
|
|
found := false
|
|
for _, existingBudget := range dbGovernanceConfig.Budgets {
|
|
if existingBudget.ID == newBudget.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
budgetsToAdd = append(budgetsToAdd, newBudget)
|
|
}
|
|
}
|
|
mergedBudgets := append(dbGovernanceConfig.Budgets, budgetsToAdd...)
|
|
|
|
// Simulate merge logic for RateLimits
|
|
rateLimitsToAdd := make([]tables.TableRateLimit, 0)
|
|
for _, newRateLimit := range fileGovernanceConfig.RateLimits {
|
|
found := false
|
|
for _, existingRateLimit := range dbGovernanceConfig.RateLimits {
|
|
if existingRateLimit.ID == newRateLimit.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
rateLimitsToAdd = append(rateLimitsToAdd, newRateLimit)
|
|
}
|
|
}
|
|
mergedRateLimits := append(dbGovernanceConfig.RateLimits, rateLimitsToAdd...)
|
|
|
|
// Simulate merge logic for Customers
|
|
customersToAdd := make([]tables.TableCustomer, 0)
|
|
for _, newCustomer := range fileGovernanceConfig.Customers {
|
|
found := false
|
|
for _, existingCustomer := range dbGovernanceConfig.Customers {
|
|
if existingCustomer.ID == newCustomer.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
customersToAdd = append(customersToAdd, newCustomer)
|
|
}
|
|
}
|
|
mergedCustomers := append(dbGovernanceConfig.Customers, customersToAdd...)
|
|
|
|
// Simulate merge logic for Teams
|
|
teamsToAdd := make([]tables.TableTeam, 0)
|
|
for _, newTeam := range fileGovernanceConfig.Teams {
|
|
found := false
|
|
for _, existingTeam := range dbGovernanceConfig.Teams {
|
|
if existingTeam.ID == newTeam.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
teamsToAdd = append(teamsToAdd, newTeam)
|
|
}
|
|
}
|
|
mergedTeams := append(dbGovernanceConfig.Teams, teamsToAdd...)
|
|
|
|
// Simulate merge logic for VirtualKeys
|
|
virtualKeysToAdd := make([]tables.TableVirtualKey, 0)
|
|
for _, newVirtualKey := range fileGovernanceConfig.VirtualKeys {
|
|
found := false
|
|
for _, existingVirtualKey := range dbGovernanceConfig.VirtualKeys {
|
|
if existingVirtualKey.ID == newVirtualKey.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
virtualKeysToAdd = append(virtualKeysToAdd, newVirtualKey)
|
|
}
|
|
}
|
|
mergedVirtualKeys := append(dbGovernanceConfig.VirtualKeys, virtualKeysToAdd...)
|
|
|
|
// Verify merge results
|
|
if len(mergedBudgets) != 3 {
|
|
t.Errorf("Expected 3 budgets after merge (2 from DB + 1 new), got %d", len(mergedBudgets))
|
|
}
|
|
|
|
if len(mergedRateLimits) != 2 {
|
|
t.Errorf("Expected 2 rate limits after merge (1 from DB + 1 new), got %d", len(mergedRateLimits))
|
|
}
|
|
|
|
if len(mergedCustomers) != 2 {
|
|
t.Errorf("Expected 2 customers after merge (1 from DB + 1 new), got %d", len(mergedCustomers))
|
|
}
|
|
|
|
if len(mergedTeams) != 2 {
|
|
t.Errorf("Expected 2 teams after merge (1 from DB + 1 new), got %d", len(mergedTeams))
|
|
}
|
|
|
|
if len(mergedVirtualKeys) != 2 {
|
|
t.Errorf("Expected 2 virtual keys after merge (1 from DB + 1 new), got %d", len(mergedVirtualKeys))
|
|
}
|
|
|
|
// Verify specific IDs
|
|
budgetIDs := make(map[string]bool)
|
|
for _, b := range mergedBudgets {
|
|
budgetIDs[b.ID] = true
|
|
}
|
|
if !budgetIDs["budget-1"] || !budgetIDs["budget-2"] || !budgetIDs["budget-3"] {
|
|
t.Error("Expected budgets budget-1, budget-2, and budget-3")
|
|
}
|
|
}
|
|
|
|
// TestGenerateProviderConfigHash tests that provider config hash is generated correctly
|
|
func TestGenerateProviderConfigHash(t *testing.T) {
|
|
// Create a provider config
|
|
config1 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
// Generate hash
|
|
hash1, err := config1.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same config should produce same hash
|
|
config2 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "different-id", Name: "different-name", Value: *schemas.NewEnvVar("different-value"), Weight: 2}, // Keys should NOT affect hash
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hash2, err := config2.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 != hash2 {
|
|
t.Error("Expected same hash for configs with same fields (keys excluded)")
|
|
}
|
|
|
|
// Different config should produce different hash
|
|
config3 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://different-api.example.com", // Different base URL
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hash3, err := config3.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash for configs with different NetworkConfig")
|
|
}
|
|
|
|
// Different provider name should produce different hash
|
|
hash4, err := config1.GenerateConfigHash("anthropic")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash4 {
|
|
t.Error("Expected different hash for different provider names")
|
|
}
|
|
|
|
// Different SendBackRawResponse should produce different hash
|
|
config5 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: false, // Different SendBackRawResponse
|
|
}
|
|
|
|
hash5, err := config5.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash5 {
|
|
t.Error("Expected different hash for configs with different SendBackRawResponse")
|
|
}
|
|
|
|
// Different ConcurrencyAndBufferSize should produce different hash
|
|
config6 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
}
|
|
|
|
hash6, err := config6.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash6 {
|
|
t.Error("Expected different hash for configs with ConcurrencyAndBufferSize")
|
|
}
|
|
|
|
// Different ProxyConfig should produce different hash
|
|
config7 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: schemas.HTTPProxy,
|
|
URL: "http://proxy.example.com:8080",
|
|
},
|
|
}
|
|
|
|
hash7, err := config7.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash7 {
|
|
t.Error("Expected different hash for configs with ProxyConfig")
|
|
}
|
|
|
|
// Different CustomProviderConfig should produce different hash
|
|
config8 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "test-key", Value: *schemas.NewEnvVar("sk-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
CustomProviderConfig: &schemas.CustomProviderConfig{
|
|
IsKeyLess: false,
|
|
BaseProviderType: schemas.OpenAI,
|
|
},
|
|
}
|
|
|
|
hash8, err := config8.GenerateConfigHash("custom-provider")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
// config1 with custom-provider name for comparison
|
|
hash1Custom, _ := config1.GenerateConfigHash("custom-provider")
|
|
if hash1Custom == hash8 {
|
|
t.Error("Expected different hash for configs with CustomProviderConfig")
|
|
}
|
|
|
|
t.Log("✓ ProviderConfig hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestGenerateKeyHash tests that key hash is generated correctly
|
|
func TestGenerateKeyHash(t *testing.T) {
|
|
// Create a key
|
|
key1 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
// Generate hash
|
|
hash1, err := configstore.GenerateKeyHash(key1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same key content with different ID should produce same hash (ID is skipped)
|
|
key2 := schemas.Key{
|
|
ID: "different-id", // Different ID - should be skipped
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
hash2, err := configstore.GenerateKeyHash(key2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 != hash2 {
|
|
t.Error("Expected same hash for keys with same content (ID should be skipped)")
|
|
}
|
|
|
|
// Different Name should produce different hash
|
|
key2b := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "different-key-name", // Different name
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
hash2b, err := configstore.GenerateKeyHash(key2b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash2b {
|
|
t.Error("Expected different hash for keys with different Name")
|
|
}
|
|
|
|
// Different value should produce different hash
|
|
key3 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-different"), // Different value
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
hash3, err := configstore.GenerateKeyHash(key3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash for keys with different Value")
|
|
}
|
|
|
|
// Different models should produce different hash
|
|
key4 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4"}, // Different models
|
|
Weight: 1.5,
|
|
}
|
|
|
|
hash4, err := configstore.GenerateKeyHash(key4)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash4 {
|
|
t.Error("Expected different hash for keys with different Models")
|
|
}
|
|
|
|
// Different weight should produce different hash
|
|
key5 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 2.0, // Different weight
|
|
}
|
|
|
|
hash5, err := configstore.GenerateKeyHash(key5)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash5 {
|
|
t.Error("Expected different hash for keys with different Weight")
|
|
}
|
|
|
|
// AzureKeyConfig should produce different hash
|
|
apiVersion := "2024-10-21"
|
|
key6 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://my-azure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar(apiVersion),
|
|
},
|
|
}
|
|
|
|
hash6, err := configstore.GenerateKeyHash(key6)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash6 {
|
|
t.Error("Expected different hash for keys with AzureKeyConfig")
|
|
}
|
|
|
|
// Different AzureKeyConfig should produce different hash
|
|
key6b := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://different-azure.openai.azure.com"), // Different endpoint
|
|
APIVersion: schemas.NewEnvVar(apiVersion),
|
|
},
|
|
}
|
|
|
|
// Aliases alone should produce different hash
|
|
keyWithAliases := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
}
|
|
|
|
hashWithAliases, err := configstore.GenerateKeyHash(keyWithAliases)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hashWithAliases {
|
|
t.Error("Expected different hash for keys with Aliases")
|
|
}
|
|
|
|
hash6b, err := configstore.GenerateKeyHash(key6b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash6 == hash6b {
|
|
t.Error("Expected different hash for keys with different AzureKeyConfig endpoint")
|
|
}
|
|
|
|
// VertexKeyConfig should produce different hash
|
|
key7 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
ProjectNumber: *schemas.NewEnvVar("123456789"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar("service-account-json"),
|
|
},
|
|
}
|
|
|
|
hash7, err := configstore.GenerateKeyHash(key7)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash7 {
|
|
t.Error("Expected different hash for keys with VertexKeyConfig")
|
|
}
|
|
|
|
// Different VertexKeyConfig should produce different hash
|
|
key7b := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("different-project"), // Different project
|
|
ProjectNumber: *schemas.NewEnvVar("123456789"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar("service-account-json"),
|
|
},
|
|
}
|
|
|
|
hash7b, err := configstore.GenerateKeyHash(key7b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash7 == hash7b {
|
|
t.Error("Expected different hash for keys with different VertexKeyConfig project")
|
|
}
|
|
|
|
// BedrockKeyConfig should produce different hash
|
|
region := "us-east-1"
|
|
key8 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar(region),
|
|
},
|
|
}
|
|
|
|
hash8, err := configstore.GenerateKeyHash(key8)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash8 {
|
|
t.Error("Expected different hash for keys with BedrockKeyConfig")
|
|
}
|
|
|
|
// Different BedrockKeyConfig should produce different hash
|
|
differentRegion := "us-west-2"
|
|
key8b := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar(differentRegion), // Different region
|
|
},
|
|
}
|
|
|
|
hash8b, err := configstore.GenerateKeyHash(key8b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash8 == hash8b {
|
|
t.Error("Expected different hash for keys with different BedrockKeyConfig region")
|
|
}
|
|
|
|
t.Log("✓ Key hash generation works correctly for all fields including Azure, Vertex, and Bedrock configs")
|
|
}
|
|
|
|
// TestProviderHashComparison_MatchingHash tests that DB config is kept when hashes match
|
|
func TestProviderHashComparison_MatchingHash(t *testing.T) {
|
|
// Create a provider config (simulating what's in config.json)
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-file-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
// Generate hash for the file config
|
|
fileHash, err := fileConfig.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate file hash: %v", err)
|
|
}
|
|
|
|
// Create DB config with same hash (simulating unchanged config.json)
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-db-different"), Weight: 1}, // DB may have different key value (edited via dashboard)
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
ConfigHash: fileHash, // Same hash as file
|
|
}
|
|
|
|
// Simulate the hash comparison logic
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: dbConfig,
|
|
}
|
|
|
|
// When hash matches, we should keep DB config
|
|
existingCfg := providersInConfigStore[schemas.OpenAI]
|
|
if existingCfg.ConfigHash == fileHash {
|
|
// Hash matches - keep DB config
|
|
// This is the expected path
|
|
} else {
|
|
t.Error("Expected hash to match")
|
|
}
|
|
|
|
// Verify DB config is preserved (key value from DB, not file)
|
|
if existingCfg.Keys[0].Value != *schemas.NewEnvVar("sk-db-different") {
|
|
t.Errorf("Expected DB key value to be preserved, got %v", existingCfg.Keys[0].Value)
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_DifferentHash tests that file config is used when hashes differ
|
|
func TestProviderHashComparison_DifferentHash(t *testing.T) {
|
|
// Create a provider config (simulating what's in config.json - CHANGED)
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-file-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v2", // Changed URL
|
|
},
|
|
SendBackRawResponse: true, // Changed setting
|
|
}
|
|
|
|
// Generate hash for the file config
|
|
fileHash, err := fileConfig.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate file hash: %v", err)
|
|
}
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Create DB config with different hash (config.json was changed)
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-db-123"), Weight: 1},
|
|
{ID: "key-2", Name: "dashboard-added-key", Value: *schemas.NewEnvVar("sk-dashboard"), Weight: 1}, // Key added via dashboard
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com", // Old URL
|
|
},
|
|
SendBackRawResponse: false, // Old setting
|
|
ConfigHash: "old-different-hash",
|
|
}
|
|
|
|
// Simulate the hash comparison logic
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: dbConfig,
|
|
}
|
|
|
|
existingCfg := providersInConfigStore[schemas.OpenAI]
|
|
if existingCfg.ConfigHash != fileHash {
|
|
// Hash mismatch - sync from file, but preserve dashboard-added keys
|
|
mergedKeys := fileConfig.Keys
|
|
|
|
// Find keys in DB that aren't in file (added via dashboard)
|
|
for _, dbKey := range existingCfg.Keys {
|
|
found := false
|
|
for _, fileKey := range fileConfig.Keys {
|
|
dbKeyHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbKey.Name,
|
|
Value: dbKey.Value,
|
|
Models: dbKey.Models,
|
|
Weight: dbKey.Weight,
|
|
})
|
|
fileKeyHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
if dbKeyHash == fileKeyHash || fileKey.Name == dbKey.Name {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// Key exists in DB but not in file - preserve it
|
|
mergedKeys = append(mergedKeys, dbKey)
|
|
}
|
|
}
|
|
|
|
// Update the result
|
|
fileConfig.Keys = mergedKeys
|
|
providersInConfigStore[schemas.OpenAI] = fileConfig
|
|
} else {
|
|
t.Error("Expected hash mismatch")
|
|
}
|
|
|
|
// Verify file config is now used
|
|
resultConfig := providersInConfigStore[schemas.OpenAI]
|
|
|
|
if resultConfig.NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Errorf("Expected file BaseURL, got %s", resultConfig.NetworkConfig.BaseURL)
|
|
}
|
|
|
|
if !resultConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be true (from file)")
|
|
}
|
|
|
|
// Verify dashboard-added key is preserved
|
|
if len(resultConfig.Keys) != 2 {
|
|
t.Errorf("Expected 2 keys (1 from file + 1 dashboard-added), got %d", len(resultConfig.Keys))
|
|
}
|
|
|
|
hasFileKey := false
|
|
hasDashboardKey := false
|
|
for _, key := range resultConfig.Keys {
|
|
if key.Name == "openai-key" {
|
|
hasFileKey = true
|
|
}
|
|
if key.Name == "dashboard-added-key" {
|
|
hasDashboardKey = true
|
|
}
|
|
}
|
|
|
|
if !hasFileKey {
|
|
t.Error("Expected file key to be present")
|
|
}
|
|
if !hasDashboardKey {
|
|
t.Error("Expected dashboard-added key to be preserved")
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_ProviderOnlyInDB tests that provider added via dashboard is preserved
|
|
func TestProviderHashComparison_ProviderOnlyInDB(t *testing.T) {
|
|
// DB has a provider that was added via dashboard (not in config.json)
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "dashboard-provider-key", Value: *schemas.NewEnvVar("sk-dashboard-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.custom-provider.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
// Generate hash for DB config
|
|
dbHash, err := dbConfig.GenerateConfigHash("custom-provider")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
dbConfig.ConfigHash = dbHash
|
|
|
|
// Existing providers from DB
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"custom-provider": dbConfig,
|
|
}
|
|
|
|
// File providers (doesn't include custom-provider)
|
|
fileProviders := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-openai-123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Simulate the logic: process file providers, but don't remove DB-only providers
|
|
for providerName, fileCfg := range fileProviders {
|
|
provider := schemas.ModelProvider(providerName)
|
|
fileHash, _ := fileCfg.GenerateConfigHash(providerName)
|
|
fileCfg.ConfigHash = fileHash
|
|
|
|
if _, exists := providersInConfigStore[provider]; !exists {
|
|
// New provider from file - add it
|
|
providersInConfigStore[provider] = fileCfg
|
|
}
|
|
// Note: We don't delete providers that are only in DB
|
|
}
|
|
|
|
// Verify dashboard-added provider is preserved
|
|
if _, exists := providersInConfigStore["custom-provider"]; !exists {
|
|
t.Error("Expected dashboard-added provider to be preserved")
|
|
}
|
|
|
|
// Verify file provider was added
|
|
if _, exists := providersInConfigStore[schemas.OpenAI]; !exists {
|
|
t.Error("Expected file provider to be added")
|
|
}
|
|
|
|
// Verify we have both providers
|
|
if len(providersInConfigStore) != 2 {
|
|
t.Errorf("Expected 2 providers (1 from DB + 1 from file), got %d", len(providersInConfigStore))
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_RoundTrip tests JSON → DB → same JSON produces no changes
|
|
func TestProviderHashComparison_RoundTrip(t *testing.T) {
|
|
// First load: config.json content
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
// Generate hash for file config
|
|
fileHash, err := fileConfig.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Simulate first load: save to DB
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: fileConfig,
|
|
}
|
|
|
|
// Second load: same config.json (no changes)
|
|
secondFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
secondFileHash, err := secondFileConfig.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate second hash: %v", err)
|
|
}
|
|
|
|
// Hash should match (config.json unchanged)
|
|
if fileHash != secondFileHash {
|
|
t.Error("Expected same hash for identical config (round-trip)")
|
|
}
|
|
|
|
// Simulate comparison logic
|
|
existingCfg := providersInConfigStore[schemas.OpenAI]
|
|
if existingCfg.ConfigHash == secondFileHash {
|
|
// Hash matches - keep DB config (no changes needed)
|
|
t.Log("Hash matches - DB config preserved (correct behavior)")
|
|
} else {
|
|
t.Error("Expected hash match on round-trip with same config")
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_DashboardEditThenSameFile tests dashboard edits are preserved
|
|
func TestProviderHashComparison_DashboardEditThenSameFile(t *testing.T) {
|
|
// Initial file config
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
fileHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Simulate: user edits key value via dashboard (but provider config hash stays same)
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-dashboard-modified-456"), Weight: 1}, // Modified via dashboard
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
ConfigHash: fileHash, // Hash based on provider config, not keys
|
|
}
|
|
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: dbConfig,
|
|
}
|
|
|
|
// Reload with same file config
|
|
reloadFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
reloadHash, _ := reloadFileConfig.GenerateConfigHash("openai")
|
|
|
|
// Hash matches (file unchanged)
|
|
existingCfg := providersInConfigStore[schemas.OpenAI]
|
|
if existingCfg.ConfigHash == reloadHash {
|
|
// Keep DB config - dashboard edits preserved
|
|
t.Log("Hash matches - dashboard edits preserved (correct behavior)")
|
|
} else {
|
|
t.Error("Expected hash match - file wasn't changed")
|
|
}
|
|
|
|
// Verify dashboard-modified key value is preserved
|
|
if existingCfg.Keys[0].Value != *schemas.NewEnvVar("sk-dashboard-modified-456") {
|
|
t.Errorf("Expected dashboard-modified key value to be preserved, got %v", existingCfg.Keys[0].Value)
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_OptionalFieldsPresence tests hash with optional fields present/absent
|
|
func TestProviderHashComparison_OptionalFieldsPresence(t *testing.T) {
|
|
// Config with no optional fields
|
|
configNoOptional := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashNoOptional, err := configNoOptional.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
// Config with NetworkConfig
|
|
configWithNetwork := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashWithNetwork, err := configWithNetwork.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithNetwork {
|
|
t.Error("Expected different hash when NetworkConfig is present vs absent")
|
|
}
|
|
|
|
// Config with ProxyConfig
|
|
configWithProxy := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashWithProxy, err := configWithProxy.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithProxy {
|
|
t.Error("Expected different hash when ProxyConfig is present vs absent")
|
|
}
|
|
|
|
// Config with ConcurrencyAndBufferSize
|
|
configWithConcurrency := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashWithConcurrency, err := configWithConcurrency.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithConcurrency {
|
|
t.Error("Expected different hash when ConcurrencyAndBufferSize is present vs absent")
|
|
}
|
|
|
|
// Config with CustomProviderConfig
|
|
configWithCustom := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
CustomProviderConfig: &schemas.CustomProviderConfig{
|
|
BaseProviderType: "openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashWithCustom, err := configWithCustom.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithCustom {
|
|
t.Error("Expected different hash when CustomProviderConfig is present vs absent")
|
|
}
|
|
|
|
// Config with SendBackRawResponse true vs false
|
|
configWithRawResponse := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashWithRawResponse, err := configWithRawResponse.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithRawResponse {
|
|
t.Error("Expected different hash when SendBackRawResponse is true vs false")
|
|
}
|
|
|
|
// Config with ALL optional fields
|
|
configAllFields := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
CustomProviderConfig: &schemas.CustomProviderConfig{
|
|
BaseProviderType: "openai",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashAllFields, err := configAllFields.GenerateConfigHash("openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
// All hashes should be unique
|
|
hashes := map[string]string{
|
|
"no_optional": hashNoOptional,
|
|
"with_network": hashWithNetwork,
|
|
"with_proxy": hashWithProxy,
|
|
"with_conc": hashWithConcurrency,
|
|
"with_custom": hashWithCustom,
|
|
"with_raw": hashWithRawResponse,
|
|
"all_fields": hashAllFields,
|
|
}
|
|
|
|
seen := make(map[string]string)
|
|
for name, hash := range hashes {
|
|
if existingName, exists := seen[hash]; exists {
|
|
t.Errorf("Hash collision between %s and %s", name, existingName)
|
|
}
|
|
seen[hash] = name
|
|
}
|
|
}
|
|
|
|
// TestKeyHashComparison_OptionalFieldsPresence tests key hash with optional fields
|
|
func TestKeyHashComparison_OptionalFieldsPresence(t *testing.T) {
|
|
// Basic key with no optional configs
|
|
keyBasic := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
}
|
|
|
|
hashBasic, _ := configstore.GenerateKeyHash(keyBasic)
|
|
|
|
// Key with Models
|
|
keyWithModels := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1,
|
|
}
|
|
|
|
hashWithModels, _ := configstore.GenerateKeyHash(keyWithModels)
|
|
|
|
if hashBasic == hashWithModels {
|
|
t.Error("Expected different hash when Models is present vs absent")
|
|
}
|
|
|
|
// Key with empty Models array vs nil
|
|
keyEmptyModels := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{},
|
|
Weight: 1,
|
|
}
|
|
|
|
hashEmptyModels, _ := configstore.GenerateKeyHash(keyEmptyModels)
|
|
|
|
// Empty slice and nil should produce same hash (both mean "no model restrictions")
|
|
if hashBasic != hashEmptyModels {
|
|
t.Error("Expected same hash for nil Models and empty Models slice")
|
|
}
|
|
|
|
// Key with AzureKeyConfig
|
|
keyWithAzure := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
hashWithAzure, _ := configstore.GenerateKeyHash(keyWithAzure)
|
|
|
|
if hashBasic == hashWithAzure {
|
|
t.Error("Expected different hash when AzureKeyConfig is present vs absent")
|
|
}
|
|
|
|
// Key with VertexKeyConfig
|
|
keyWithVertex := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
hashWithVertex, _ := configstore.GenerateKeyHash(keyWithVertex)
|
|
|
|
if hashBasic == hashWithVertex {
|
|
t.Error("Expected different hash when VertexKeyConfig is present vs absent")
|
|
}
|
|
|
|
// Key with BedrockKeyConfig
|
|
keyWithBedrock := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIA..."),
|
|
SecretKey: *schemas.NewEnvVar("secret..."),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
hashWithBedrock, _ := configstore.GenerateKeyHash(keyWithBedrock)
|
|
|
|
if hashBasic == hashWithBedrock {
|
|
t.Error("Expected different hash when BedrockKeyConfig is present vs absent")
|
|
}
|
|
|
|
// Verify all hashes are unique
|
|
hashes := map[string]string{
|
|
"basic": hashBasic,
|
|
"with_models": hashWithModels,
|
|
"with_azure": hashWithAzure,
|
|
"with_vertex": hashWithVertex,
|
|
"with_bedrock": hashWithBedrock,
|
|
}
|
|
|
|
seen := make(map[string]string)
|
|
for name, hash := range hashes {
|
|
if existingName, exists := seen[hash]; exists {
|
|
t.Errorf("Hash collision between %s and %s", name, existingName)
|
|
}
|
|
seen[hash] = name
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_FieldValueChanges tests hash changes when field values change
|
|
func TestProviderHashComparison_FieldValueChanges(t *testing.T) {
|
|
// Base config
|
|
baseConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
baseHash, _ := baseConfig.GenerateConfigHash("openai")
|
|
|
|
// Change BaseURL
|
|
configChangedURL := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.different.com", // Changed
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashChangedURL, _ := configChangedURL.GenerateConfigHash("openai")
|
|
|
|
if baseHash == hashChangedURL {
|
|
t.Error("Expected different hash when BaseURL changes")
|
|
}
|
|
|
|
// Add extra headers
|
|
configWithHeaders := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
ExtraHeaders: map[string]string{
|
|
"X-Custom-Header": "value",
|
|
},
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashWithHeaders, _ := configWithHeaders.GenerateConfigHash("openai")
|
|
|
|
if baseHash == hashWithHeaders {
|
|
t.Error("Expected different hash when ExtraHeaders are added")
|
|
}
|
|
|
|
// Change concurrency values
|
|
configWithConc1 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
}
|
|
|
|
hashConc1, _ := configWithConc1.GenerateConfigHash("openai")
|
|
|
|
configWithConc2 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 20, // Changed
|
|
BufferSize: 100,
|
|
},
|
|
}
|
|
|
|
hashConc2, _ := configWithConc2.GenerateConfigHash("openai")
|
|
|
|
if hashConc1 == hashConc2 {
|
|
t.Error("Expected different hash when Concurrency value changes")
|
|
}
|
|
}
|
|
|
|
// Helper function for string pointers
|
|
func stringPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
// TestProviderHashComparison_FieldRemoved tests hash changes when fields are removed
|
|
func TestProviderHashComparison_FieldRemoved(t *testing.T) {
|
|
// Original config with all fields
|
|
originalConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
ExtraHeaders: map[string]string{
|
|
"X-Custom": "value",
|
|
},
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
originalHash, _ := originalConfig.GenerateConfigHash("openai")
|
|
|
|
// NetworkConfig removed
|
|
configNoNetwork := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
// NetworkConfig: nil (removed)
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashNoNetwork, _ := configNoNetwork.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashNoNetwork {
|
|
t.Error("Expected different hash when NetworkConfig is removed")
|
|
}
|
|
|
|
// ConcurrencyAndBufferSize removed
|
|
configNoConcurrency := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
ExtraHeaders: map[string]string{
|
|
"X-Custom": "value",
|
|
},
|
|
},
|
|
// ConcurrencyAndBufferSize: nil (removed)
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashNoConcurrency, _ := configNoConcurrency.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashNoConcurrency {
|
|
t.Error("Expected different hash when ConcurrencyAndBufferSize is removed")
|
|
}
|
|
|
|
// ProxyConfig removed
|
|
configNoProxy := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
ExtraHeaders: map[string]string{
|
|
"X-Custom": "value",
|
|
},
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
// ProxyConfig: nil (removed)
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashNoProxy, _ := configNoProxy.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashNoProxy {
|
|
t.Error("Expected different hash when ProxyConfig is removed")
|
|
}
|
|
|
|
// SendBackRawResponse changed to false
|
|
configNoRawResponse := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
ExtraHeaders: map[string]string{
|
|
"X-Custom": "value",
|
|
},
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: false, // Changed to false
|
|
}
|
|
|
|
hashNoRawResponse, _ := configNoRawResponse.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashNoRawResponse {
|
|
t.Error("Expected different hash when SendBackRawResponse is changed to false")
|
|
}
|
|
|
|
// ExtraHeaders removed from NetworkConfig
|
|
configNoHeaders := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
// ExtraHeaders removed
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
ProxyConfig: &schemas.ProxyConfig{
|
|
Type: "http",
|
|
URL: "http://proxy.example.com",
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
hashNoHeaders, _ := configNoHeaders.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashNoHeaders {
|
|
t.Error("Expected different hash when ExtraHeaders are removed")
|
|
}
|
|
|
|
// All optional fields removed
|
|
configMinimal := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
hashMinimal, _ := configMinimal.GenerateConfigHash("openai")
|
|
|
|
if originalHash == hashMinimal {
|
|
t.Error("Expected different hash when all optional fields are removed")
|
|
}
|
|
}
|
|
|
|
// TestKeyHashComparison_FieldRemoved tests key hash changes when fields are removed
|
|
func TestKeyHashComparison_FieldRemoved(t *testing.T) {
|
|
// Original key with all fields
|
|
originalKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
originalHash, _ := configstore.GenerateKeyHash(originalKey)
|
|
|
|
// Models removed
|
|
keyNoModels := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
// Models: nil (removed)
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
hashNoModels, _ := configstore.GenerateKeyHash(keyNoModels)
|
|
|
|
if originalHash == hashNoModels {
|
|
t.Error("Expected different hash when Models are removed")
|
|
}
|
|
|
|
// AzureKeyConfig removed
|
|
keyNoAzure := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
// AzureKeyConfig: nil (removed)
|
|
}
|
|
|
|
hashNoAzure, _ := configstore.GenerateKeyHash(keyNoAzure)
|
|
|
|
if originalHash == hashNoAzure {
|
|
t.Error("Expected different hash when AzureKeyConfig is removed")
|
|
}
|
|
|
|
// Weight changed
|
|
keyDifferentWeight := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.0, // Changed from 1.5
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
hashDifferentWeight, _ := configstore.GenerateKeyHash(keyDifferentWeight)
|
|
|
|
if originalHash == hashDifferentWeight {
|
|
t.Error("Expected different hash when Weight is changed")
|
|
}
|
|
|
|
// Some models removed
|
|
keyFewerModels := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4"}, // gpt-3.5-turbo removed
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
hashFewerModels, _ := configstore.GenerateKeyHash(keyFewerModels)
|
|
|
|
if originalHash == hashFewerModels {
|
|
t.Error("Expected different hash when some Models are removed")
|
|
}
|
|
|
|
// Azure endpoint changed
|
|
keyDifferentEndpoint := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://different.openai.azure.com"), // Changed
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
hashDifferentEndpoint, _ := configstore.GenerateKeyHash(keyDifferentEndpoint)
|
|
|
|
if originalHash == hashDifferentEndpoint {
|
|
t.Error("Expected different hash when Azure endpoint is changed")
|
|
}
|
|
|
|
// Azure APIVersion removed
|
|
keyNoAPIVersion := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
// APIVersion: nil (removed)
|
|
},
|
|
}
|
|
|
|
hashNoAPIVersion, _ := configstore.GenerateKeyHash(keyNoAPIVersion)
|
|
|
|
if originalHash == hashNoAPIVersion {
|
|
t.Error("Expected different hash when Azure APIVersion is removed")
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_PartialFieldChanges tests partial changes within nested structs
|
|
func TestProviderHashComparison_PartialFieldChanges(t *testing.T) {
|
|
// Base config
|
|
baseConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
DefaultRequestTimeoutInSeconds: 30,
|
|
MaxRetries: 3,
|
|
},
|
|
}
|
|
|
|
baseHash, _ := baseConfig.GenerateConfigHash("openai")
|
|
|
|
// Timeout set to 0 (default/removed)
|
|
configNoTimeout := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
DefaultRequestTimeoutInSeconds: 0, // Removed/default
|
|
MaxRetries: 3,
|
|
},
|
|
}
|
|
|
|
hashNoTimeout, _ := configNoTimeout.GenerateConfigHash("openai")
|
|
|
|
if baseHash == hashNoTimeout {
|
|
t.Error("Expected different hash when DefaultRequestTimeoutInSeconds is removed/zeroed")
|
|
}
|
|
|
|
// MaxRetries set to 0 (default/removed)
|
|
configNoRetries := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
DefaultRequestTimeoutInSeconds: 30,
|
|
MaxRetries: 0, // Removed/default
|
|
},
|
|
}
|
|
|
|
hashNoRetries, _ := configNoRetries.GenerateConfigHash("openai")
|
|
|
|
if baseHash == hashNoRetries {
|
|
t.Error("Expected different hash when MaxRetries is removed/zeroed")
|
|
}
|
|
|
|
// Timeout value changed
|
|
configDifferentTimeout := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "test", Value: *schemas.NewEnvVar("sk-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.example.com",
|
|
DefaultRequestTimeoutInSeconds: 60, // Changed from 30
|
|
MaxRetries: 3,
|
|
},
|
|
}
|
|
|
|
hashDifferentTimeout, _ := configDifferentTimeout.GenerateConfigHash("openai")
|
|
|
|
if baseHash == hashDifferentTimeout {
|
|
t.Error("Expected different hash when DefaultRequestTimeoutInSeconds value changes")
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_FullLifecycle tests DB → new JSON → update DB → same JSON (no update)
|
|
func TestProviderHashComparison_FullLifecycle(t *testing.T) {
|
|
// === STEP 1: Initial state - provider exists in DB from previous config.json ===
|
|
initialConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-initial-123"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
initialHash, _ := initialConfig.GenerateConfigHash("openai")
|
|
initialConfig.ConfigHash = initialHash
|
|
|
|
// Simulate DB state
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: initialConfig,
|
|
}
|
|
|
|
t.Logf("Step 1 - Initial DB hash: %s", initialHash[:16]+"...")
|
|
|
|
// === STEP 2: New config.json comes with changes ===
|
|
newFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-new-456"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v2", // Changed!
|
|
MaxRetries: 5, // Added!
|
|
},
|
|
SendBackRawResponse: true, // Changed!
|
|
}
|
|
|
|
newFileHash, _ := newFileConfig.GenerateConfigHash("openai")
|
|
newFileConfig.ConfigHash = newFileHash
|
|
|
|
t.Logf("Step 2 - New file hash: %s", newFileHash[:16]+"...")
|
|
|
|
// Verify hashes are different (config.json changed)
|
|
dbConfig := providersInDB[schemas.OpenAI]
|
|
if dbConfig.ConfigHash == newFileHash {
|
|
t.Fatal("Expected different hash - config.json was changed")
|
|
}
|
|
|
|
// === STEP 3: Sync from file to DB (hash mismatch triggers update) ===
|
|
t.Log("Step 3 - Hash mismatch detected, syncing from file to DB")
|
|
|
|
// Simulate the sync: file config replaces DB config
|
|
providersInDB[schemas.OpenAI] = newFileConfig
|
|
|
|
// Verify DB was updated
|
|
updatedDBConfig := providersInDB[schemas.OpenAI]
|
|
if updatedDBConfig.ConfigHash != newFileHash {
|
|
t.Error("Expected DB to be updated with new hash")
|
|
}
|
|
if updatedDBConfig.NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Error("Expected DB to have new BaseURL from file")
|
|
}
|
|
if !updatedDBConfig.SendBackRawResponse {
|
|
t.Error("Expected DB to have SendBackRawResponse=true from file")
|
|
}
|
|
|
|
t.Logf("Step 3 - DB updated, new DB hash: %s", updatedDBConfig.ConfigHash[:16]+"...")
|
|
|
|
// === STEP 4: Same config.json loaded again - should NOT update ===
|
|
sameFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "openai-key", Value: *schemas.NewEnvVar("sk-new-456"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v2",
|
|
MaxRetries: 5,
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
sameFileHash, _ := sameFileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("Step 4 - Same file loaded again, hash: %s", sameFileHash[:16]+"...")
|
|
|
|
// Verify hashes match now (no changes in config.json since last sync)
|
|
currentDBConfig := providersInDB[schemas.OpenAI]
|
|
if currentDBConfig.ConfigHash != sameFileHash {
|
|
t.Errorf("Expected hash match - config.json unchanged since last sync. DB: %s, File: %s",
|
|
currentDBConfig.ConfigHash[:16], sameFileHash[:16])
|
|
}
|
|
|
|
// Simulate the comparison logic
|
|
if currentDBConfig.ConfigHash == sameFileHash {
|
|
t.Log("Step 4 - Hash matches, keeping DB config (no update needed) ✓")
|
|
} else {
|
|
t.Error("Step 4 - Should have matched, but didn't")
|
|
}
|
|
|
|
// === STEP 5: Verify DB wasn't modified (still has step 3 values) ===
|
|
finalDBConfig := providersInDB[schemas.OpenAI]
|
|
if finalDBConfig.NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Error("DB should still have v2 URL")
|
|
}
|
|
if finalDBConfig.NetworkConfig.MaxRetries != 5 {
|
|
t.Error("DB should still have MaxRetries=5")
|
|
}
|
|
if !finalDBConfig.SendBackRawResponse {
|
|
t.Error("DB should still have SendBackRawResponse=true")
|
|
}
|
|
|
|
t.Log("Step 5 - DB state verified, lifecycle complete ✓")
|
|
}
|
|
|
|
// TestProviderHashComparison_MultipleUpdates tests multiple config.json updates over time
|
|
func TestProviderHashComparison_MultipleUpdates(t *testing.T) {
|
|
// Track all hashes for verification
|
|
hashHistory := []string{}
|
|
|
|
// === Round 1: Initial config ===
|
|
config1 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "key", Value: *schemas.NewEnvVar("sk-v1"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.v1.com",
|
|
},
|
|
}
|
|
hash1, _ := config1.GenerateConfigHash("openai")
|
|
config1.ConfigHash = hash1
|
|
hashHistory = append(hashHistory, hash1)
|
|
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
schemas.OpenAI: config1,
|
|
}
|
|
|
|
t.Logf("Round 1 - hash: %s", hash1[:16]+"...")
|
|
|
|
// === Round 2: Update config.json ===
|
|
config2 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "key", Value: *schemas.NewEnvVar("sk-v2"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.v2.com", // Changed
|
|
},
|
|
}
|
|
hash2, _ := config2.GenerateConfigHash("openai")
|
|
config2.ConfigHash = hash2
|
|
hashHistory = append(hashHistory, hash2)
|
|
|
|
// Hash should be different
|
|
if hash1 == hash2 {
|
|
t.Fatal("Round 2 hash should differ from Round 1")
|
|
}
|
|
|
|
// Sync to DB
|
|
providersInDB[schemas.OpenAI] = config2
|
|
t.Logf("Round 2 - hash: %s (different from Round 1) ✓", hash2[:16]+"...")
|
|
|
|
// === Round 3: Another update ===
|
|
config3 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "key", Value: *schemas.NewEnvVar("sk-v3"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.v3.com", // Changed again
|
|
MaxRetries: 3, // Added
|
|
},
|
|
SendBackRawResponse: true, // Added
|
|
}
|
|
hash3, _ := config3.GenerateConfigHash("openai")
|
|
config3.ConfigHash = hash3
|
|
hashHistory = append(hashHistory, hash3)
|
|
|
|
// Hash should be different from all previous
|
|
if hash3 == hash1 || hash3 == hash2 {
|
|
t.Fatal("Round 3 hash should differ from all previous")
|
|
}
|
|
|
|
// Sync to DB
|
|
providersInDB[schemas.OpenAI] = config3
|
|
t.Logf("Round 3 - hash: %s (different from Round 1 & 2) ✓", hash3[:16]+"...")
|
|
|
|
// === Round 4: Same as Round 3 (no change) ===
|
|
config4 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "key", Value: *schemas.NewEnvVar("sk-v3"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.v3.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
hash4, _ := config4.GenerateConfigHash("openai")
|
|
|
|
// Hash should match Round 3
|
|
if hash4 != hash3 {
|
|
t.Fatalf("Round 4 hash should match Round 3. Got %s, expected %s", hash4[:16], hash3[:16])
|
|
}
|
|
|
|
// No sync needed
|
|
t.Logf("Round 4 - hash: %s (matches Round 3, no update) ✓", hash4[:16]+"...")
|
|
|
|
// === Round 5: Revert to Round 1 config ===
|
|
config5 := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{ID: "key-1", Name: "key", Value: *schemas.NewEnvVar("sk-v1"), Weight: 1}},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.v1.com",
|
|
},
|
|
}
|
|
hash5, _ := config5.GenerateConfigHash("openai")
|
|
config5.ConfigHash = hash5
|
|
|
|
// Hash should match Round 1
|
|
if hash5 != hash1 {
|
|
t.Fatalf("Round 5 hash should match Round 1. Got %s, expected %s", hash5[:16], hash1[:16])
|
|
}
|
|
|
|
// But it differs from current DB (which has Round 3 config)
|
|
currentDB := providersInDB[schemas.OpenAI]
|
|
if currentDB.ConfigHash == hash5 {
|
|
t.Fatal("Round 5 should trigger update (reverted config differs from DB)")
|
|
}
|
|
|
|
// Sync reverted config to DB
|
|
providersInDB[schemas.OpenAI] = config5
|
|
t.Logf("Round 5 - hash: %s (reverted to Round 1, update triggered) ✓", hash5[:16]+"...")
|
|
|
|
// Verify all unique hashes were generated
|
|
uniqueHashes := make(map[string]bool)
|
|
for _, h := range hashHistory {
|
|
uniqueHashes[h] = true
|
|
}
|
|
if len(uniqueHashes) != 3 { // hash1, hash2, hash3 (hash4 = hash3, hash5 = hash1)
|
|
t.Errorf("Expected 3 unique hashes, got %d", len(uniqueHashes))
|
|
}
|
|
|
|
t.Log("Multiple updates lifecycle complete ✓")
|
|
}
|
|
|
|
// TestProviderHashComparison_ProviderChangedKeysUnchanged tests provider update without key changes
|
|
func TestProviderHashComparison_ProviderChangedKeysUnchanged(t *testing.T) {
|
|
// === Initial state: Provider with keys in DB ===
|
|
originalKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
originalKeyHash, _ := configstore.GenerateKeyHash(originalKey)
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{originalKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
t.Logf("Initial - Provider hash: %s", dbProviderHash[:16]+"...")
|
|
t.Logf("Initial - Key hash: %s", originalKeyHash[:16]+"...")
|
|
|
|
// === File config: Provider changed, keys SAME ===
|
|
sameKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"), // SAME
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"}, // SAME
|
|
Weight: 1.5, // SAME
|
|
}
|
|
sameKeyHash, _ := configstore.GenerateKeyHash(sameKey)
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{sameKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v2", // CHANGED!
|
|
MaxRetries: 5, // CHANGED!
|
|
},
|
|
SendBackRawResponse: true, // CHANGED!
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("File - Provider hash: %s", fileProviderHash[:16]+"...")
|
|
t.Logf("File - Key hash: %s", sameKeyHash[:16]+"...")
|
|
|
|
// === Verify: Provider hash changed, key hash unchanged ===
|
|
if dbProviderHash == fileProviderHash {
|
|
t.Error("Expected provider hash to be DIFFERENT (provider config changed)")
|
|
} else {
|
|
t.Log("✓ Provider hash changed (expected)")
|
|
}
|
|
|
|
if originalKeyHash != sameKeyHash {
|
|
t.Error("Expected key hash to be SAME (key unchanged)")
|
|
} else {
|
|
t.Log("✓ Key hash unchanged (expected)")
|
|
}
|
|
|
|
// === Simulate sync logic: Update provider, preserve keys ===
|
|
// When provider hash differs but key hashes match:
|
|
// - Update provider-level config (NetworkConfig, SendBackRawResponse, etc.)
|
|
// - Keep existing keys from DB (they weren't changed in file)
|
|
|
|
updatedConfig := configstore.ProviderConfig{
|
|
Keys: dbConfig.Keys, // Keep original keys from DB
|
|
NetworkConfig: fileConfig.NetworkConfig, // Update from file
|
|
SendBackRawResponse: fileConfig.SendBackRawResponse, // Update from file
|
|
ConfigHash: fileProviderHash, // New provider hash
|
|
}
|
|
|
|
// Verify keys are preserved (same values as DB)
|
|
if len(updatedConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(updatedConfig.Keys))
|
|
}
|
|
if updatedConfig.Keys[0].Value.GetValue() != "sk-original-123" {
|
|
t.Errorf("Expected key value to be preserved, got %v", updatedConfig.Keys[0].Value)
|
|
}
|
|
if len(updatedConfig.Keys[0].Models) != 2 {
|
|
t.Errorf("Expected 2 models to be preserved, got %d", len(updatedConfig.Keys[0].Models))
|
|
}
|
|
|
|
// Verify provider config is updated
|
|
if updatedConfig.NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Error("Expected BaseURL to be updated from file")
|
|
}
|
|
if updatedConfig.NetworkConfig.MaxRetries != 5 {
|
|
t.Error("Expected MaxRetries to be updated from file")
|
|
}
|
|
if !updatedConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be updated from file")
|
|
}
|
|
|
|
t.Log("✓ Provider updated, keys preserved")
|
|
}
|
|
|
|
// TestProviderHashComparison_KeysChangedProviderUnchanged tests key update without provider changes
|
|
func TestProviderHashComparison_KeysChangedProviderUnchanged(t *testing.T) {
|
|
// === Initial state: Provider with keys in DB ===
|
|
originalKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
originalKeyHash, _ := configstore.GenerateKeyHash(originalKey)
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{originalKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
t.Logf("Initial - Provider hash: %s", dbProviderHash[:16]+"...")
|
|
t.Logf("Initial - Key hash: %s", originalKeyHash[:16]+"...")
|
|
|
|
// === File config: Provider SAME, keys changed ===
|
|
changedKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-new-456"), // CHANGED!
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo", "o1"}, // CHANGED!
|
|
Weight: 2.0, // CHANGED!
|
|
}
|
|
changedKeyHash, _ := configstore.GenerateKeyHash(changedKey)
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{changedKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
MaxRetries: 3, // SAME
|
|
},
|
|
SendBackRawResponse: false, // SAME
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("File - Provider hash: %s", fileProviderHash[:16]+"...")
|
|
t.Logf("File - Key hash: %s", changedKeyHash[:16]+"...")
|
|
|
|
// === Verify: Provider hash unchanged, key hash changed ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Error("Expected provider hash to be SAME (provider config unchanged)")
|
|
} else {
|
|
t.Log("✓ Provider hash unchanged (expected)")
|
|
}
|
|
|
|
if originalKeyHash == changedKeyHash {
|
|
t.Error("Expected key hash to be DIFFERENT (key changed)")
|
|
} else {
|
|
t.Log("✓ Key hash changed (expected)")
|
|
}
|
|
|
|
// === Simulate sync logic: Keep provider, update keys ===
|
|
// When provider hash matches but key hashes differ:
|
|
// - Keep provider-level config from DB
|
|
// - Update keys from file (they were changed)
|
|
|
|
updatedConfig := configstore.ProviderConfig{
|
|
Keys: fileConfig.Keys, // Update keys from file
|
|
NetworkConfig: dbConfig.NetworkConfig, // Keep from DB
|
|
SendBackRawResponse: dbConfig.SendBackRawResponse, // Keep from DB
|
|
ConfigHash: dbProviderHash, // Provider hash unchanged
|
|
}
|
|
|
|
// Verify provider config is preserved
|
|
if updatedConfig.NetworkConfig.BaseURL != "https://api.openai.com/v1" {
|
|
t.Error("Expected BaseURL to be preserved from DB")
|
|
}
|
|
if updatedConfig.NetworkConfig.MaxRetries != 3 {
|
|
t.Error("Expected MaxRetries to be preserved from DB")
|
|
}
|
|
if updatedConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be preserved from DB (false)")
|
|
}
|
|
|
|
// Verify keys are updated
|
|
if len(updatedConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(updatedConfig.Keys))
|
|
}
|
|
if updatedConfig.Keys[0].Value.GetValue() != "sk-new-456" {
|
|
t.Errorf("Expected key value to be updated, got %v", updatedConfig.Keys[0].Value)
|
|
}
|
|
if len(updatedConfig.Keys[0].Models) != 3 {
|
|
t.Errorf("Expected 3 models (updated), got %d", len(updatedConfig.Keys[0].Models))
|
|
}
|
|
if updatedConfig.Keys[0].Weight != 2.0 {
|
|
t.Errorf("Expected weight to be 2.0 (updated), got %f", updatedConfig.Keys[0].Weight)
|
|
}
|
|
|
|
t.Log("✓ Provider preserved, keys updated")
|
|
}
|
|
|
|
// TestProviderHashComparison_BothChangedIndependently tests both provider and keys changed
|
|
func TestProviderHashComparison_BothChangedIndependently(t *testing.T) {
|
|
// === Initial state ===
|
|
originalKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
originalKeyHash, _ := configstore.GenerateKeyHash(originalKey)
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{originalKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("Initial - Provider hash: %s", dbProviderHash[:16]+"...")
|
|
t.Logf("Initial - Key hash: %s", originalKeyHash[:16]+"...")
|
|
|
|
// === File config: BOTH provider and keys changed ===
|
|
changedKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-new-456"), // CHANGED
|
|
Models: []string{"gpt-4", "o1"}, // CHANGED
|
|
Weight: 2.0, // CHANGED
|
|
}
|
|
changedKeyHash, _ := configstore.GenerateKeyHash(changedKey)
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{changedKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v2", // CHANGED
|
|
MaxRetries: 5, // ADDED
|
|
},
|
|
SendBackRawResponse: true, // CHANGED
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("File - Provider hash: %s", fileProviderHash[:16]+"...")
|
|
t.Logf("File - Key hash: %s", changedKeyHash[:16]+"...")
|
|
|
|
// === Verify: Both hashes changed ===
|
|
if dbProviderHash == fileProviderHash {
|
|
t.Error("Expected provider hash to be DIFFERENT")
|
|
} else {
|
|
t.Log("✓ Provider hash changed")
|
|
}
|
|
|
|
if originalKeyHash == changedKeyHash {
|
|
t.Error("Expected key hash to be DIFFERENT")
|
|
} else {
|
|
t.Log("✓ Key hash changed")
|
|
}
|
|
|
|
// === Simulate sync: Update everything from file ===
|
|
updatedConfig := fileConfig
|
|
updatedConfig.ConfigHash = fileProviderHash
|
|
|
|
// Verify both provider and keys are updated
|
|
if updatedConfig.NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Error("Expected BaseURL to be updated")
|
|
}
|
|
if !updatedConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be updated")
|
|
}
|
|
if updatedConfig.Keys[0].Value.GetValue() != "sk-new-456" {
|
|
t.Error("Expected key value to be updated")
|
|
}
|
|
if updatedConfig.Keys[0].Weight != 2.0 {
|
|
t.Error("Expected key weight to be updated")
|
|
}
|
|
|
|
t.Log("✓ Both provider and keys updated")
|
|
}
|
|
|
|
// TestProviderHashComparison_NeitherChanged tests no changes scenario
|
|
func TestProviderHashComparison_NeitherChanged(t *testing.T) {
|
|
// === Initial state ===
|
|
originalKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
originalKeyHash, _ := configstore.GenerateKeyHash(originalKey)
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{originalKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
t.Logf("Initial - Provider hash: %s", dbProviderHash[:16]+"...")
|
|
t.Logf("Initial - Key hash: %s", originalKeyHash[:16]+"...")
|
|
|
|
// === File config: SAME as DB ===
|
|
sameKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-original-123"), // SAME
|
|
Models: []string{"gpt-4"}, // SAME
|
|
Weight: 1.0, // SAME
|
|
}
|
|
sameKeyHash, _ := configstore.GenerateKeyHash(sameKey)
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{sameKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
SendBackRawResponse: false, // SAME
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("File - Provider hash: %s", fileProviderHash[:16]+"...")
|
|
t.Logf("File - Key hash: %s", sameKeyHash[:16]+"...")
|
|
|
|
// === Verify: Both hashes match ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Errorf("Expected provider hash to be SAME, got DB=%s File=%s",
|
|
dbProviderHash[:16], fileProviderHash[:16])
|
|
} else {
|
|
t.Log("✓ Provider hash unchanged")
|
|
}
|
|
|
|
if originalKeyHash != sameKeyHash {
|
|
t.Errorf("Expected key hash to be SAME, got DB=%s File=%s",
|
|
originalKeyHash[:16], sameKeyHash[:16])
|
|
} else {
|
|
t.Log("✓ Key hash unchanged")
|
|
}
|
|
|
|
// === No sync needed - keep DB as is ===
|
|
t.Log("✓ No changes detected, DB preserved")
|
|
}
|
|
|
|
// =============================================================================
|
|
// KEY-LEVEL SYNC TESTS (when provider hash matches)
|
|
// =============================================================================
|
|
|
|
// TestKeyLevelSync_ProviderHashMatch_SingleKeyChanged tests that when provider hash matches
|
|
// but a single key has changed, only that key is updated from the file
|
|
func TestKeyLevelSync_ProviderHashMatch_SingleKeyChanged(t *testing.T) {
|
|
// === DB state: Provider with one key ===
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-old-value"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
dbKeyHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{dbKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
t.Logf("DB - Provider hash: %s", dbProviderHash[:16]+"...")
|
|
t.Logf("DB - Key hash: %s", dbKeyHash[:16]+"...")
|
|
|
|
// === File state: Same provider config, but key value changed ===
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-new-value"), // CHANGED
|
|
Models: []string{"gpt-4", "gpt-4-turbo"}, // CHANGED
|
|
Weight: 2.0, // CHANGED
|
|
}
|
|
fileKeyHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{fileKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
t.Logf("File - Provider hash: %s", fileProviderHash[:16]+"...")
|
|
t.Logf("File - Key hash: %s", fileKeyHash[:16]+"...")
|
|
|
|
// === Verify provider hash matches but key hash differs ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Fatalf("Expected provider hashes to match, got DB=%s File=%s",
|
|
dbProviderHash[:16], fileProviderHash[:16])
|
|
}
|
|
t.Log("✓ Provider hash matches (as expected)")
|
|
|
|
if dbKeyHash == fileKeyHash {
|
|
t.Fatal("Expected key hashes to differ")
|
|
}
|
|
t.Log("✓ Key hash differs (as expected)")
|
|
|
|
// === Simulate key-level sync logic ===
|
|
mergedKeys := make([]schemas.Key, 0)
|
|
fileKeysByName := make(map[string]int)
|
|
for i, fk := range fileConfig.Keys {
|
|
fileKeysByName[fk.Name] = i
|
|
}
|
|
|
|
for _, dbk := range dbConfig.Keys {
|
|
if fileIdx, exists := fileKeysByName[dbk.Name]; exists {
|
|
fk := fileConfig.Keys[fileIdx]
|
|
fkHash, _ := configstore.GenerateKeyHash(fk)
|
|
dkHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbk.Name,
|
|
Value: dbk.Value,
|
|
Models: dbk.Models,
|
|
Weight: dbk.Weight,
|
|
})
|
|
|
|
if fkHash != dkHash {
|
|
// Key changed - use file version but preserve ID
|
|
fk.ID = dbk.ID
|
|
mergedKeys = append(mergedKeys, fk)
|
|
t.Logf("✓ Key '%s' changed, using file version", fk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
}
|
|
delete(fileKeysByName, dbk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
}
|
|
}
|
|
|
|
// Add keys only in file
|
|
for _, idx := range fileKeysByName {
|
|
mergedKeys = append(mergedKeys, fileConfig.Keys[idx])
|
|
}
|
|
|
|
// === Verify results ===
|
|
if len(mergedKeys) != 1 {
|
|
t.Fatalf("Expected 1 merged key, got %d", len(mergedKeys))
|
|
}
|
|
|
|
mergedKey := mergedKeys[0]
|
|
if mergedKey.ID != "key-1" {
|
|
t.Errorf("Expected key ID to be preserved, got %s", mergedKey.ID)
|
|
}
|
|
if mergedKey.Value.GetValue() != "sk-new-value" {
|
|
t.Errorf("Expected key value from file, got %v", mergedKey.Value)
|
|
}
|
|
if len(mergedKey.Models) != 2 || mergedKey.Models[1] != "gpt-4-turbo" {
|
|
t.Errorf("Expected models from file, got %v", mergedKey.Models)
|
|
}
|
|
if mergedKey.Weight != 2.0 {
|
|
t.Errorf("Expected weight from file (2.0), got %f", mergedKey.Weight)
|
|
}
|
|
|
|
t.Log("✓ Key updated correctly from file while preserving ID")
|
|
}
|
|
|
|
// TestKeyLevelSync_ProviderHashMatch_NewKeyInFile tests that when provider hash matches
|
|
// and a new key exists only in the file, it is added to the merged result
|
|
func TestKeyLevelSync_ProviderHashMatch_NewKeyInFile(t *testing.T) {
|
|
// === DB state: Provider with one key ===
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key-1",
|
|
Value: *schemas.NewEnvVar("sk-key-1"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{dbKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
// === File state: Same key + NEW key ===
|
|
fileKey1 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key-1",
|
|
Value: *schemas.NewEnvVar("sk-key-1"), // SAME
|
|
Models: []string{"gpt-4"}, // SAME
|
|
Weight: 1.0, // SAME
|
|
}
|
|
newFileKey := schemas.Key{
|
|
ID: "key-2",
|
|
Name: "openai-key-2", // NEW KEY
|
|
Value: *schemas.NewEnvVar("sk-key-2"),
|
|
Models: []string{"gpt-3.5-turbo"},
|
|
Weight: 1.0,
|
|
}
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{fileKey1, newFileKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
// === Verify provider hash matches ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Fatalf("Expected provider hashes to match")
|
|
}
|
|
t.Log("✓ Provider hash matches")
|
|
|
|
// === Simulate key-level sync logic ===
|
|
mergedKeys := make([]schemas.Key, 0)
|
|
fileKeysByName := make(map[string]int)
|
|
for i, fk := range fileConfig.Keys {
|
|
fileKeysByName[fk.Name] = i
|
|
}
|
|
|
|
for _, dbk := range dbConfig.Keys {
|
|
if fileIdx, exists := fileKeysByName[dbk.Name]; exists {
|
|
fk := fileConfig.Keys[fileIdx]
|
|
fkHash, _ := configstore.GenerateKeyHash(fk)
|
|
dkHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbk.Name,
|
|
Value: dbk.Value,
|
|
Models: dbk.Models,
|
|
Weight: dbk.Weight,
|
|
})
|
|
|
|
if fkHash != dkHash {
|
|
fk.ID = dbk.ID
|
|
mergedKeys = append(mergedKeys, fk)
|
|
} else {
|
|
// Key unchanged - keep DB version
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
t.Logf("✓ Key '%s' unchanged, keeping DB version", dbk.Name)
|
|
}
|
|
delete(fileKeysByName, dbk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
}
|
|
}
|
|
|
|
// Add keys only in file (NEW keys)
|
|
for name, idx := range fileKeysByName {
|
|
mergedKeys = append(mergedKeys, fileConfig.Keys[idx])
|
|
t.Logf("✓ New key '%s' added from file", name)
|
|
}
|
|
|
|
// === Verify results ===
|
|
if len(mergedKeys) != 2 {
|
|
t.Fatalf("Expected 2 merged keys, got %d", len(mergedKeys))
|
|
}
|
|
|
|
// Check existing key is preserved
|
|
foundExisting := false
|
|
foundNew := false
|
|
for _, k := range mergedKeys {
|
|
if k.Name == "openai-key-1" {
|
|
foundExisting = true
|
|
if k.ID != "key-1" {
|
|
t.Error("Expected existing key ID to be preserved")
|
|
}
|
|
}
|
|
if k.Name == "openai-key-2" {
|
|
foundNew = true
|
|
if k.Value.GetValue() != "sk-key-2" {
|
|
t.Error("Expected new key value from file")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundExisting {
|
|
t.Error("Expected existing key to be in merged result")
|
|
}
|
|
if !foundNew {
|
|
t.Error("Expected new key from file to be in merged result")
|
|
}
|
|
|
|
t.Log("✓ New key from file added while preserving existing key")
|
|
}
|
|
|
|
// TestKeyLevelSync_ProviderHashMatch_KeyOnlyInDB tests that when provider hash matches
|
|
// and a key exists only in DB (added via dashboard), it is preserved
|
|
func TestKeyLevelSync_ProviderHashMatch_KeyOnlyInDB(t *testing.T) {
|
|
// === DB state: Provider with TWO keys (one added via dashboard) ===
|
|
dbKey1 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key-1",
|
|
Value: *schemas.NewEnvVar("sk-key-1"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
dashboardKey := schemas.Key{
|
|
ID: "key-dashboard",
|
|
Name: "dashboard-added-key", // Added via dashboard, NOT in config.json
|
|
Value: *schemas.NewEnvVar("sk-dashboard-key"),
|
|
Models: []string{"gpt-4", "o1"},
|
|
Weight: 2.0,
|
|
}
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{dbKey1, dashboardKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
// === File state: Only the original key (dashboard key not in file) ===
|
|
fileKey1 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key-1",
|
|
Value: *schemas.NewEnvVar("sk-key-1"), // SAME
|
|
Models: []string{"gpt-4"}, // SAME
|
|
Weight: 1.0, // SAME
|
|
}
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{fileKey1}, // Dashboard key NOT here
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
// === Verify provider hash matches ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Fatalf("Expected provider hashes to match")
|
|
}
|
|
t.Log("✓ Provider hash matches")
|
|
|
|
// === Simulate key-level sync logic ===
|
|
mergedKeys := make([]schemas.Key, 0)
|
|
fileKeysByName := make(map[string]int)
|
|
for i, fk := range fileConfig.Keys {
|
|
fileKeysByName[fk.Name] = i
|
|
}
|
|
|
|
for _, dbk := range dbConfig.Keys {
|
|
if fileIdx, exists := fileKeysByName[dbk.Name]; exists {
|
|
fk := fileConfig.Keys[fileIdx]
|
|
fkHash, _ := configstore.GenerateKeyHash(fk)
|
|
dkHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbk.Name,
|
|
Value: dbk.Value,
|
|
Models: dbk.Models,
|
|
Weight: dbk.Weight,
|
|
})
|
|
|
|
if fkHash != dkHash {
|
|
fk.ID = dbk.ID
|
|
mergedKeys = append(mergedKeys, fk)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
t.Logf("✓ Key '%s' unchanged, keeping DB version", dbk.Name)
|
|
}
|
|
delete(fileKeysByName, dbk.Name)
|
|
} else {
|
|
// Key only in DB - preserve it (added via dashboard)
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
t.Logf("✓ Key '%s' only in DB, preserving (dashboard-added)", dbk.Name)
|
|
}
|
|
}
|
|
|
|
// Add keys only in file
|
|
for _, idx := range fileKeysByName {
|
|
mergedKeys = append(mergedKeys, fileConfig.Keys[idx])
|
|
}
|
|
|
|
// === Verify results ===
|
|
if len(mergedKeys) != 2 {
|
|
t.Fatalf("Expected 2 merged keys, got %d", len(mergedKeys))
|
|
}
|
|
|
|
// Check dashboard key is preserved
|
|
foundDashboard := false
|
|
for _, k := range mergedKeys {
|
|
if k.Name == "dashboard-added-key" {
|
|
foundDashboard = true
|
|
if k.ID != "key-dashboard" {
|
|
t.Error("Expected dashboard key ID to be preserved")
|
|
}
|
|
if k.Value.GetValue() != "sk-dashboard-key" {
|
|
t.Error("Expected dashboard key value to be preserved")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundDashboard {
|
|
t.Error("Expected dashboard-added key to be preserved in merged result")
|
|
}
|
|
|
|
t.Log("✓ Dashboard-added key preserved correctly")
|
|
}
|
|
|
|
// TestKeyLevelSync_ProviderHashMatch_MixedScenario tests a complex scenario with:
|
|
// - Keys that are unchanged
|
|
// - Keys that changed in the file
|
|
// - Keys only in the file (new)
|
|
// - Keys only in DB (dashboard-added)
|
|
func TestKeyLevelSync_ProviderHashMatch_MixedScenario(t *testing.T) {
|
|
// === DB state ===
|
|
unchangedKey := schemas.Key{
|
|
ID: "key-unchanged",
|
|
Name: "unchanged-key",
|
|
Value: *schemas.NewEnvVar("sk-unchanged"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
changedKey := schemas.Key{
|
|
ID: "key-changed",
|
|
Name: "changed-key",
|
|
Value: *schemas.NewEnvVar("sk-old-value"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1.0,
|
|
}
|
|
dashboardKey := schemas.Key{
|
|
ID: "key-dashboard",
|
|
Name: "dashboard-key",
|
|
Value: *schemas.NewEnvVar("sk-dashboard"),
|
|
Models: []string{"o1"},
|
|
Weight: 3.0,
|
|
}
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{unchangedKey, changedKey, dashboardKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
// === File state ===
|
|
fileUnchangedKey := schemas.Key{
|
|
ID: "key-unchanged",
|
|
Name: "unchanged-key",
|
|
Value: *schemas.NewEnvVar("sk-unchanged"), // SAME
|
|
Models: []string{"gpt-4"}, // SAME
|
|
Weight: 1.0, // SAME
|
|
}
|
|
fileChangedKey := schemas.Key{
|
|
ID: "key-changed",
|
|
Name: "changed-key",
|
|
Value: *schemas.NewEnvVar("sk-NEW-value"), // CHANGED
|
|
Models: []string{"gpt-4", "gpt-4-turbo"}, // CHANGED
|
|
Weight: 2.0, // CHANGED
|
|
}
|
|
newFileKey := schemas.Key{
|
|
ID: "key-new",
|
|
Name: "new-file-key", // NEW - not in DB
|
|
Value: *schemas.NewEnvVar("sk-new-from-file"),
|
|
Models: []string{"gpt-3.5-turbo"},
|
|
Weight: 1.0,
|
|
}
|
|
// Note: dashboardKey is NOT in file
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{fileUnchangedKey, fileChangedKey, newFileKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
// === Verify provider hash matches ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Fatalf("Expected provider hashes to match")
|
|
}
|
|
t.Log("✓ Provider hash matches")
|
|
|
|
// === Simulate key-level sync logic ===
|
|
mergedKeys := make([]schemas.Key, 0)
|
|
fileKeysByName := make(map[string]int)
|
|
for i, fk := range fileConfig.Keys {
|
|
fileKeysByName[fk.Name] = i
|
|
}
|
|
|
|
for _, dbk := range dbConfig.Keys {
|
|
if fileIdx, exists := fileKeysByName[dbk.Name]; exists {
|
|
fk := fileConfig.Keys[fileIdx]
|
|
fkHash, _ := configstore.GenerateKeyHash(fk)
|
|
dkHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbk.Name,
|
|
Value: dbk.Value,
|
|
Models: dbk.Models,
|
|
Weight: dbk.Weight,
|
|
})
|
|
|
|
if fkHash != dkHash {
|
|
fk.ID = dbk.ID
|
|
mergedKeys = append(mergedKeys, fk)
|
|
t.Logf(" Key '%s': CHANGED → using file version", fk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
t.Logf(" Key '%s': UNCHANGED → keeping DB version", dbk.Name)
|
|
}
|
|
delete(fileKeysByName, dbk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
t.Logf(" Key '%s': DB-ONLY → preserving (dashboard-added)", dbk.Name)
|
|
}
|
|
}
|
|
|
|
for name, idx := range fileKeysByName {
|
|
mergedKeys = append(mergedKeys, fileConfig.Keys[idx])
|
|
t.Logf(" Key '%s': FILE-ONLY → adding new key", name)
|
|
}
|
|
|
|
// === Verify results ===
|
|
if len(mergedKeys) != 4 {
|
|
t.Fatalf("Expected 4 merged keys, got %d", len(mergedKeys))
|
|
}
|
|
|
|
keysByName := make(map[string]schemas.Key)
|
|
for _, k := range mergedKeys {
|
|
keysByName[k.Name] = k
|
|
}
|
|
|
|
// Check unchanged key
|
|
if k, ok := keysByName["unchanged-key"]; !ok {
|
|
t.Error("Missing unchanged-key")
|
|
} else {
|
|
if k.Value.GetValue() != "sk-unchanged" {
|
|
t.Errorf("unchanged-key: expected original value, got %v", k.Value)
|
|
}
|
|
if k.ID != "key-unchanged" {
|
|
t.Errorf("unchanged-key: expected original ID, got %s", k.ID)
|
|
}
|
|
}
|
|
|
|
// Check changed key
|
|
if k, ok := keysByName["changed-key"]; !ok {
|
|
t.Error("Missing changed-key")
|
|
} else {
|
|
if k.Value.GetValue() != "sk-NEW-value" {
|
|
t.Errorf("changed-key: expected new value, got %v", k.Value)
|
|
}
|
|
if k.ID != "key-changed" {
|
|
t.Errorf("changed-key: expected preserved ID, got %s", k.ID)
|
|
}
|
|
if k.Weight != 2.0 {
|
|
t.Errorf("changed-key: expected weight 2.0, got %f", k.Weight)
|
|
}
|
|
}
|
|
|
|
// Check dashboard key (preserved)
|
|
if k, ok := keysByName["dashboard-key"]; !ok {
|
|
t.Error("Missing dashboard-key - should be preserved!")
|
|
} else {
|
|
if k.Value.GetValue() != "sk-dashboard" {
|
|
t.Errorf("dashboard-key: expected preserved value, got %v", k.Value)
|
|
}
|
|
if k.ID != "key-dashboard" {
|
|
t.Errorf("dashboard-key: expected preserved ID, got %s", k.ID)
|
|
}
|
|
}
|
|
|
|
// Check new file key (added)
|
|
if k, ok := keysByName["new-file-key"]; !ok {
|
|
t.Error("Missing new-file-key - should be added!")
|
|
} else {
|
|
if k.Value.GetValue() != "sk-new-from-file" {
|
|
t.Errorf("new-file-key: expected file value, got %v", k.Value)
|
|
}
|
|
}
|
|
|
|
t.Log("✓ Mixed scenario handled correctly:")
|
|
t.Log(" - Unchanged keys preserved from DB")
|
|
t.Log(" - Changed keys updated from file with preserved ID")
|
|
t.Log(" - Dashboard-added keys preserved")
|
|
t.Log(" - New file keys added")
|
|
}
|
|
|
|
// TestKeyLevelSync_ProviderHashMatch_MultipleKeysChanged tests that when multiple keys
|
|
// change simultaneously, all are correctly updated
|
|
func TestKeyLevelSync_ProviderHashMatch_MultipleKeysChanged(t *testing.T) {
|
|
// === DB state: Three keys ===
|
|
dbKeys := []schemas.Key{
|
|
{ID: "key-1", Name: "key-one", Value: *schemas.NewEnvVar("old-1"), Models: []string{"gpt-4"}, Weight: 1.0},
|
|
{ID: "key-2", Name: "key-two", Value: *schemas.NewEnvVar("old-2"), Models: []string{"gpt-4"}, Weight: 1.0},
|
|
{ID: "key-3", Name: "key-three", Value: *schemas.NewEnvVar("old-3"), Models: []string{"gpt-4"}, Weight: 1.0},
|
|
}
|
|
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: dbKeys,
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1",
|
|
},
|
|
}
|
|
dbProviderHash, _ := dbConfig.GenerateConfigHash("openai")
|
|
dbConfig.ConfigHash = dbProviderHash
|
|
|
|
// === File state: All three keys changed ===
|
|
fileKeys := []schemas.Key{
|
|
{ID: "key-1", Name: "key-one", Value: *schemas.NewEnvVar("NEW-1"), Models: []string{"gpt-4", "o1"}, Weight: 2.0},
|
|
{ID: "key-2", Name: "key-two", Value: *schemas.NewEnvVar("NEW-2"), Models: []string{"gpt-3.5-turbo"}, Weight: 3.0},
|
|
{ID: "key-3", Name: "key-three", Value: *schemas.NewEnvVar("NEW-3"), Models: []string{"gpt-4-turbo"}, Weight: 4.0},
|
|
}
|
|
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: fileKeys,
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com/v1", // SAME
|
|
},
|
|
}
|
|
fileProviderHash, _ := fileConfig.GenerateConfigHash("openai")
|
|
|
|
// === Verify provider hash matches ===
|
|
if dbProviderHash != fileProviderHash {
|
|
t.Fatalf("Expected provider hashes to match")
|
|
}
|
|
|
|
// === Simulate key-level sync logic ===
|
|
mergedKeys := make([]schemas.Key, 0)
|
|
fileKeysByName := make(map[string]int)
|
|
for i, fk := range fileConfig.Keys {
|
|
fileKeysByName[fk.Name] = i
|
|
}
|
|
|
|
changedCount := 0
|
|
for _, dbk := range dbConfig.Keys {
|
|
if fileIdx, exists := fileKeysByName[dbk.Name]; exists {
|
|
fk := fileConfig.Keys[fileIdx]
|
|
fkHash, _ := configstore.GenerateKeyHash(fk)
|
|
dkHash, _ := configstore.GenerateKeyHash(schemas.Key{
|
|
Name: dbk.Name,
|
|
Value: dbk.Value,
|
|
Models: dbk.Models,
|
|
Weight: dbk.Weight,
|
|
})
|
|
|
|
if fkHash != dkHash {
|
|
fk.ID = dbk.ID
|
|
mergedKeys = append(mergedKeys, fk)
|
|
changedCount++
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
}
|
|
delete(fileKeysByName, dbk.Name)
|
|
} else {
|
|
mergedKeys = append(mergedKeys, dbk)
|
|
}
|
|
}
|
|
|
|
for _, idx := range fileKeysByName {
|
|
mergedKeys = append(mergedKeys, fileConfig.Keys[idx])
|
|
}
|
|
|
|
// === Verify all 3 keys were detected as changed ===
|
|
if changedCount != 3 {
|
|
t.Errorf("Expected 3 keys to be detected as changed, got %d", changedCount)
|
|
}
|
|
|
|
// === Verify all keys have new values but preserved IDs ===
|
|
expectedValues := map[string]struct {
|
|
value string
|
|
id string
|
|
weight float64
|
|
}{
|
|
"key-one": {value: "NEW-1", id: "key-1", weight: 2.0},
|
|
"key-two": {value: "NEW-2", id: "key-2", weight: 3.0},
|
|
"key-three": {value: "NEW-3", id: "key-3", weight: 4.0},
|
|
}
|
|
|
|
for _, k := range mergedKeys {
|
|
expected, ok := expectedValues[k.Name]
|
|
if !ok {
|
|
t.Errorf("Unexpected key: %s", k.Name)
|
|
continue
|
|
}
|
|
if k.Value.GetValue() != expected.value {
|
|
t.Errorf("Key %s: expected value %s, got %v", k.Name, expected.value, k.Value)
|
|
}
|
|
if k.ID != expected.id {
|
|
t.Errorf("Key %s: expected ID %s (preserved), got %s", k.Name, expected.id, k.ID)
|
|
}
|
|
if k.Weight != expected.weight {
|
|
t.Errorf("Key %s: expected weight %f, got %f", k.Name, expected.weight, k.Weight)
|
|
}
|
|
}
|
|
|
|
t.Log("✓ All 3 keys updated correctly from file with preserved IDs")
|
|
}
|
|
|
|
// TestKeyHashComparison_KeyContentChanged tests key content change detection
|
|
func TestKeyHashComparison_KeyContentChanged(t *testing.T) {
|
|
// Original key in DB
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-old-value"),
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1,
|
|
}
|
|
|
|
dbKeyHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
|
|
// Same key in file but with different value
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-new-value"), // Changed!
|
|
Models: []string{"gpt-4"},
|
|
Weight: 1,
|
|
}
|
|
|
|
fileKeyHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
// Hashes should be different (key content changed)
|
|
if dbKeyHash == fileKeyHash {
|
|
t.Error("Expected different hash for keys with different Value")
|
|
}
|
|
|
|
// Same key with only models changed
|
|
fileKey2 := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "openai-key",
|
|
Value: *schemas.NewEnvVar("sk-old-value"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo"}, // Changed models
|
|
Weight: 1,
|
|
}
|
|
|
|
fileKey2Hash, _ := configstore.GenerateKeyHash(fileKey2)
|
|
|
|
if dbKeyHash == fileKey2Hash {
|
|
t.Error("Expected different hash for keys with different Models")
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_NewProvider tests that new provider is added from file
|
|
func TestProviderHashComparison_NewProvider(t *testing.T) {
|
|
// Create a provider config (simulating new provider in config.json)
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: "key-1", Name: "anthropic-key", Value: *schemas.NewEnvVar("sk-ant-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.anthropic.com",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
// Generate hash for the file config
|
|
fileHash, err := fileConfig.GenerateConfigHash("anthropic")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate file hash: %v", err)
|
|
}
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Empty DB (no existing providers)
|
|
providersInConfigStore := map[schemas.ModelProvider]configstore.ProviderConfig{}
|
|
|
|
provider := schemas.Anthropic
|
|
|
|
// Simulate the logic: provider doesn't exist, add from file
|
|
if _, exists := providersInConfigStore[provider]; !exists {
|
|
providersInConfigStore[provider] = fileConfig
|
|
}
|
|
|
|
// Verify provider was added
|
|
if _, exists := providersInConfigStore[provider]; !exists {
|
|
t.Error("Expected provider to be added")
|
|
}
|
|
|
|
resultConfig := providersInConfigStore[provider]
|
|
|
|
if resultConfig.ConfigHash != fileHash {
|
|
t.Error("Expected ConfigHash to be set from file")
|
|
}
|
|
|
|
if len(resultConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(resultConfig.Keys))
|
|
}
|
|
|
|
if resultConfig.Keys[0].Name != "anthropic-key" {
|
|
t.Errorf("Expected key name 'anthropic-key', got %s", resultConfig.Keys[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestKeyHashComparison_AzureConfigSyncScenarios tests full lifecycle for Azure key configs
|
|
func TestKeyHashComparison_AzureConfigSyncScenarios(t *testing.T) {
|
|
// === Scenario 1: Azure config in DB + same in file -> hash matches, no update ===
|
|
t.Run("SameAzureConfig_NoUpdate", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash != fileHash {
|
|
t.Errorf("Expected same hash for identical Azure configs. DB: %s, File: %s", dbHash[:16], fileHash[:16])
|
|
}
|
|
t.Log("✓ Same Azure config produces same hash - no update needed")
|
|
})
|
|
|
|
// === Scenario 2: Azure config in DB + different endpoint in file -> hash differs ===
|
|
t.Run("DifferentEndpoint_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://different-azure.openai.azure.com"), // Changed!
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure endpoint changes")
|
|
}
|
|
t.Log("✓ Different Azure endpoint produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 3: Azure config in DB + different APIVersion in file -> hash differs ===
|
|
t.Run("DifferentAPIVersion_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-10-21"), // Changed!
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure APIVersion changes")
|
|
}
|
|
t.Log("✓ Different Azure APIVersion produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 4: Azure config in DB + different Deployments map in file -> hash differs ===
|
|
t.Run("DifferentDeployments_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment", "gpt-3.5-turbo": "gpt-35-turbo-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure Deployments map changes")
|
|
}
|
|
t.Log("✓ Different Azure Deployments produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 5: Azure config added to file when not in DB -> new key detected ===
|
|
t.Run("AzureConfigAdded_NewKeyDetected", func(t *testing.T) {
|
|
// DB key has no Azure config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
// No AzureKeyConfig
|
|
}
|
|
|
|
// File key has Azure config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure config is added")
|
|
}
|
|
t.Log("✓ Azure config added produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 6: Azure config removed from file -> hash differs ===
|
|
t.Run("AzureConfigRemoved_UpdateTriggered", func(t *testing.T) {
|
|
// DB key has Azure config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
// File key has no Azure config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
// No AzureKeyConfig
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure config is removed")
|
|
}
|
|
t.Log("✓ Azure config removed produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 7: APIVersion nil vs set -> hash differs ===
|
|
t.Run("APIVersionNilVsSet_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
// APIVersion is nil (will use default)
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"), // Explicitly set
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when APIVersion goes from nil to set")
|
|
}
|
|
t.Log("✓ APIVersion nil vs set produces different hash - update triggered")
|
|
})
|
|
}
|
|
|
|
// TestKeyHashComparison_BedrockConfigSyncScenarios tests full lifecycle for Bedrock key configs
|
|
func TestKeyHashComparison_BedrockConfigSyncScenarios(t *testing.T) {
|
|
// === Scenario 1: Bedrock config in DB + same in file -> hash matches, no update ===
|
|
t.Run("SameBedrockConfig_NoUpdate", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash != fileHash {
|
|
t.Errorf("Expected same hash for identical Bedrock configs. DB: %s, File: %s", dbHash[:16], fileHash[:16])
|
|
}
|
|
t.Log("✓ Same Bedrock config produces same hash - no update needed")
|
|
})
|
|
|
|
// === Scenario 2: Bedrock config in DB + different AccessKey/SecretKey -> hash differs ===
|
|
t.Run("DifferentAccessKey_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAI44QH8DHBEXAMPLE"), // Changed!
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock AccessKey changes")
|
|
}
|
|
t.Log("✓ Different Bedrock AccessKey produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 3: Bedrock config in DB + different SecretKey -> hash differs ===
|
|
t.Run("DifferentSecretKey_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("differentSecretKey/NEWKEY/bPxRfiCYEXAMPLEKEY"), // Changed!
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock SecretKey changes")
|
|
}
|
|
t.Log("✓ Different Bedrock SecretKey produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 4: Bedrock config in DB + different Region -> hash differs ===
|
|
t.Run("DifferentRegion_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-west-2"), // Changed!
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock Region changes")
|
|
}
|
|
t.Log("✓ Different Bedrock Region produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 5: Bedrock config in DB + different ARN -> hash differs ===
|
|
t.Run("DifferentARN_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
ARN: schemas.NewEnvVar("arn:aws:bedrock:us-east-1:123456789012:inference-profile/old-profile"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
ARN: schemas.NewEnvVar("arn:aws:bedrock:us-east-1:123456789012:inference-profile/new-profile"), // Changed!
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock ARN changes")
|
|
}
|
|
t.Log("✓ Different Bedrock ARN produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 6: Bedrock config in DB + different Deployments -> hash differs ===
|
|
t.Run("DifferentDeployments_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile", "claude-3.5": "claude-35-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock Deployments map changes")
|
|
}
|
|
t.Log("✓ Different Bedrock Deployments produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 7: Bedrock config added to file when not in DB -> new key detected ===
|
|
t.Run("BedrockConfigAdded_NewKeyDetected", func(t *testing.T) {
|
|
// DB key has no Bedrock config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
// No BedrockKeyConfig
|
|
}
|
|
|
|
// File key has Bedrock config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock config is added")
|
|
}
|
|
t.Log("✓ Bedrock config added produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 8: Bedrock config removed from file -> hash differs ===
|
|
t.Run("BedrockConfigRemoved_UpdateTriggered", func(t *testing.T) {
|
|
// DB key has Bedrock config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
// File key has no Bedrock config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
// No BedrockKeyConfig
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Bedrock config is removed")
|
|
}
|
|
t.Log("✓ Bedrock config removed produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 9: SessionToken nil vs set -> hash differs ===
|
|
t.Run("SessionTokenNilVsSet_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
// SessionToken is nil
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
SessionToken: schemas.NewEnvVar("AQoDYXdzEJr..."), // Explicitly set
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when SessionToken goes from nil to set")
|
|
}
|
|
t.Log("✓ SessionToken nil vs set produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 10: IAM role auth (empty credentials) vs explicit credentials -> hash differs ===
|
|
t.Run("IAMRoleAuthVsExplicitCredentials_UpdateTriggered", func(t *testing.T) {
|
|
// IAM role auth: empty AccessKey and SecretKey
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar(""), // Empty for IAM role auth
|
|
SecretKey: *schemas.NewEnvVar(""), // Empty for IAM role auth
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
// Explicit credentials
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "claude-3-inference-profile"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when switching from IAM role auth to explicit credentials")
|
|
}
|
|
t.Log("✓ IAM role auth vs explicit credentials produces different hash - update triggered")
|
|
})
|
|
}
|
|
|
|
// TestProviderHashComparison_AzureProviderFullLifecycle tests end-to-end Azure provider lifecycle
|
|
func TestProviderHashComparison_AzureProviderFullLifecycle(t *testing.T) {
|
|
// === STEP 1: Initial state - Azure provider exists in DB from previous config.json ===
|
|
initialAzureKey := schemas.Key{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-initial"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
initialConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{initialAzureKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
initialProviderHash, _ := initialConfig.GenerateConfigHash("azure")
|
|
initialKeyHash, _ := configstore.GenerateKeyHash(initialAzureKey)
|
|
initialConfig.ConfigHash = initialProviderHash
|
|
|
|
// Simulate DB state
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"azure": initialConfig,
|
|
}
|
|
|
|
t.Logf("Step 1 - Initial DB provider hash: %s", initialProviderHash[:16]+"...")
|
|
t.Logf("Step 1 - Initial DB key hash: %s", initialKeyHash[:16]+"...")
|
|
|
|
// === STEP 2: Dashboard edit to key (API key value changed via dashboard) ===
|
|
// The key value is edited via dashboard, but the Azure config structure stays the same
|
|
// Provider config hash should remain unchanged
|
|
dashboardEditedKey := schemas.Key{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-dashboard-edited"), // Changed via dashboard!
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
}
|
|
|
|
dbConfigAfterDashboardEdit := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{dashboardEditedKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
ConfigHash: initialProviderHash, // Provider hash unchanged (only key value changed)
|
|
}
|
|
|
|
providersInDB["azure"] = dbConfigAfterDashboardEdit
|
|
|
|
dashboardKeyHash, _ := configstore.GenerateKeyHash(dashboardEditedKey)
|
|
t.Logf("Step 2 - After dashboard edit, key hash: %s (different from initial)", dashboardKeyHash[:16]+"...")
|
|
|
|
if initialKeyHash == dashboardKeyHash {
|
|
t.Error("Expected key hash to change after dashboard edit")
|
|
}
|
|
|
|
// === STEP 3: Same config.json loaded (unchanged) - should NOT update, preserve dashboard edits ===
|
|
sameFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-initial"), // Original value from file
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
sameFileProviderHash, _ := sameFileConfig.GenerateConfigHash("azure")
|
|
|
|
// Provider hash should match (config.json unchanged)
|
|
existingCfg := providersInDB["azure"]
|
|
if existingCfg.ConfigHash != sameFileProviderHash {
|
|
t.Errorf("Expected provider hash to match - config.json unchanged. DB: %s, File: %s",
|
|
existingCfg.ConfigHash[:16], sameFileProviderHash[:16])
|
|
}
|
|
|
|
t.Logf("Step 3 - Hash matches, dashboard edits preserved ✓")
|
|
|
|
// Verify dashboard-edited key value is preserved
|
|
if existingCfg.Keys[0].Value.GetValue() != "azure-api-key-dashboard-edited" {
|
|
t.Errorf("Expected dashboard-edited key value to be preserved, got %v", existingCfg.Keys[0].Value)
|
|
}
|
|
|
|
// === STEP 4: Config.json changed (Azure endpoint updated) - should trigger sync ===
|
|
newFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-initial"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment", "gpt-4o": "gpt-4o-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://new-azure.openai.azure.com"), // Changed!
|
|
APIVersion: schemas.NewEnvVar("2024-10-21"), // Changed!
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://new-azure.openai.azure.com/openai", // Changed!
|
|
},
|
|
SendBackRawResponse: true, // Changed!
|
|
}
|
|
|
|
newFileProviderHash, _ := newFileConfig.GenerateConfigHash("azure")
|
|
newFileKeyHash, _ := configstore.GenerateKeyHash(newFileConfig.Keys[0])
|
|
|
|
t.Logf("Step 4 - New file provider hash: %s", newFileProviderHash[:16]+"...")
|
|
t.Logf("Step 4 - New file key hash: %s", newFileKeyHash[:16]+"...")
|
|
|
|
// Provider hash should be different (config.json changed)
|
|
if existingCfg.ConfigHash == newFileProviderHash {
|
|
t.Error("Expected provider hash to differ - config.json was changed")
|
|
}
|
|
|
|
// Simulate sync: update from file, but preserve dashboard-added keys
|
|
// (In this case, we're updating the existing key, not adding new ones)
|
|
mergedKeys := []schemas.Key{}
|
|
|
|
// For each key in file, check if it exists in DB
|
|
for _, fileKey := range newFileConfig.Keys {
|
|
found := false
|
|
for _, dbKey := range existingCfg.Keys {
|
|
if dbKey.Name == fileKey.Name || dbKey.ID == fileKey.ID {
|
|
// Key exists in both - use file version (config.json changed)
|
|
mergedKeys = append(mergedKeys, fileKey)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// New key from file
|
|
mergedKeys = append(mergedKeys, fileKey)
|
|
}
|
|
}
|
|
|
|
// Preserve dashboard-added keys that aren't in file
|
|
for _, dbKey := range existingCfg.Keys {
|
|
found := false
|
|
for _, fileKey := range newFileConfig.Keys {
|
|
if dbKey.Name == fileKey.Name || dbKey.ID == fileKey.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// Key only in DB (added via dashboard) - preserve it
|
|
mergedKeys = append(mergedKeys, dbKey)
|
|
}
|
|
}
|
|
|
|
updatedConfig := configstore.ProviderConfig{
|
|
Keys: mergedKeys,
|
|
NetworkConfig: newFileConfig.NetworkConfig,
|
|
SendBackRawResponse: newFileConfig.SendBackRawResponse,
|
|
ConfigHash: newFileProviderHash,
|
|
}
|
|
|
|
providersInDB["azure"] = updatedConfig
|
|
|
|
t.Logf("Step 4 - Sync complete, DB updated ✓")
|
|
|
|
// === STEP 5: Verify final state ===
|
|
finalConfig := providersInDB["azure"]
|
|
|
|
// Verify provider config updated
|
|
if finalConfig.NetworkConfig.BaseURL != "https://new-azure.openai.azure.com/openai" {
|
|
t.Errorf("Expected updated BaseURL, got %s", finalConfig.NetworkConfig.BaseURL)
|
|
}
|
|
if !finalConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be true")
|
|
}
|
|
if finalConfig.ConfigHash != newFileProviderHash {
|
|
t.Error("Expected config hash to be updated")
|
|
}
|
|
|
|
// Verify Azure key config updated
|
|
if len(finalConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(finalConfig.Keys))
|
|
}
|
|
if finalConfig.Keys[0].AzureKeyConfig.Endpoint.GetValue() != "https://new-azure.openai.azure.com" {
|
|
t.Errorf("Expected updated Azure endpoint, got %s", finalConfig.Keys[0].AzureKeyConfig.Endpoint.GetValue())
|
|
}
|
|
if finalConfig.Keys[0].AzureKeyConfig.APIVersion.GetValue() != "2024-10-21" {
|
|
t.Errorf("Expected updated APIVersion, got %s", finalConfig.Keys[0].AzureKeyConfig.APIVersion.GetValue())
|
|
}
|
|
if len(finalConfig.Keys[0].Aliases) != 2 {
|
|
t.Errorf("Expected 2 deployments, got %d", len(finalConfig.Keys[0].Aliases))
|
|
}
|
|
|
|
t.Log("Step 5 - Final state verified, Azure provider lifecycle complete ✓")
|
|
}
|
|
|
|
// TestProviderHashComparison_BedrockProviderFullLifecycle tests end-to-end Bedrock provider lifecycle
|
|
func TestProviderHashComparison_BedrockProviderFullLifecycle(t *testing.T) {
|
|
// === STEP 1: Initial state - Bedrock provider exists in DB from previous config.json ===
|
|
initialBedrockKey := schemas.Key{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""), // Empty for Bedrock with IAM or AccessKey auth
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
initialConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{initialBedrockKey},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
initialProviderHash, _ := initialConfig.GenerateConfigHash("bedrock")
|
|
initialKeyHash, _ := configstore.GenerateKeyHash(initialBedrockKey)
|
|
initialConfig.ConfigHash = initialProviderHash
|
|
|
|
// Simulate DB state
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"bedrock": initialConfig,
|
|
}
|
|
|
|
t.Logf("Step 1 - Initial DB provider hash: %s", initialProviderHash[:16]+"...")
|
|
t.Logf("Step 1 - Initial DB key hash: %s", initialKeyHash[:16]+"...")
|
|
|
|
// === STEP 2: Dashboard adds a second key ===
|
|
dashboardAddedKey := schemas.Key{
|
|
ID: "bedrock-key-2",
|
|
Name: "aws-bedrock-key-eu",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAI44QH8DHBEXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("eu-west-1"), // Different region
|
|
},
|
|
}
|
|
|
|
dbConfigAfterDashboardAdd := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{initialBedrockKey, dashboardAddedKey}, // Added via dashboard
|
|
NetworkConfig: initialConfig.NetworkConfig,
|
|
SendBackRawResponse: false,
|
|
ConfigHash: initialProviderHash, // Provider hash unchanged
|
|
}
|
|
|
|
providersInDB["bedrock"] = dbConfigAfterDashboardAdd
|
|
|
|
t.Logf("Step 2 - Dashboard added second key ✓")
|
|
|
|
// === STEP 3: Same config.json loaded (unchanged) - should NOT update, preserve dashboard keys ===
|
|
sameFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
sameFileProviderHash, _ := sameFileConfig.GenerateConfigHash("bedrock")
|
|
|
|
// Provider hash should match (config.json unchanged)
|
|
existingCfg := providersInDB["bedrock"]
|
|
if existingCfg.ConfigHash != sameFileProviderHash {
|
|
t.Errorf("Expected provider hash to match - config.json unchanged. DB: %s, File: %s",
|
|
existingCfg.ConfigHash[:16], sameFileProviderHash[:16])
|
|
}
|
|
|
|
t.Logf("Step 3 - Hash matches, dashboard-added key preserved ✓")
|
|
|
|
// Verify dashboard-added key is preserved
|
|
if len(existingCfg.Keys) != 2 {
|
|
t.Errorf("Expected 2 keys (1 original + 1 dashboard-added), got %d", len(existingCfg.Keys))
|
|
}
|
|
|
|
// === STEP 4: Config.json changed (region and new deployment) - should trigger sync but preserve dashboard keys ===
|
|
newFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0", "claude-3-opus": "anthropic.claude-3-opus-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-west-2"), // Changed!
|
|
ARN: schemas.NewEnvVar("arn:aws:bedrock:us-west-2:123456789012:inference-profile/my-profile"), // Added!
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-west-2.amazonaws.com", // Changed!
|
|
MaxRetries: 5, // Changed!
|
|
},
|
|
SendBackRawResponse: true, // Changed!
|
|
}
|
|
|
|
newFileProviderHash, _ := newFileConfig.GenerateConfigHash("bedrock")
|
|
newFileKeyHash, _ := configstore.GenerateKeyHash(newFileConfig.Keys[0])
|
|
|
|
t.Logf("Step 4 - New file provider hash: %s", newFileProviderHash[:16]+"...")
|
|
t.Logf("Step 4 - New file key hash: %s", newFileKeyHash[:16]+"...")
|
|
|
|
// Provider hash should be different (config.json changed)
|
|
if existingCfg.ConfigHash == newFileProviderHash {
|
|
t.Error("Expected provider hash to differ - config.json was changed")
|
|
}
|
|
|
|
// Simulate sync: update from file, but preserve dashboard-added keys
|
|
mergedKeys := []schemas.Key{}
|
|
|
|
// For each key in file, update or add
|
|
for _, fileKey := range newFileConfig.Keys {
|
|
mergedKeys = append(mergedKeys, fileKey)
|
|
}
|
|
|
|
// Preserve dashboard-added keys that aren't in file
|
|
for _, dbKey := range existingCfg.Keys {
|
|
found := false
|
|
for _, fileKey := range newFileConfig.Keys {
|
|
if dbKey.Name == fileKey.Name || dbKey.ID == fileKey.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// Key only in DB (added via dashboard) - preserve it
|
|
mergedKeys = append(mergedKeys, dbKey)
|
|
}
|
|
}
|
|
|
|
updatedConfig := configstore.ProviderConfig{
|
|
Keys: mergedKeys,
|
|
NetworkConfig: newFileConfig.NetworkConfig,
|
|
SendBackRawResponse: newFileConfig.SendBackRawResponse,
|
|
ConfigHash: newFileProviderHash,
|
|
}
|
|
|
|
providersInDB["bedrock"] = updatedConfig
|
|
|
|
t.Logf("Step 4 - Sync complete, DB updated ✓")
|
|
|
|
// === STEP 5: Verify final state ===
|
|
finalConfig := providersInDB["bedrock"]
|
|
|
|
// Verify provider config updated
|
|
if finalConfig.NetworkConfig.BaseURL != "https://bedrock-runtime.us-west-2.amazonaws.com" {
|
|
t.Errorf("Expected updated BaseURL, got %s", finalConfig.NetworkConfig.BaseURL)
|
|
}
|
|
if finalConfig.NetworkConfig.MaxRetries != 5 {
|
|
t.Errorf("Expected MaxRetries to be 5, got %d", finalConfig.NetworkConfig.MaxRetries)
|
|
}
|
|
if !finalConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be true")
|
|
}
|
|
if finalConfig.ConfigHash != newFileProviderHash {
|
|
t.Error("Expected config hash to be updated")
|
|
}
|
|
|
|
// Verify we have both keys (1 updated from file + 1 dashboard-added)
|
|
if len(finalConfig.Keys) != 2 {
|
|
t.Errorf("Expected 2 keys (1 from file + 1 dashboard-added), got %d", len(finalConfig.Keys))
|
|
}
|
|
|
|
// Find the file key and verify its updates
|
|
var fileKey *schemas.Key
|
|
var dashboardKey *schemas.Key
|
|
for i := range finalConfig.Keys {
|
|
if finalConfig.Keys[i].Name == "aws-bedrock-key" {
|
|
fileKey = &finalConfig.Keys[i]
|
|
}
|
|
if finalConfig.Keys[i].Name == "aws-bedrock-key-eu" {
|
|
dashboardKey = &finalConfig.Keys[i]
|
|
}
|
|
}
|
|
|
|
if fileKey == nil {
|
|
t.Fatal("Expected to find file key")
|
|
}
|
|
if dashboardKey == nil {
|
|
t.Fatal("Expected to find dashboard-added key")
|
|
}
|
|
|
|
// Verify file key Bedrock config updated
|
|
if fileKey.BedrockKeyConfig.Region.GetValue() != "us-west-2" {
|
|
t.Errorf("Expected updated Bedrock region, got %s", fileKey.BedrockKeyConfig.Region.GetValue())
|
|
}
|
|
if fileKey.BedrockKeyConfig.ARN == nil || fileKey.BedrockKeyConfig.ARN.GetValue() != "arn:aws:bedrock:us-west-2:123456789012:inference-profile/my-profile" {
|
|
t.Error("Expected ARN to be set")
|
|
}
|
|
if len(fileKey.Aliases) != 2 {
|
|
t.Errorf("Expected 2 deployments, got %d", len(fileKey.Aliases))
|
|
}
|
|
|
|
// Verify dashboard-added key is preserved
|
|
if dashboardKey.BedrockKeyConfig.Region.GetValue() != "eu-west-1" {
|
|
t.Errorf("Expected dashboard key region to be preserved, got %v", *dashboardKey.BedrockKeyConfig.Region)
|
|
}
|
|
|
|
t.Log("Step 5 - Final state verified, Bedrock provider lifecycle complete ✓")
|
|
|
|
// === STEP 6: Same config.json loaded again - should NOT update ===
|
|
sameNewFileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0", "claude-3-opus": "anthropic.claude-3-opus-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-west-2"),
|
|
ARN: schemas.NewEnvVar("arn:aws:bedrock:us-west-2:123456789012:inference-profile/my-profile"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-west-2.amazonaws.com",
|
|
MaxRetries: 5,
|
|
},
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
sameNewFileProviderHash, _ := sameNewFileConfig.GenerateConfigHash("bedrock")
|
|
|
|
currentDBConfig := providersInDB["bedrock"]
|
|
if currentDBConfig.ConfigHash != sameNewFileProviderHash {
|
|
t.Errorf("Expected hash match on same config reload. DB: %s, File: %s",
|
|
currentDBConfig.ConfigHash[:16], sameNewFileProviderHash[:16])
|
|
}
|
|
|
|
t.Log("Step 6 - Hash matches on reload, no update needed ✓")
|
|
}
|
|
|
|
// TestProviderHashComparison_AzureNewProviderFromConfig tests adding new Azure provider from config.json when not in DB
|
|
func TestProviderHashComparison_AzureNewProviderFromConfig(t *testing.T) {
|
|
// === Scenario: Azure provider not in DB, but present in config.json ===
|
|
// Expected: Provider should be added to DB with new hash
|
|
|
|
// Empty DB - no Azure provider
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{}
|
|
|
|
// File has Azure provider with Azure key config
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
fileHash, err := fileConfig.GenerateConfigHash("azure")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Simulate: check if provider exists in DB
|
|
if _, exists := providersInDB["azure"]; !exists {
|
|
// Provider not in DB - add from file
|
|
providersInDB["azure"] = fileConfig
|
|
t.Log("✓ Azure provider not in DB - added from config.json")
|
|
}
|
|
|
|
// Verify provider was added
|
|
addedConfig, exists := providersInDB["azure"]
|
|
if !exists {
|
|
t.Fatal("Expected Azure provider to be added to DB")
|
|
}
|
|
|
|
// Verify hash was set
|
|
if addedConfig.ConfigHash != fileHash {
|
|
t.Error("Expected config hash to be set from file")
|
|
}
|
|
|
|
// Verify Azure key config is present
|
|
if len(addedConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(addedConfig.Keys))
|
|
}
|
|
if addedConfig.Keys[0].AzureKeyConfig == nil {
|
|
t.Fatal("Expected AzureKeyConfig to be present")
|
|
}
|
|
if addedConfig.Keys[0].AzureKeyConfig.Endpoint.GetValue() != "https://myazure.openai.azure.com" {
|
|
t.Errorf("Expected Azure endpoint, got %v", addedConfig.Keys[0].AzureKeyConfig.Endpoint)
|
|
}
|
|
|
|
t.Log("✓ New Azure provider added to DB with correct hash and config")
|
|
}
|
|
|
|
// TestProviderHashComparison_BedrockNewProviderFromConfig tests adding new Bedrock provider from config.json when not in DB
|
|
func TestProviderHashComparison_BedrockNewProviderFromConfig(t *testing.T) {
|
|
// === Scenario: Bedrock provider not in DB, but present in config.json ===
|
|
// Expected: Provider should be added to DB with new hash
|
|
|
|
// Empty DB - no Bedrock provider
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{}
|
|
|
|
// File has Bedrock provider with Bedrock key config
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
fileHash, err := fileConfig.GenerateConfigHash("bedrock")
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// Simulate: check if provider exists in DB
|
|
if _, exists := providersInDB["bedrock"]; !exists {
|
|
// Provider not in DB - add from file
|
|
providersInDB["bedrock"] = fileConfig
|
|
t.Log("✓ Bedrock provider not in DB - added from config.json")
|
|
}
|
|
|
|
// Verify provider was added
|
|
addedConfig, exists := providersInDB["bedrock"]
|
|
if !exists {
|
|
t.Fatal("Expected Bedrock provider to be added to DB")
|
|
}
|
|
|
|
// Verify hash was set
|
|
if addedConfig.ConfigHash != fileHash {
|
|
t.Error("Expected config hash to be set from file")
|
|
}
|
|
|
|
// Verify Bedrock key config is present
|
|
if len(addedConfig.Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(addedConfig.Keys))
|
|
}
|
|
if addedConfig.Keys[0].BedrockKeyConfig == nil {
|
|
t.Fatal("Expected BedrockKeyConfig to be present")
|
|
}
|
|
if addedConfig.Keys[0].BedrockKeyConfig.AccessKey.GetValue() != "AKIAIOSFODNN7EXAMPLE" {
|
|
t.Errorf("Expected Bedrock AccessKey, got %v", addedConfig.Keys[0].BedrockKeyConfig.AccessKey)
|
|
}
|
|
if addedConfig.Keys[0].BedrockKeyConfig.Region.GetValue() != "us-east-1" {
|
|
t.Errorf("Expected Bedrock region us-east-1, got %v", *addedConfig.Keys[0].BedrockKeyConfig.Region)
|
|
}
|
|
|
|
t.Log("✓ New Bedrock provider added to DB with correct hash and config")
|
|
}
|
|
|
|
// TestProviderHashComparison_AzureDBValuePreservedWhenHashMatches explicitly tests that DB values are NOT overwritten when hash matches
|
|
func TestProviderHashComparison_AzureDBValuePreservedWhenHashMatches(t *testing.T) {
|
|
// === Scenario: DB has Azure config with dashboard-edited value, config.json has same structure but different value ===
|
|
// Expected: Hash matches (structure same), DB value should NOT be overwritten
|
|
|
|
// DB has Azure config - key value was edited via dashboard
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("DASHBOARD-EDITED-SECRET-KEY"), // Dashboard edited this!
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
// Generate hash based on provider config structure (keys excluded from provider hash)
|
|
dbHash, _ := dbConfig.GenerateConfigHash("azure")
|
|
dbConfig.ConfigHash = dbHash
|
|
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"azure": dbConfig,
|
|
}
|
|
|
|
// File config has SAME STRUCTURE but DIFFERENT key value
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("original-key-from-file"), // Different value than DB!
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"), // Same
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"), // Same
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://myazure.openai.azure.com/openai", // Same
|
|
},
|
|
SendBackRawResponse: false, // Same
|
|
}
|
|
|
|
fileHash, _ := fileConfig.GenerateConfigHash("azure")
|
|
|
|
// === Key assertion: Provider hashes should MATCH (structure is same) ===
|
|
if dbHash != fileHash {
|
|
t.Fatalf("Expected provider hashes to match (same structure). DB: %s, File: %s", dbHash[:16], fileHash[:16])
|
|
}
|
|
t.Log("✓ Provider hashes match (same structure)")
|
|
|
|
// === Simulate sync logic: hash matches -> keep DB config ===
|
|
existingConfig := providersInDB["azure"]
|
|
if existingConfig.ConfigHash == fileHash {
|
|
// Hash matches - DO NOT overwrite DB
|
|
t.Log("✓ Hash matches - keeping DB config (not overwriting)")
|
|
} else {
|
|
t.Error("Expected hash match - should keep DB config")
|
|
}
|
|
|
|
// === Verify DB value was NOT overwritten ===
|
|
if existingConfig.Keys[0].Value.GetValue() != "DASHBOARD-EDITED-SECRET-KEY" {
|
|
t.Errorf("DB value should NOT be overwritten! Expected 'DASHBOARD-EDITED-SECRET-KEY', got '%v'",
|
|
existingConfig.Keys[0].Value)
|
|
}
|
|
t.Log("✓ DB value preserved (dashboard edit NOT overwritten)")
|
|
|
|
// === Verify Azure config in DB is intact ===
|
|
if existingConfig.Keys[0].AzureKeyConfig.Endpoint.GetValue() != "https://myazure.openai.azure.com" {
|
|
t.Error("Azure endpoint should be preserved")
|
|
}
|
|
t.Log("✓ Azure config preserved in DB")
|
|
}
|
|
|
|
// TestProviderHashComparison_BedrockDBValuePreservedWhenHashMatches explicitly tests that DB values are NOT overwritten when hash matches
|
|
func TestProviderHashComparison_BedrockDBValuePreservedWhenHashMatches(t *testing.T) {
|
|
// === Scenario: DB has Bedrock config with dashboard-edited credentials, config.json has same structure ===
|
|
// Expected: Hash matches (structure same), DB credentials should NOT be overwritten
|
|
|
|
// DB has Bedrock config - credentials were edited via dashboard
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("DASHBOARD-EDITED-ACCESS-KEY"), // Dashboard edited!
|
|
SecretKey: *schemas.NewEnvVar("DASHBOARD-EDITED-SECRET-KEY"), // Dashboard edited!
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
dbHash, _ := dbConfig.GenerateConfigHash("bedrock")
|
|
dbConfig.ConfigHash = dbHash
|
|
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"bedrock": dbConfig,
|
|
}
|
|
|
|
// File config has SAME STRUCTURE but DIFFERENT credentials
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "anthropic.claude-3-sonnet-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"), // Different!
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), // Different!
|
|
Region: schemas.NewEnvVar("us-east-1"), // Same
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com", // Same
|
|
MaxRetries: 3, // Same
|
|
},
|
|
SendBackRawResponse: false, // Same
|
|
}
|
|
|
|
fileHash, _ := fileConfig.GenerateConfigHash("bedrock")
|
|
|
|
// === Key assertion: Provider hashes should MATCH (structure is same) ===
|
|
if dbHash != fileHash {
|
|
t.Fatalf("Expected provider hashes to match (same structure). DB: %s, File: %s", dbHash[:16], fileHash[:16])
|
|
}
|
|
t.Log("✓ Provider hashes match (same structure)")
|
|
|
|
// === Simulate sync logic: hash matches -> keep DB config ===
|
|
existingConfig := providersInDB["bedrock"]
|
|
if existingConfig.ConfigHash == fileHash {
|
|
t.Log("✓ Hash matches - keeping DB config (not overwriting)")
|
|
} else {
|
|
t.Error("Expected hash match - should keep DB config")
|
|
}
|
|
|
|
// === Verify DB credentials were NOT overwritten ===
|
|
if existingConfig.Keys[0].BedrockKeyConfig.AccessKey.GetValue() != "DASHBOARD-EDITED-ACCESS-KEY" {
|
|
t.Errorf("DB AccessKey should NOT be overwritten! Expected 'DASHBOARD-EDITED-ACCESS-KEY', got '%v'",
|
|
existingConfig.Keys[0].BedrockKeyConfig.AccessKey)
|
|
}
|
|
if existingConfig.Keys[0].BedrockKeyConfig.SecretKey.GetValue() != "DASHBOARD-EDITED-SECRET-KEY" {
|
|
t.Errorf("DB SecretKey should NOT be overwritten! Expected 'DASHBOARD-EDITED-SECRET-KEY', got '%v'",
|
|
existingConfig.Keys[0].BedrockKeyConfig.SecretKey)
|
|
}
|
|
t.Log("✓ DB credentials preserved (dashboard edits NOT overwritten)")
|
|
|
|
// === Verify Bedrock config in DB is intact ===
|
|
if existingConfig.Keys[0].BedrockKeyConfig.Region.GetValue() != "us-east-1" {
|
|
t.Error("Bedrock region should be preserved")
|
|
}
|
|
t.Log("✓ Bedrock config preserved in DB")
|
|
}
|
|
|
|
// TestProviderHashComparison_AzureConfigChangedInFile tests that DB updates when config.json Azure config changes
|
|
func TestProviderHashComparison_AzureConfigChangedInFile(t *testing.T) {
|
|
// === Scenario: DB has Azure config, config.json has DIFFERENT Azure config ===
|
|
// Expected: Hash differs, DB should be updated with new config and hash
|
|
|
|
// DB has existing Azure config
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://old-azure.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar("2024-02-01"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://old-azure.openai.azure.com/openai",
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
dbHash, _ := dbConfig.GenerateConfigHash("azure")
|
|
dbConfig.ConfigHash = dbHash
|
|
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"azure": dbConfig,
|
|
}
|
|
|
|
// File has CHANGED Azure config (new endpoint, new API version)
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "azure-key-1",
|
|
Name: "azure-openai-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4o": "gpt-4o-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://NEW-azure.openai.azure.com"), // Changed!
|
|
APIVersion: schemas.NewEnvVar("2024-10-21"), // Changed!
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://NEW-azure.openai.azure.com/openai", // Changed!
|
|
},
|
|
SendBackRawResponse: true, // Changed!
|
|
}
|
|
|
|
fileHash, _ := fileConfig.GenerateConfigHash("azure")
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// === Key assertion: Hashes should DIFFER (config changed) ===
|
|
existingConfig := providersInDB["azure"]
|
|
if existingConfig.ConfigHash == fileHash {
|
|
t.Fatal("Expected hashes to DIFFER (config changed)")
|
|
}
|
|
t.Log("✓ Hashes differ (config changed in file)")
|
|
|
|
// === Simulate sync: hash differs -> update DB from file ===
|
|
providersInDB["azure"] = fileConfig
|
|
t.Log("✓ DB updated from config.json")
|
|
|
|
// === Verify DB was updated ===
|
|
updatedConfig := providersInDB["azure"]
|
|
|
|
if updatedConfig.ConfigHash != fileHash {
|
|
t.Error("Expected DB hash to be updated")
|
|
}
|
|
if updatedConfig.Keys[0].AzureKeyConfig.Endpoint.GetValue() != "https://NEW-azure.openai.azure.com" {
|
|
t.Errorf("Expected new Azure endpoint, got %v", updatedConfig.Keys[0].AzureKeyConfig.Endpoint)
|
|
}
|
|
if updatedConfig.Keys[0].AzureKeyConfig.APIVersion.GetValue() != "2024-10-21" {
|
|
t.Errorf("Expected new API version, got %v", *updatedConfig.Keys[0].AzureKeyConfig.APIVersion)
|
|
}
|
|
if !updatedConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be updated to true")
|
|
}
|
|
|
|
t.Log("✓ DB updated with new Azure config and hash")
|
|
}
|
|
|
|
// TestProviderHashComparison_BedrockConfigChangedInFile tests that DB updates when config.json Bedrock config changes
|
|
func TestProviderHashComparison_BedrockConfigChangedInFile(t *testing.T) {
|
|
// === Scenario: DB has Bedrock config, config.json has DIFFERENT Bedrock config ===
|
|
// Expected: Hash differs, DB should be updated with new config and hash
|
|
|
|
// DB has existing Bedrock config
|
|
dbConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
MaxRetries: 3,
|
|
},
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
dbHash, _ := dbConfig.GenerateConfigHash("bedrock")
|
|
dbConfig.ConfigHash = dbHash
|
|
|
|
providersInDB := map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"bedrock": dbConfig,
|
|
}
|
|
|
|
// File has CHANGED Bedrock config (new region, new ARN)
|
|
fileConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: "bedrock-key-1",
|
|
Name: "aws-bedrock-key",
|
|
Value: *schemas.NewEnvVar(""),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3-opus": "anthropic.claude-3-opus-20240229-v1:0"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
|
|
Region: schemas.NewEnvVar("us-west-2"), // Changed!
|
|
ARN: schemas.NewEnvVar("arn:aws:bedrock:us-west-2:123456789012:inference-profile/new-profile"), // Added!
|
|
},
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://bedrock-runtime.us-west-2.amazonaws.com", // Changed!
|
|
MaxRetries: 5, // Changed!
|
|
},
|
|
SendBackRawResponse: true, // Changed!
|
|
}
|
|
|
|
fileHash, _ := fileConfig.GenerateConfigHash("bedrock")
|
|
fileConfig.ConfigHash = fileHash
|
|
|
|
// === Key assertion: Hashes should DIFFER (config changed) ===
|
|
existingConfig := providersInDB["bedrock"]
|
|
if existingConfig.ConfigHash == fileHash {
|
|
t.Fatal("Expected hashes to DIFFER (config changed)")
|
|
}
|
|
t.Log("✓ Hashes differ (config changed in file)")
|
|
|
|
// === Simulate sync: hash differs -> update DB from file ===
|
|
providersInDB["bedrock"] = fileConfig
|
|
t.Log("✓ DB updated from config.json")
|
|
|
|
// === Verify DB was updated ===
|
|
updatedConfig := providersInDB["bedrock"]
|
|
|
|
if updatedConfig.ConfigHash != fileHash {
|
|
t.Error("Expected DB hash to be updated")
|
|
}
|
|
if updatedConfig.Keys[0].BedrockKeyConfig.Region.GetValue() != "us-west-2" {
|
|
t.Errorf("Expected new Bedrock region, got %v", *updatedConfig.Keys[0].BedrockKeyConfig.Region)
|
|
}
|
|
if updatedConfig.Keys[0].BedrockKeyConfig.ARN == nil {
|
|
t.Error("Expected ARN to be set")
|
|
}
|
|
if updatedConfig.NetworkConfig.MaxRetries != 5 {
|
|
t.Errorf("Expected MaxRetries to be 5, got %d", updatedConfig.NetworkConfig.MaxRetries)
|
|
}
|
|
if !updatedConfig.SendBackRawResponse {
|
|
t.Error("Expected SendBackRawResponse to be updated to true")
|
|
}
|
|
|
|
t.Log("✓ DB updated with new Bedrock config and hash")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// VIRTUAL KEY HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateVirtualKeyHash tests that virtual key hash is generated correctly
|
|
func TestGenerateVirtualKeyHash(t *testing.T) {
|
|
// Create a virtual key
|
|
teamID := "team-1"
|
|
vk1 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
// Generate hash
|
|
hash1, err := configstore.GenerateVirtualKeyHash(vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same virtual key content with different ID should produce same hash (ID is skipped)
|
|
vk2 := tables.TableVirtualKey{
|
|
ID: "different-id", // Different ID - should be skipped
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 != hash2 {
|
|
t.Error("Expected same hash for virtual keys with same content (ID should be skipped)")
|
|
}
|
|
|
|
// Different name should produce different hash
|
|
vk3 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "different-name", // Different name
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hash3, err := configstore.GenerateVirtualKeyHash(vk3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash for virtual keys with different Name")
|
|
}
|
|
|
|
// Different value should produce different hash
|
|
vk4 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_different", // Different value
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hash4, err := configstore.GenerateVirtualKeyHash(vk4)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash4 {
|
|
t.Error("Expected different hash for virtual keys with different Value")
|
|
}
|
|
|
|
// Different IsActive should produce different hash
|
|
vk5 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: false, // Different IsActive
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hash5, err := configstore.GenerateVirtualKeyHash(vk5)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash5 {
|
|
t.Error("Expected different hash for virtual keys with different IsActive")
|
|
}
|
|
|
|
// Different TeamID should produce different hash
|
|
differentTeamID := "team-2"
|
|
vk6 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &differentTeamID, // Different TeamID
|
|
}
|
|
|
|
hash6, err := configstore.GenerateVirtualKeyHash(vk6)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash6 {
|
|
t.Error("Expected different hash for virtual keys with different TeamID")
|
|
}
|
|
|
|
// Different Description should produce different hash
|
|
vk7 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Different description", // Different description
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hash7, err := configstore.GenerateVirtualKeyHash(vk7)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash7 {
|
|
t.Error("Expected different hash for virtual keys with different Description")
|
|
}
|
|
|
|
// Different CustomerID should produce different hash
|
|
customerID := "customer-1"
|
|
vk8 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
CustomerID: &customerID, // CustomerID set
|
|
}
|
|
|
|
hash8, err := configstore.GenerateVirtualKeyHash(vk8)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash8 {
|
|
t.Error("Expected different hash for virtual keys with CustomerID set")
|
|
}
|
|
|
|
// Different CustomerID value should produce different hash
|
|
differentCustomerID := "customer-2"
|
|
vk8b := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
CustomerID: &differentCustomerID, // Different CustomerID
|
|
}
|
|
|
|
hash8b, err := configstore.GenerateVirtualKeyHash(vk8b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash8 == hash8b {
|
|
t.Error("Expected different hash for virtual keys with different CustomerID values")
|
|
}
|
|
|
|
// RateLimitID should produce different hash
|
|
rateLimitID := "ratelimit-1"
|
|
vk10 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
RateLimitID: &rateLimitID, // RateLimitID set
|
|
}
|
|
|
|
hash10, err := configstore.GenerateVirtualKeyHash(vk10)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash10 {
|
|
t.Error("Expected different hash for virtual keys with RateLimitID set")
|
|
}
|
|
|
|
// Different RateLimitID value should produce different hash
|
|
differentRateLimitID := "ratelimit-2"
|
|
vk10b := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
RateLimitID: &differentRateLimitID, // Different RateLimitID
|
|
}
|
|
|
|
hash10b, err := configstore.GenerateVirtualKeyHash(vk10b)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash10 == hash10b {
|
|
t.Error("Expected different hash for virtual keys with different RateLimitID values")
|
|
}
|
|
|
|
t.Log("✓ VirtualKey hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_WithProviderConfigs tests hash generation with provider configs
|
|
func TestGenerateVirtualKeyHash_WithProviderConfigs(t *testing.T) {
|
|
rateLimitID := "rl-pc-1"
|
|
|
|
// Virtual key with provider configs
|
|
vk1 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
RateLimitID: &rateLimitID,
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-1", Name: "key-1"},
|
|
{KeyID: "key-2", Name: "key-2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateVirtualKeyHash(vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Different provider configs should produce different hash
|
|
vk2 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "anthropic", // Different provider
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"claude-3"},
|
|
RateLimitID: &rateLimitID,
|
|
},
|
|
},
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash2 {
|
|
t.Error("Expected different hash for virtual keys with different ProviderConfigs")
|
|
}
|
|
|
|
// Same provider configs with different weight should produce different hash
|
|
vk3 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.0), // Different weight
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
RateLimitID: &rateLimitID,
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-1", Name: "key-1"},
|
|
{KeyID: "key-2", Name: "key-2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash3, err := configstore.GenerateVirtualKeyHash(vk3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash for virtual keys with different ProviderConfigs weight")
|
|
}
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_WithMCPConfigs tests hash generation with MCP configs
|
|
func TestGenerateVirtualKeyHash_WithMCPConfigs(t *testing.T) {
|
|
// Virtual key with MCP configs
|
|
vk1 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateVirtualKeyHash(vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Different MCP configs should produce different hash
|
|
vk2 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 2, // Different MCP client ID
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash2 {
|
|
t.Error("Expected different hash for virtual keys with different MCPConfigs")
|
|
}
|
|
|
|
// Different tools should produce different hash
|
|
vk3 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool3"}, // Different tools
|
|
},
|
|
},
|
|
}
|
|
|
|
hash3, err := configstore.GenerateVirtualKeyHash(vk3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash for virtual keys with different MCPConfigs tools")
|
|
}
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_MatchingHash tests that DB config is kept when hashes match
|
|
func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) {
|
|
teamID := "team-1"
|
|
|
|
// Create a virtual key (simulating what's in config.json)
|
|
fileVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
// Generate file hash
|
|
fileHash, err := configstore.GenerateVirtualKeyHash(fileVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate file hash: %v", err)
|
|
}
|
|
|
|
// Create DB virtual key with same content (simulating existing DB record)
|
|
dbTeamID := "team-1"
|
|
dbVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &dbTeamID,
|
|
ConfigHash: fileHash, // Same hash as file
|
|
}
|
|
|
|
// Verify hashes match
|
|
dbHash, err := configstore.GenerateVirtualKeyHash(dbVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate DB hash: %v", err)
|
|
}
|
|
|
|
if fileHash != dbHash {
|
|
t.Error("Expected hashes to match for same content")
|
|
}
|
|
|
|
// When hash matches, DB config should be kept
|
|
if dbVK.ConfigHash != fileHash {
|
|
t.Error("Expected DB config hash to match file hash")
|
|
}
|
|
|
|
t.Log("✓ Matching hashes correctly detected - DB config would be kept")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_DifferentHash tests that file config is used when hashes differ
|
|
func TestVirtualKeyHashComparison_DifferentHash(t *testing.T) {
|
|
teamID := "team-1"
|
|
|
|
// Create DB virtual key with old config
|
|
dbVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "old-name", // Old name
|
|
Description: "Old description",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
dbHash, err := configstore.GenerateVirtualKeyHash(dbVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate DB hash: %v", err)
|
|
}
|
|
dbVK.ConfigHash = dbHash
|
|
|
|
// Create file virtual key with updated config
|
|
fileTeamID := "team-1"
|
|
fileVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "new-name", // Updated name
|
|
Description: "New description",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &fileTeamID,
|
|
}
|
|
|
|
fileHash, err := configstore.GenerateVirtualKeyHash(fileVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate file hash: %v", err)
|
|
}
|
|
|
|
// Hashes should differ
|
|
if dbHash == fileHash {
|
|
t.Error("Expected hashes to differ for different content")
|
|
}
|
|
|
|
// When hash differs, file config should be used
|
|
t.Logf("DB hash: %s", dbHash)
|
|
t.Logf("File hash: %s", fileHash)
|
|
t.Log("✓ Different hashes correctly detected - file config would be synced to DB")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_VirtualKeyOnlyInDB tests that dashboard-added VK is preserved
|
|
func TestVirtualKeyHashComparison_VirtualKeyOnlyInDB(t *testing.T) {
|
|
customerID := "customer-1"
|
|
rateLimitID := "rl-1"
|
|
|
|
// Create a dashboard-added virtual key (not in config.json)
|
|
dashboardVK := tables.TableVirtualKey{
|
|
ID: "vk-dashboard",
|
|
Name: "dashboard-vk",
|
|
Description: "Added via dashboard",
|
|
Value: "vk_dashboard123",
|
|
IsActive: true,
|
|
CustomerID: &customerID,
|
|
RateLimitID: &rateLimitID,
|
|
}
|
|
|
|
dashboardHash, err := configstore.GenerateVirtualKeyHash(dashboardVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate dashboard hash: %v", err)
|
|
}
|
|
dashboardVK.ConfigHash = dashboardHash
|
|
|
|
// Config.json has different virtual keys
|
|
fileVKs := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-file",
|
|
Name: "file-vk",
|
|
Description: "From config.json",
|
|
Value: "vk_file123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
|
|
// Dashboard VK should not be found in file VKs
|
|
found := false
|
|
for _, fileVK := range fileVKs {
|
|
if fileVK.ID == dashboardVK.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if found {
|
|
t.Error("Expected dashboard VK to not be found in file VKs")
|
|
}
|
|
|
|
t.Log("✓ Dashboard-added virtual key preserved (not in config.json)")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_NewVirtualKey tests that new VK is added from file
|
|
func TestVirtualKeyHashComparison_NewVirtualKey(t *testing.T) {
|
|
teamID := "team-new"
|
|
|
|
// Create a new virtual key in config.json
|
|
newFileVK := tables.TableVirtualKey{
|
|
ID: "vk-new",
|
|
Name: "new-vk",
|
|
Description: "New virtual key from config.json",
|
|
Value: "vk_new123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
newHash, err := configstore.GenerateVirtualKeyHash(newFileVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate new VK hash: %v", err)
|
|
}
|
|
newFileVK.ConfigHash = newHash
|
|
|
|
// DB has no virtual keys
|
|
dbVKs := []tables.TableVirtualKey{}
|
|
|
|
// New VK should not be found in DB
|
|
found := false
|
|
for _, dbVK := range dbVKs {
|
|
if dbVK.ID == newFileVK.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if found {
|
|
t.Error("Expected new VK to not be found in DB")
|
|
}
|
|
|
|
// Hash should be set for new VK
|
|
if newFileVK.ConfigHash == "" {
|
|
t.Error("Expected new VK to have hash set")
|
|
}
|
|
|
|
t.Log("✓ New virtual key would be added from config.json with hash")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_OptionalFieldsPresence tests hash with optional fields
|
|
func TestVirtualKeyHashComparison_OptionalFieldsPresence(t *testing.T) {
|
|
// Virtual key with no optional fields
|
|
vkNoOptional := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
}
|
|
|
|
hashNoOptional, err := configstore.GenerateVirtualKeyHash(vkNoOptional)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
// Virtual key with team_id
|
|
teamID := "team-1"
|
|
vkWithTeam := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
hashWithTeam, err := configstore.GenerateVirtualKeyHash(vkWithTeam)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithTeam {
|
|
t.Error("Expected different hash when team_id is added")
|
|
}
|
|
|
|
// Virtual key with customer_id
|
|
customerID := "customer-1"
|
|
vkWithCustomer := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
CustomerID: &customerID,
|
|
}
|
|
|
|
hashWithCustomer, err := configstore.GenerateVirtualKeyHash(vkWithCustomer)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithCustomer {
|
|
t.Error("Expected different hash when customer_id is added")
|
|
}
|
|
|
|
if hashWithTeam == hashWithCustomer {
|
|
t.Error("Expected different hash for team_id vs customer_id")
|
|
}
|
|
|
|
// Virtual key with rate_limit_id
|
|
rateLimitID := "rl-1"
|
|
vkWithRateLimit := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
RateLimitID: &rateLimitID,
|
|
}
|
|
|
|
hashWithRateLimit, err := configstore.GenerateVirtualKeyHash(vkWithRateLimit)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if hashNoOptional == hashWithRateLimit {
|
|
t.Error("Expected different hash when rate_limit_id is added")
|
|
}
|
|
|
|
t.Log("✓ Optional fields correctly affect hash generation")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_FieldValueChanges tests hash changes when field values change
|
|
func TestVirtualKeyHashComparison_FieldValueChanges(t *testing.T) {
|
|
teamID := "team-1"
|
|
|
|
// Base virtual key
|
|
baseVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Base description",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
}
|
|
|
|
baseHash, err := configstore.GenerateVirtualKeyHash(baseVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate base hash: %v", err)
|
|
}
|
|
|
|
// Change description
|
|
vkChangedDesc := baseVK
|
|
vkChangedDesc.Description = "Changed description"
|
|
|
|
hashChangedDesc, err := configstore.GenerateVirtualKeyHash(vkChangedDesc)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if baseHash == hashChangedDesc {
|
|
t.Error("Expected different hash when description changes")
|
|
}
|
|
|
|
// Change IsActive
|
|
vkChangedActive := baseVK
|
|
vkChangedActive.IsActive = false
|
|
|
|
hashChangedActive, err := configstore.GenerateVirtualKeyHash(vkChangedActive)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if baseHash == hashChangedActive {
|
|
t.Error("Expected different hash when IsActive changes")
|
|
}
|
|
|
|
// Change TeamID value
|
|
newTeamID := "team-2"
|
|
vkChangedTeam := baseVK
|
|
vkChangedTeam.TeamID = &newTeamID
|
|
|
|
hashChangedTeam, err := configstore.GenerateVirtualKeyHash(vkChangedTeam)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
|
|
if baseHash == hashChangedTeam {
|
|
t.Error("Expected different hash when TeamID value changes")
|
|
}
|
|
|
|
t.Log("✓ Field value changes correctly detected in hash")
|
|
}
|
|
|
|
// TestVirtualKeyHashComparison_RoundTrip tests JSON → DB → same JSON produces no changes
|
|
func TestVirtualKeyHashComparison_RoundTrip(t *testing.T) {
|
|
teamID := "team-1"
|
|
rateLimitID := "rl-1"
|
|
|
|
// Original config.json virtual key
|
|
originalVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &teamID,
|
|
RateLimitID: &rateLimitID,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Generate hash and store in "DB"
|
|
originalHash, err := configstore.GenerateVirtualKeyHash(originalVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate original hash: %v", err)
|
|
}
|
|
originalVK.ConfigHash = originalHash
|
|
|
|
// Simulate DB storage and retrieval
|
|
dbVK := originalVK
|
|
|
|
// Same config.json on reload (simulating app restart)
|
|
reloadTeamID := "team-1"
|
|
reloadRateLimitID := "rl-1"
|
|
reloadVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
TeamID: &reloadTeamID,
|
|
RateLimitID: &reloadRateLimitID,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Generate hash for reload
|
|
reloadHash, err := configstore.GenerateVirtualKeyHash(reloadVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate reload hash: %v", err)
|
|
}
|
|
|
|
// Hashes should match - no update needed
|
|
if dbVK.ConfigHash != reloadHash {
|
|
t.Errorf("Expected hashes to match on round-trip: DB=%s, reload=%s", dbVK.ConfigHash, reloadHash)
|
|
}
|
|
|
|
t.Log("✓ Round-trip produces matching hashes - no unnecessary DB updates")
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Tests - Provider Hash Scenarios
|
|
// =============================================================================
|
|
|
|
// TestSQLite_Provider_NewProviderFromFile tests that a new provider in config.json is added to an empty DB
|
|
func TestSQLite_Provider_NewProviderFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a new provider
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config - this should create the provider in the DB
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify provider exists in memory
|
|
if _, exists := config.Providers[schemas.OpenAI]; !exists {
|
|
t.Fatal("OpenAI provider not found in memory")
|
|
}
|
|
|
|
// Verify provider exists in DB
|
|
verifyProviderInDB(t, config.ConfigStore, schemas.OpenAI, 1)
|
|
|
|
// Verify the hash was set
|
|
dbProviders, err := config.ConfigStore.GetProvidersConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get providers from DB: %v", err)
|
|
}
|
|
if dbProviders[schemas.OpenAI].ConfigHash == "" {
|
|
t.Error("Expected config hash to be set for new provider")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Provider_HashMatch_DBPreserved tests that DB config is preserved when hashes match
|
|
func TestSQLite_Provider_HashMatch_DBPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a provider
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load - creates provider in DB
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get the hash from first load
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
firstHash := dbProviders1[schemas.OpenAI].ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json - should preserve DB config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify hash is still the same
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
secondHash := dbProviders2[schemas.OpenAI].ConfigHash
|
|
|
|
if firstHash != secondHash {
|
|
t.Errorf("Expected hash to remain unchanged on reload: first=%s, second=%s", firstHash, secondHash)
|
|
}
|
|
|
|
// Verify provider still has 1 key
|
|
verifyProviderInDB(t, config2.ConfigStore, schemas.OpenAI, 1)
|
|
}
|
|
|
|
// TestSQLite_Provider_HashMismatch_FileSync tests that file config is synced when hashes differ
|
|
func TestSQLite_Provider_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a provider
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get original hash
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
originalHash := dbProviders1[schemas.OpenAI].ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Modify config.json - change the BaseURL
|
|
providers2 := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com/v2"),
|
|
}
|
|
configData2 := makeConfigDataWithProvidersAndDir(providers2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load with modified config.json - should sync from file
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify hash changed
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
newHash := dbProviders2[schemas.OpenAI].ConfigHash
|
|
|
|
if originalHash == newHash {
|
|
t.Error("Expected hash to change when config.json is modified")
|
|
}
|
|
|
|
// Verify the new BaseURL is in memory
|
|
if config2.Providers[schemas.OpenAI].NetworkConfig.BaseURL != "https://api.openai.com/v2" {
|
|
t.Errorf("Expected BaseURL to be updated, got %s", config2.Providers[schemas.OpenAI].NetworkConfig.BaseURL)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Provider_DBOnlyProvider_Preserved tests that provider added via DB is preserved
|
|
func TestSQLite_Provider_DBOnlyProvider_Preserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with OpenAI provider only
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Add Anthropic provider directly to DB (simulating dashboard addition)
|
|
anthropicConfig := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: uuid.NewString(),
|
|
Name: "anthropic-key-1",
|
|
Value: *schemas.NewEnvVar("sk-anthropic-123"),
|
|
Weight: 1,
|
|
},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.anthropic.com",
|
|
},
|
|
}
|
|
anthropicHash, _ := anthropicConfig.GenerateConfigHash("anthropic")
|
|
anthropicConfig.ConfigHash = anthropicHash
|
|
|
|
// Update providers in DB to include both
|
|
existingProviders, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
existingProviders[schemas.Anthropic] = anthropicConfig
|
|
err = config1.ConfigStore.UpdateProvidersConfig(ctx, existingProviders)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add Anthropic to DB: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json (no Anthropic) - should preserve DB-added provider
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify both providers exist
|
|
if _, exists := config2.Providers[schemas.OpenAI]; !exists {
|
|
t.Error("OpenAI provider should exist (from file)")
|
|
}
|
|
if _, exists := config2.Providers[schemas.Anthropic]; !exists {
|
|
t.Error("Anthropic provider should be preserved (added via DB)")
|
|
}
|
|
|
|
// Verify both in DB
|
|
verifyProviderInDB(t, config2.ConfigStore, schemas.OpenAI, 1)
|
|
verifyProviderInDB(t, config2.ConfigStore, schemas.Anthropic, 1)
|
|
}
|
|
|
|
// TestSQLite_Provider_RoundTrip tests load -> modify via DB -> reload same file -> no changes
|
|
func TestSQLite_Provider_RoundTrip(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get original state
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
originalHash := dbProviders1[schemas.OpenAI].ConfigHash
|
|
originalKeyValue := dbProviders1[schemas.OpenAI].Keys[0].Value
|
|
|
|
// Modify key value in DB (simulating dashboard edit)
|
|
dbProviders1[schemas.OpenAI].Keys[0].Value = *schemas.NewEnvVar("sk-dashboard-modified")
|
|
// Note: We keep the same hash since only the key value changed, not provider config
|
|
err = config1.ConfigStore.UpdateProvidersConfig(ctx, dbProviders1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update provider in DB: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json - should preserve DB changes since hash matches
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify hash is unchanged
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders2[schemas.OpenAI].ConfigHash != originalHash {
|
|
t.Error("Hash should remain unchanged when config.json hasn't changed")
|
|
}
|
|
|
|
// The key value should be from DB (dashboard edit preserved) since hash matches
|
|
// This demonstrates that when hashes match, DB config is kept
|
|
if dbProviders2[schemas.OpenAI].Keys[0].Value == originalKeyValue {
|
|
t.Log("Note: Key value preserved from initial load (hash match means DB preserved)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Tests - Provider Key Hash Scenarios
|
|
// =============================================================================
|
|
|
|
// TestSQLite_Key_NewKeyFromFile tests that a new key in config.json provider is added to DB
|
|
func TestSQLite_Key_NewKeyFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a provider and one key
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key-1", Value: *schemas.NewEnvVar("sk-key1-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify the key exists
|
|
dbProviders, _ := config.ConfigStore.GetProvidersConfig(ctx)
|
|
if len(dbProviders[schemas.OpenAI].Keys) != 1 {
|
|
t.Errorf("Expected 1 key, got %d", len(dbProviders[schemas.OpenAI].Keys))
|
|
}
|
|
if dbProviders[schemas.OpenAI].Keys[0].Name != "openai-key-1" {
|
|
t.Errorf("Expected key name 'openai-key-1', got '%s'", dbProviders[schemas.OpenAI].Keys[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_HashMatch_DBKeyPreserved tests that DB key is preserved when unchanged
|
|
func TestSQLite_Key_HashMatch_DBKeyPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "key-1", Value: *schemas.NewEnvVar("sk-key1-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get original key ID
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
originalKeyID := dbProviders1[schemas.OpenAI].Keys[0].ID
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify key ID is preserved (same key, not recreated)
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders2[schemas.OpenAI].Keys[0].ID != originalKeyID {
|
|
t.Errorf("Expected key ID to be preserved on reload: got %s, want %s",
|
|
dbProviders2[schemas.OpenAI].Keys[0].ID, originalKeyID)
|
|
}
|
|
|
|
// Key should still be present
|
|
if len(dbProviders2[schemas.OpenAI].Keys) != 1 {
|
|
t.Errorf("Expected 1 key after reload, got %d", len(dbProviders2[schemas.OpenAI].Keys))
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_DashboardAddedKey_Preserved tests that key added via DB is preserved on reload
|
|
func TestSQLite_Key_DashboardAddedKey_Preserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with one key
|
|
keyID1 := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID1, Name: "file-key", Value: *schemas.NewEnvVar("sk-file-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Add a second key directly to DB (simulating dashboard addition)
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
openaiConfig := dbProviders1[schemas.OpenAI]
|
|
openaiConfig.Keys = append(openaiConfig.Keys, schemas.Key{
|
|
ID: uuid.NewString(),
|
|
Name: "dashboard-key",
|
|
Value: *schemas.NewEnvVar("sk-dashboard-456"),
|
|
Weight: 1,
|
|
})
|
|
dbProviders1[schemas.OpenAI] = openaiConfig
|
|
err = config1.ConfigStore.UpdateProvidersConfig(ctx, dbProviders1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add dashboard key: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json (still has only file-key)
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify both keys exist (dashboard-added key preserved)
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if len(dbProviders2[schemas.OpenAI].Keys) != 2 {
|
|
t.Errorf("Expected 2 keys (1 from file + 1 from dashboard), got %d", len(dbProviders2[schemas.OpenAI].Keys))
|
|
}
|
|
|
|
// Check key names
|
|
keyNames := make(map[string]bool)
|
|
for _, k := range dbProviders2[schemas.OpenAI].Keys {
|
|
keyNames[k.Name] = true
|
|
}
|
|
if !keyNames["file-key"] {
|
|
t.Error("Expected file-key to be present")
|
|
}
|
|
if !keyNames["dashboard-key"] {
|
|
t.Error("Expected dashboard-key to be preserved")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_KeyValueChange_Detected tests that key value change in file is detected via hash
|
|
func TestSQLite_Key_KeyValueChange_Detected(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a key
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "test-key", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify original value
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders1[schemas.OpenAI].Keys[0].Value.GetValue() != "sk-original-123" {
|
|
t.Errorf("Expected original key value, got %v", dbProviders1[schemas.OpenAI].Keys[0].Value)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Modify config.json - change key value AND network config to trigger hash mismatch
|
|
providers2 := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "test-key", Value: *schemas.NewEnvVar("sk-modified-456"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com/v2"}, // Changed to trigger hash mismatch
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithProvidersAndDir(providers2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load with modified config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// When hash mismatches (provider config changed), file key value should be synced
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders2[schemas.OpenAI].Keys[0].Value.GetValue() != "sk-modified-456" {
|
|
t.Errorf("Expected key value to be updated to 'sk-modified-456', got '%v'", dbProviders2[schemas.OpenAI].Keys[0].Value)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_MultipleKeys_MergeLogic tests that multiple keys merge correctly
|
|
func TestSQLite_Key_MultipleKeys_MergeLogic(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with two keys
|
|
keyID1 := uuid.NewString()
|
|
keyID2 := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID1, Name: "key-1", Value: *schemas.NewEnvVar("sk-key1-123"), Weight: 1},
|
|
{ID: keyID2, Name: "key-2", Value: *schemas.NewEnvVar("sk-key2-456"), Weight: 2},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify two keys exist
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
if len(dbProviders1[schemas.OpenAI].Keys) != 2 {
|
|
t.Errorf("Expected 2 keys, got %d", len(dbProviders1[schemas.OpenAI].Keys))
|
|
}
|
|
|
|
// Add a third key via dashboard
|
|
openaiConfig := dbProviders1[schemas.OpenAI]
|
|
openaiConfig.Keys = append(openaiConfig.Keys, schemas.Key{
|
|
ID: uuid.NewString(),
|
|
Name: "key-3-dashboard",
|
|
Value: *schemas.NewEnvVar("sk-key3-789"),
|
|
Weight: 1,
|
|
})
|
|
dbProviders1[schemas.OpenAI] = openaiConfig
|
|
err = config1.ConfigStore.UpdateProvidersConfig(ctx, dbProviders1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add third key: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json (still has key-1 and key-2)
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify all three keys exist
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if len(dbProviders2[schemas.OpenAI].Keys) != 3 {
|
|
t.Errorf("Expected 3 keys after merge, got %d", len(dbProviders2[schemas.OpenAI].Keys))
|
|
}
|
|
|
|
// Verify key names
|
|
keyNames := make(map[string]bool)
|
|
for _, k := range dbProviders2[schemas.OpenAI].Keys {
|
|
keyNames[k.Name] = true
|
|
}
|
|
if !keyNames["key-1"] || !keyNames["key-2"] || !keyNames["key-3-dashboard"] {
|
|
t.Errorf("Expected all three keys, got: %v", keyNames)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Tests - Virtual Key Hash Scenarios
|
|
// =============================================================================
|
|
|
|
// TestSQLite_VirtualKey_NewFromFile tests that a new VK in config.json is added to DB with hash
|
|
func TestSQLite_VirtualKey_NewFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with providers and a virtual key
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "test-vk", "vk_test123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify virtual key exists in DB
|
|
dbVK := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
|
|
// Verify hash was set
|
|
if dbVK.ConfigHash == "" {
|
|
t.Error("Expected config hash to be set for new virtual key")
|
|
}
|
|
|
|
// Verify virtual key is in memory config
|
|
if config.GovernanceConfig == nil || len(config.GovernanceConfig.VirtualKeys) == 0 {
|
|
t.Fatal("Expected virtual key in governance config")
|
|
}
|
|
if config.GovernanceConfig.VirtualKeys[0].Name != "test-vk" {
|
|
t.Errorf("Expected VK name 'test-vk', got '%s'", config.GovernanceConfig.VirtualKeys[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_HashMatch_DBPreserved tests that DB VK is preserved when hash matches
|
|
func TestSQLite_VirtualKey_HashMatch_DBPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a virtual key
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "test-vk", "vk_test123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get original hash
|
|
dbVK1 := verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
originalHash := dbVK1.ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify hash is unchanged
|
|
dbVK2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
if dbVK2.ConfigHash != originalHash {
|
|
t.Errorf("Expected hash to remain unchanged: original=%s, new=%s", originalHash, dbVK2.ConfigHash)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_HashMismatch_FileSync tests that file VK is synced when hash differs
|
|
func TestSQLite_VirtualKey_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a virtual key
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "original-name", "vk_test123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify original name
|
|
dbVK1 := verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
if dbVK1.Name != "original-name" {
|
|
t.Errorf("Expected name 'original-name', got '%s'", dbVK1.Name)
|
|
}
|
|
originalHash := dbVK1.ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Modify config.json - change VK name and description
|
|
vks2 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "modified-name",
|
|
Description: "Modified description",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load with modified config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify name was updated
|
|
dbVK2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
if dbVK2.Name != "modified-name" {
|
|
t.Errorf("Expected name to be updated to 'modified-name', got '%s'", dbVK2.Name)
|
|
}
|
|
|
|
// Verify hash changed
|
|
if dbVK2.ConfigHash == originalHash {
|
|
t.Error("Expected hash to change when VK is modified")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_DBOnlyVK_Preserved tests that VK added via DB is preserved on reload
|
|
func TestSQLite_VirtualKey_DBOnlyVK_Preserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with one VK
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-file", "file-vk", "vk_file123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Add a second VK directly to DB (simulating dashboard addition)
|
|
dashboardVK := tables.TableVirtualKey{
|
|
ID: "vk-dashboard",
|
|
Name: "dashboard-vk",
|
|
Description: "Added via dashboard",
|
|
Value: "vk_dashboard456",
|
|
IsActive: true,
|
|
}
|
|
dashboardHash, _ := configstore.GenerateVirtualKeyHash(dashboardVK)
|
|
dashboardVK.ConfigHash = dashboardHash
|
|
err = config1.ConfigStore.CreateVirtualKey(ctx, &dashboardVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create dashboard VK: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json (only has vk-file)
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify both VKs exist
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-file")
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-dashboard")
|
|
|
|
// Verify dashboard VK is in governance config
|
|
found := false
|
|
for _, vk := range config2.GovernanceConfig.VirtualKeys {
|
|
if vk.ID == "vk-dashboard" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Expected dashboard VK to be preserved in governance config")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_WithProviderConfigs tests VK with provider configs hash generation
|
|
func TestSQLite_VirtualKey_WithProviderConfigs(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with providers and a virtual key with provider configs
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK with provider configs",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify VK exists with hash
|
|
dbVK := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
if dbVK.ConfigHash == "" {
|
|
t.Error("Expected config hash to be set")
|
|
}
|
|
|
|
// Verify provider configs are present in VK
|
|
if len(dbVK.ProviderConfigs) == 0 {
|
|
t.Error("Expected provider configs to be persisted and loaded with virtual key")
|
|
}
|
|
|
|
// Generate hash manually and compare
|
|
testVK := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK with provider configs",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
},
|
|
},
|
|
}
|
|
expectedHash, err := configstore.GenerateVirtualKeyHash(testVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate expected hash: %v", err)
|
|
}
|
|
|
|
if dbVK.ConfigHash != expectedHash {
|
|
t.Errorf("Expected config hash to match: got %s, want %s", dbVK.ConfigHash, expectedHash)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_MergePath_WithProviderConfigs tests that when a NEW VK with ProviderConfigs
|
|
// is added via config.json during a reload (merge path), the ProviderConfigs are properly persisted.
|
|
// This is different from the bootstrap path which correctly handles associations.
|
|
func TestSQLite_VirtualKey_MergePath_WithProviderConfigs(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Step 1: Create initial config.json with a simple VK (no ProviderConfigs)
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "simple-vk", "vk_simple123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load - bootstrap path
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify initial VK exists
|
|
verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
config1.Close(ctx)
|
|
|
|
// Step 2: Update config.json to add a NEW VK with ProviderConfigs
|
|
vks2 := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "simple-vk", "vk_simple123"), // Keep existing
|
|
{
|
|
ID: "vk-2",
|
|
Name: "vk-with-providers",
|
|
Description: "VK with provider configs added via merge",
|
|
Value: "vk_providers456",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load - merge path (this is where the bug is)
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify both VKs exist
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
dbVK2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-2")
|
|
|
|
// CRITICAL: Verify ProviderConfigs were persisted for the NEW VK added via merge path
|
|
if len(dbVK2.ProviderConfigs) == 0 {
|
|
t.Error("Expected provider configs to be persisted for VK added via merge path")
|
|
}
|
|
|
|
// Verify the provider config details
|
|
if len(dbVK2.ProviderConfigs) > 0 {
|
|
pc := dbVK2.ProviderConfigs[0]
|
|
if pc.Provider != "openai" {
|
|
t.Errorf("Expected provider 'openai', got '%s'", pc.Provider)
|
|
}
|
|
if pc.Weight == nil || *pc.Weight != 2.0 {
|
|
t.Errorf("Expected weight 2.0, got %v", pc.Weight)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_MergePath_WithProviderConfigKeys tests that when a NEW VK with ProviderConfigs
|
|
// that reference specific Keys is added via merge path, the Keys many-to-many association is properly persisted.
|
|
//
|
|
// WHY THIS TEST WAS FAILING BEFORE THE FIX:
|
|
// -----------------------------------------
|
|
// The merge path at config.go:1121-1126 was calling CreateVirtualKey() with ProviderConfigs attached.
|
|
// When ProviderConfigs contain Keys ([]TableKey), GORM's Create() tries to handle the nested associations:
|
|
//
|
|
// 1. GORM creates the VirtualKey
|
|
// 2. GORM creates the ProviderConfigs (has-many from VirtualKey)
|
|
// 3. GORM tries to handle Keys inside ProviderConfigs (many-to-many)
|
|
//
|
|
// The problem is step 3: GORM tries to INSERT the Keys, but they already exist in the DB
|
|
// (they were created when the provider was loaded). This causes a unique constraint violation
|
|
// or foreign key error, which makes the entire transaction fail.
|
|
//
|
|
// The fix uses CreateVirtualKeyProviderConfig() which:
|
|
// 1. Stores Keys to a local variable
|
|
// 2. Creates ProviderConfig WITHOUT Keys
|
|
// 3. Uses Association("Keys").Append() to ASSOCIATE existing keys (not INSERT them)
|
|
//
|
|
// This test verifies that Keys are properly associated after the fix.
|
|
func TestSQLite_VirtualKey_MergePath_WithProviderConfigKeys(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Step 1: Create initial config.json with a provider that has keys
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key-1", Value: *schemas.NewEnvVar("sk-test-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "simple-vk", "vk_simple123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load - bootstrap path (creates provider with key in DB)
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get the actual TableKey from DB (we need the uint ID for association)
|
|
dbKeys, err := config1.ConfigStore.GetKeysByProvider(ctx, "openai")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get keys: %v", err)
|
|
}
|
|
if len(dbKeys) == 0 {
|
|
t.Fatal("Expected at least one key in OpenAI provider")
|
|
}
|
|
dbKey := dbKeys[0] // This is a tables.TableKey with proper uint ID
|
|
|
|
// Verify initial VK exists
|
|
verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
config1.Close(ctx)
|
|
|
|
// Step 2: Update config.json to add a NEW VK with ProviderConfigs that reference the existing key
|
|
// The key reference uses the TableKey from DB which has the proper uint ID
|
|
vks2 := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "simple-vk", "vk_simple123"), // Keep existing
|
|
{
|
|
ID: "vk-2",
|
|
Name: "vk-with-provider-keys",
|
|
Description: "VK with provider configs referencing keys",
|
|
Value: "vk_keys456",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
Keys: []tables.TableKey{dbKey}, // Reference existing DB key
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load - merge path
|
|
// BEFORE FIX: This would fail because GORM tries to INSERT the key again
|
|
// AFTER FIX: CreateVirtualKeyProviderConfig uses Append() to associate existing keys
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify both VKs exist
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
dbVK2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-2")
|
|
|
|
// Verify ProviderConfigs were persisted
|
|
if len(dbVK2.ProviderConfigs) == 0 {
|
|
t.Fatal("Expected provider configs to be persisted for VK added via merge path")
|
|
}
|
|
|
|
// CRITICAL: Verify Keys many-to-many association was persisted
|
|
pc := dbVK2.ProviderConfigs[0]
|
|
if len(pc.Keys) == 0 {
|
|
t.Error("Expected Keys to be associated with provider config via merge path")
|
|
}
|
|
|
|
// Verify the key details if present
|
|
if len(pc.Keys) > 0 {
|
|
if pc.Keys[0].KeyID != dbKey.KeyID {
|
|
t.Errorf("Expected key ID '%s', got '%s'", dbKey.KeyID, pc.Keys[0].KeyID)
|
|
}
|
|
t.Logf("✓ Key successfully associated: ID=%d, KeyID=%s, Name=%s", pc.Keys[0].ID, pc.Keys[0].KeyID, pc.Keys[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_ProviderConfigKeyIDs tests VK provider config key IDs are correctly hashed
|
|
func TestSQLite_VirtualKey_ProviderConfigKeyIDs(t *testing.T) {
|
|
// This test verifies that when a VK's provider config references specific keys,
|
|
// the hash includes those key IDs and changes when they change
|
|
|
|
// Create two VKs with different key IDs in provider configs
|
|
vk1 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-1"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
vk2 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-2"}, // Different key ID
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateVirtualKeyHash(vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash1: %v", err)
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash2: %v", err)
|
|
}
|
|
|
|
if hash1 == hash2 {
|
|
t.Error("Expected different hashes for VKs with different key IDs in provider configs")
|
|
}
|
|
|
|
// Same key IDs should produce same hash
|
|
vk3 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-1"}, // Same as vk1
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash3, err := configstore.GenerateVirtualKeyHash(vk3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash3: %v", err)
|
|
}
|
|
|
|
if hash1 != hash3 {
|
|
t.Error("Expected same hash for VKs with same key IDs in provider configs")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Tests - Virtual Key Provider Config Scenarios
|
|
// =============================================================================
|
|
|
|
// TestSQLite_VKProviderConfig_NewConfig tests that new provider config in VK is properly created
|
|
func TestSQLite_VKProviderConfig_NewConfig(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with a VK that has provider configs
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "vk-with-provider-config",
|
|
Description: "VK with provider configs",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify VK exists
|
|
dbVK := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
if dbVK.Name != "vk-with-provider-config" {
|
|
t.Errorf("Expected VK name 'vk-with-provider-config', got '%s'", dbVK.Name)
|
|
}
|
|
|
|
// Verify provider configs were created
|
|
providerConfigs, err := config.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs: %v", err)
|
|
}
|
|
|
|
if len(providerConfigs) != 1 {
|
|
t.Errorf("Expected 1 provider config, got %d", len(providerConfigs))
|
|
}
|
|
|
|
if len(providerConfigs) > 0 {
|
|
if providerConfigs[0].Provider != "openai" {
|
|
t.Errorf("Expected provider 'openai', got '%s'", providerConfigs[0].Provider)
|
|
}
|
|
if providerConfigs[0].Weight == nil || *providerConfigs[0].Weight != 2.0 {
|
|
t.Errorf("Expected weight 2.0, got %v", providerConfigs[0].Weight)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VKProviderConfig_KeyReference tests provider config references keys by ID correctly
|
|
func TestSQLite_VKProviderConfig_KeyReference(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create provider key first
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key-1", Value: *schemas.NewEnvVar("sk-test-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
|
|
// Create VK without key references (simple provider config)
|
|
// Key references in VK provider configs require complex setup with existing DB keys
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "vk-with-provider-ref",
|
|
Description: "VK with provider config",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
// Keys left empty - means all keys for the provider are allowed
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify VK exists
|
|
dbVK := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
if dbVK.Name != "vk-with-provider-ref" {
|
|
t.Errorf("Expected VK name 'vk-with-provider-ref', got '%s'", dbVK.Name)
|
|
}
|
|
|
|
// Verify provider config exists
|
|
providerConfigs, err := config.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs: %v", err)
|
|
}
|
|
|
|
if len(providerConfigs) > 0 {
|
|
if providerConfigs[0].Provider != "openai" {
|
|
t.Errorf("Expected provider 'openai', got '%s'", providerConfigs[0].Provider)
|
|
}
|
|
t.Logf("Provider config created successfully with provider: %s", providerConfigs[0].Provider)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VKProviderConfig_HashChangesOnKeyIDChange tests hash changes when key IDs change
|
|
func TestSQLite_VKProviderConfig_HashChangesOnKeyIDChange(t *testing.T) {
|
|
// Test that changing the key IDs in a provider config changes the hash
|
|
|
|
// VK with key-id-1
|
|
vk1 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-1", Name: "key-1"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with key-id-2
|
|
vk2 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-2", Name: "key-2"}, // Different key
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateVirtualKeyHash(vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash1: %v", err)
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash2: %v", err)
|
|
}
|
|
|
|
if hash1 == hash2 {
|
|
t.Error("Expected different hashes when key IDs change in provider config")
|
|
}
|
|
|
|
// VK with multiple keys
|
|
vk3 := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-id-1", Name: "key-1"},
|
|
{KeyID: "key-id-2", Name: "key-2"}, // Additional key
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hash3, err := configstore.GenerateVirtualKeyHash(vk3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash3: %v", err)
|
|
}
|
|
|
|
if hash1 == hash3 {
|
|
t.Error("Expected different hash when additional key is added to provider config")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VKProviderConfig_WeightAndAllowedModels tests Weight/AllowedModels changes detected
|
|
func TestSQLite_VKProviderConfig_WeightAndAllowedModels(t *testing.T) {
|
|
// Test that changing weight or allowed models changes the hash
|
|
|
|
// Base VK
|
|
vkBase := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with different weight
|
|
vkDifferentWeight := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.5), // Different weight
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with different allowed models
|
|
vkDifferentModels := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"}, // Different models
|
|
},
|
|
},
|
|
}
|
|
|
|
hashBase, err := configstore.GenerateVirtualKeyHash(vkBase)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashBase: %v", err)
|
|
}
|
|
|
|
hashDiffWeight, err := configstore.GenerateVirtualKeyHash(vkDifferentWeight)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashDiffWeight: %v", err)
|
|
}
|
|
|
|
hashDiffModels, err := configstore.GenerateVirtualKeyHash(vkDifferentModels)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashDiffModels: %v", err)
|
|
}
|
|
|
|
if hashBase == hashDiffWeight {
|
|
t.Error("Expected different hash when weight changes in provider config")
|
|
}
|
|
|
|
if hashBase == hashDiffModels {
|
|
t.Error("Expected different hash when allowed models change in provider config")
|
|
}
|
|
|
|
if hashDiffWeight == hashDiffModels {
|
|
t.Error("Expected different hashes for weight change vs model change")
|
|
}
|
|
|
|
// Same config should produce same hash
|
|
vkSame := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashSame, err := configstore.GenerateVirtualKeyHash(vkSame)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashSame: %v", err)
|
|
}
|
|
|
|
if hashBase != hashSame {
|
|
t.Error("Expected same hash for identical configs")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SQLite Integration Tests - Full Lifecycle Scenarios
|
|
// =============================================================================
|
|
|
|
// TestSQLite_FullLifecycle_InitialLoad tests fresh DB + config.json -> all entities created
|
|
func TestSQLite_FullLifecycle_InitialLoad(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create comprehensive config.json
|
|
keyID1 := uuid.NewString()
|
|
keyID2 := uuid.NewString()
|
|
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID1, Name: "openai-key-1", Value: *schemas.NewEnvVar("sk-openai-123"), Weight: 1},
|
|
{ID: keyID2, Name: "openai-key-2", Value: *schemas.NewEnvVar("sk-openai-456"), Weight: 2},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.openai.com",
|
|
},
|
|
ConcurrencyAndBufferSize: &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
},
|
|
},
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "anthropic-key-1", Value: *schemas.NewEnvVar("sk-anthropic-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{
|
|
BaseURL: "https://api.anthropic.com",
|
|
},
|
|
},
|
|
}
|
|
|
|
budgetID := "budget-1"
|
|
rateLimitID := "rl-1"
|
|
tokenMaxLimit := int64(10000)
|
|
tokenResetDuration := "1h"
|
|
requestMaxLimit := int64(100)
|
|
requestResetDuration := "1m"
|
|
|
|
governance := &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{
|
|
ID: budgetID,
|
|
MaxLimit: 100.0,
|
|
ResetDuration: "1M",
|
|
CurrentUsage: 0,
|
|
},
|
|
},
|
|
RateLimits: []tables.TableRateLimit{
|
|
{
|
|
ID: rateLimitID,
|
|
TokenMaxLimit: &tokenMaxLimit,
|
|
TokenResetDuration: &tokenResetDuration,
|
|
RequestMaxLimit: &requestMaxLimit,
|
|
RequestResetDuration: &requestResetDuration,
|
|
},
|
|
},
|
|
VirtualKeys: []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk-1",
|
|
Description: "Test virtual key 1",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
RateLimitID: &rateLimitID,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "vk-2",
|
|
Name: "test-vk-2",
|
|
Description: "Test virtual key 2",
|
|
Value: "vk_test456",
|
|
IsActive: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataFullWithDir(nil, providers, governance, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Load config
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify providers
|
|
verifyProviderInDB(t, config.ConfigStore, schemas.OpenAI, 2)
|
|
verifyProviderInDB(t, config.ConfigStore, schemas.Anthropic, 1)
|
|
|
|
// Verify virtual keys
|
|
verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
verifyVirtualKeyInDB(t, config.ConfigStore, "vk-2")
|
|
|
|
// Verify in-memory state
|
|
if len(config.Providers) != 2 {
|
|
t.Errorf("Expected 2 providers in memory, got %d", len(config.Providers))
|
|
}
|
|
if len(config.GovernanceConfig.VirtualKeys) != 2 {
|
|
t.Errorf("Expected 2 virtual keys in memory, got %d", len(config.GovernanceConfig.VirtualKeys))
|
|
}
|
|
if len(config.GovernanceConfig.Budgets) != 1 {
|
|
t.Errorf("Expected 1 budget in memory, got %d", len(config.GovernanceConfig.Budgets))
|
|
}
|
|
if len(config.GovernanceConfig.RateLimits) != 1 {
|
|
t.Errorf("Expected 1 rate limit in memory, got %d", len(config.GovernanceConfig.RateLimits))
|
|
}
|
|
|
|
// Verify hashes are set
|
|
dbProviders, _ := config.ConfigStore.GetProvidersConfig(ctx)
|
|
for provider, cfg := range dbProviders {
|
|
if cfg.ConfigHash == "" {
|
|
t.Errorf("Expected config hash for provider %s", provider)
|
|
}
|
|
}
|
|
|
|
dbVK1 := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
if dbVK1.ConfigHash == "" {
|
|
t.Error("Expected config hash for VK vk-1")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_FullLifecycle_SecondLoadNoChanges tests that second load with same file has no DB writes
|
|
func TestSQLite_FullLifecycle_SecondLoadNoChanges(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"),
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "test-vk", "vk_test123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Capture state after first load
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
providerHash1 := dbProviders1[schemas.OpenAI].ConfigHash
|
|
dbVK1 := verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
vkHash1 := dbVK1.ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Second load with same config.json
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify hashes are unchanged (no writes needed)
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders2[schemas.OpenAI].ConfigHash != providerHash1 {
|
|
t.Error("Provider hash should not change on reload with same config")
|
|
}
|
|
|
|
dbVK2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
if dbVK2.ConfigHash != vkHash1 {
|
|
t.Error("VK hash should not change on reload with same config")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_FullLifecycle_FileChange_Selective tests that only changed items are updated in DB
|
|
func TestSQLite_FullLifecycle_FileChange_Selective(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json with two providers and two VKs
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-openai-123", "https://api.openai.com"),
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "anthropic-key-1", Value: *schemas.NewEnvVar("sk-anthropic-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.anthropic.com"},
|
|
},
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "vk-one", "vk_one123"),
|
|
makeVirtualKey("vk-2", "vk-two", "vk_two456"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Capture hashes
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
openaiHash1 := dbProviders1[schemas.OpenAI].ConfigHash
|
|
anthropicHash1 := dbProviders1[schemas.Anthropic].ConfigHash
|
|
dbVK1_1 := verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-1")
|
|
vk1Hash1 := dbVK1_1.ConfigHash
|
|
dbVK2_1 := verifyVirtualKeyInDB(t, config1.ConfigStore, "vk-2")
|
|
vk2Hash1 := dbVK2_1.ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Modify config.json - change only OpenAI and vk-1
|
|
providers2 := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-openai-123", "https://api.openai.com/v2"), // Changed
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "anthropic-key-1", Value: *schemas.NewEnvVar("sk-anthropic-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.anthropic.com"}, // Unchanged
|
|
},
|
|
}
|
|
vks2 := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "vk-one-modified", "vk_one123"), // Changed name
|
|
makeVirtualKey("vk-2", "vk-two", "vk_two456"), // Unchanged
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers2, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify OpenAI hash changed (config changed)
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if dbProviders2[schemas.OpenAI].ConfigHash == openaiHash1 {
|
|
t.Error("OpenAI hash should have changed (BaseURL changed)")
|
|
}
|
|
|
|
// Verify Anthropic hash unchanged (config unchanged)
|
|
if dbProviders2[schemas.Anthropic].ConfigHash != anthropicHash1 {
|
|
t.Error("Anthropic hash should remain unchanged")
|
|
}
|
|
|
|
// Verify vk-1 hash changed (name changed)
|
|
dbVK1_2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
if dbVK1_2.ConfigHash == vk1Hash1 {
|
|
t.Error("VK-1 hash should have changed (name changed)")
|
|
}
|
|
if dbVK1_2.Name != "vk-one-modified" {
|
|
t.Errorf("Expected VK-1 name to be 'vk-one-modified', got '%s'", dbVK1_2.Name)
|
|
}
|
|
|
|
// Verify vk-2 hash unchanged
|
|
dbVK2_2 := verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-2")
|
|
if dbVK2_2.ConfigHash != vk2Hash1 {
|
|
t.Error("VK-2 hash should remain unchanged")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_FullLifecycle_DashboardEdits_ThenFileUnchanged tests dashboard edits are preserved
|
|
func TestSQLite_FullLifecycle_DashboardEdits_ThenFileUnchanged(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config.json
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key-1", Value: *schemas.NewEnvVar("sk-original-123"), Weight: 1},
|
|
},
|
|
NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.openai.com"},
|
|
},
|
|
}
|
|
vks := []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "test-vk", "vk_test123"),
|
|
}
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Simulate dashboard edits:
|
|
// 1. Add a new key
|
|
dbProviders1, _ := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
openaiConfig := dbProviders1[schemas.OpenAI]
|
|
openaiConfig.Keys = append(openaiConfig.Keys, schemas.Key{
|
|
ID: uuid.NewString(),
|
|
Name: "dashboard-key",
|
|
Value: *schemas.NewEnvVar("sk-dashboard-789"),
|
|
Weight: 1,
|
|
})
|
|
dbProviders1[schemas.OpenAI] = openaiConfig
|
|
err = config1.ConfigStore.UpdateProvidersConfig(ctx, dbProviders1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add dashboard key: %v", err)
|
|
}
|
|
|
|
// 2. Add a new VK via dashboard
|
|
dashboardVK := tables.TableVirtualKey{
|
|
ID: "vk-dashboard",
|
|
Name: "dashboard-vk",
|
|
Description: "Added via dashboard",
|
|
Value: "vk_dashboard456",
|
|
IsActive: true,
|
|
}
|
|
dashboardHash, _ := configstore.GenerateVirtualKeyHash(dashboardVK)
|
|
dashboardVK.ConfigHash = dashboardHash
|
|
err = config1.ConfigStore.CreateVirtualKey(ctx, &dashboardVK)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create dashboard VK: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load with SAME config.json (unchanged)
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify dashboard-added key is preserved
|
|
dbProviders2, _ := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
if len(dbProviders2[schemas.OpenAI].Keys) != 2 {
|
|
t.Errorf("Expected 2 keys (1 original + 1 dashboard), got %d", len(dbProviders2[schemas.OpenAI].Keys))
|
|
}
|
|
|
|
keyNames := make(map[string]bool)
|
|
for _, k := range dbProviders2[schemas.OpenAI].Keys {
|
|
keyNames[k.Name] = true
|
|
}
|
|
if !keyNames["openai-key-1"] {
|
|
t.Error("Expected original key to be present")
|
|
}
|
|
if !keyNames["dashboard-key"] {
|
|
t.Error("Expected dashboard key to be preserved")
|
|
}
|
|
|
|
// Verify dashboard-added VK is preserved
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-1")
|
|
verifyVirtualKeyInDB(t, config2.ConfigStore, "vk-dashboard")
|
|
|
|
// Check VKs in memory
|
|
vkNames := make(map[string]bool)
|
|
for _, vk := range config2.GovernanceConfig.VirtualKeys {
|
|
vkNames[vk.Name] = true
|
|
}
|
|
if !vkNames["test-vk"] {
|
|
t.Error("Expected original VK to be present")
|
|
}
|
|
if !vkNames["dashboard-vk"] {
|
|
t.Error("Expected dashboard VK to be preserved")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// VirtualKey MCPConfig Tests
|
|
// =============================================================================
|
|
|
|
// TestGenerateVirtualKeyHash_MCPConfigChanges tests that MCP config changes affect the hash
|
|
func TestGenerateVirtualKeyHash_MCPConfigChanges(t *testing.T) {
|
|
// Base VK with no MCP configs
|
|
vkBase := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
}
|
|
|
|
// VK with one MCP config
|
|
vkWithMCP := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with different MCP client ID
|
|
vkDifferentMCPClient := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: 2, // Different client
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with different tools
|
|
vkDifferentTools := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool3", "tool4"}, // Different tools
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with multiple MCP configs
|
|
vkMultipleMCP := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
{
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool3"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashBase, err := configstore.GenerateVirtualKeyHash(vkBase)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashBase: %v", err)
|
|
}
|
|
|
|
hashWithMCP, err := configstore.GenerateVirtualKeyHash(vkWithMCP)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashWithMCP: %v", err)
|
|
}
|
|
|
|
hashDifferentClient, err := configstore.GenerateVirtualKeyHash(vkDifferentMCPClient)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashDifferentClient: %v", err)
|
|
}
|
|
|
|
hashDifferentTools, err := configstore.GenerateVirtualKeyHash(vkDifferentTools)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashDifferentTools: %v", err)
|
|
}
|
|
|
|
hashMultipleMCP, err := configstore.GenerateVirtualKeyHash(vkMultipleMCP)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashMultipleMCP: %v", err)
|
|
}
|
|
|
|
// Test: Adding MCP config changes hash
|
|
if hashBase == hashWithMCP {
|
|
t.Error("Expected different hash when MCP config is added")
|
|
}
|
|
|
|
// Test: Different MCP client ID changes hash
|
|
if hashWithMCP == hashDifferentClient {
|
|
t.Error("Expected different hash when MCP client ID changes")
|
|
}
|
|
|
|
// Test: Different tools change hash
|
|
if hashWithMCP == hashDifferentTools {
|
|
t.Error("Expected different hash when tools change")
|
|
}
|
|
|
|
// Test: Multiple MCP configs produce different hash
|
|
if hashWithMCP == hashMultipleMCP {
|
|
t.Error("Expected different hash when additional MCP config is added")
|
|
}
|
|
|
|
// Test: Same config produces same hash
|
|
vkSame := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
}
|
|
hashSame, err := configstore.GenerateVirtualKeyHash(vkSame)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hashSame: %v", err)
|
|
}
|
|
|
|
if hashWithMCP != hashSame {
|
|
t.Error("Expected same hash for identical MCP configs")
|
|
}
|
|
|
|
t.Log("✓ MCP config changes correctly affect virtual key hash")
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_WithMCPConfigs tests VK creation with MCP configs
|
|
func TestSQLite_VirtualKey_WithMCPConfigs(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Create config with virtual key
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK with MCP config",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load - creates VK
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Manually create an MCP client in the DB (simulating MCP setup)
|
|
mcpClientConfig := schemas.MCPClientConfig{
|
|
ID: "mcp-client-1",
|
|
Name: "test-mcp-client",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
err = config1.ConfigStore.CreateMCPClientConfig(ctx, &mcpClientConfig)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP client: %v", err)
|
|
}
|
|
|
|
// Get the created MCP client to get its ID
|
|
mcpClient, err := config1.ConfigStore.GetMCPClientByName(ctx, "test-mcp-client")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP client: %v", err)
|
|
}
|
|
|
|
// Create MCP config for the virtual key
|
|
mcpConfig := tables.TableVirtualKeyMCPConfig{
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: mcpClient.ID,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
}
|
|
err = config1.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &mcpConfig)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create VK MCP config: %v", err)
|
|
}
|
|
|
|
// Verify MCP config was created
|
|
mcpConfigs, err := config1.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs: %v", err)
|
|
}
|
|
|
|
if len(mcpConfigs) != 1 {
|
|
t.Errorf("Expected 1 MCP config, got %d", len(mcpConfigs))
|
|
}
|
|
|
|
if len(mcpConfigs) > 0 {
|
|
if mcpConfigs[0].MCPClientID != mcpClient.ID {
|
|
t.Errorf("Expected MCPClientID %d, got %d", mcpClient.ID, mcpConfigs[0].MCPClientID)
|
|
}
|
|
if len(mcpConfigs[0].ToolsToExecute) != 2 {
|
|
t.Errorf("Expected 2 tools, got %d", len(mcpConfigs[0].ToolsToExecute))
|
|
}
|
|
t.Logf("✓ MCP config created successfully with MCPClientID: %d", mcpConfigs[0].MCPClientID)
|
|
}
|
|
|
|
config1.Close(ctx)
|
|
}
|
|
|
|
// TestSQLite_VKMCPConfig_Reconciliation tests MCP config reconciliation on hash mismatch.
|
|
// When config.json changes (file is source of truth):
|
|
// - Configs in both file and DB → update from file
|
|
// - Configs only in file → create new
|
|
// - Configs only in DB → DELETE (file is source of truth)
|
|
func TestSQLite_VKMCPConfig_Reconciliation(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Create initial config
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for MCP reconciliation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Create MCP clients
|
|
mcpClientConfig1 := schemas.MCPClientConfig{
|
|
ID: "mcp-client-1",
|
|
Name: "mcp-client-1",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
err = config1.ConfigStore.CreateMCPClientConfig(ctx, &mcpClientConfig1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP client 1: %v", err)
|
|
}
|
|
|
|
mcpClientConfig2 := schemas.MCPClientConfig{
|
|
ID: "mcp-client-2",
|
|
Name: "mcp-client-2",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
err = config1.ConfigStore.CreateMCPClientConfig(ctx, &mcpClientConfig2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP client 2: %v", err)
|
|
}
|
|
|
|
mcpClient1, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client-1")
|
|
mcpClient2, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client-2")
|
|
|
|
// Create initial MCP config for VK (simulates being added via dashboard, not in file)
|
|
mcpConfig := tables.TableVirtualKeyMCPConfig{
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: mcpClient1.ID,
|
|
ToolsToExecute: []string{"tool1"},
|
|
}
|
|
err = config1.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &mcpConfig)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create VK MCP config: %v", err)
|
|
}
|
|
|
|
// Update the VK's config hash to include the MCP config
|
|
vk, err := config1.ConfigStore.GetVirtualKey(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get VK: %v", err)
|
|
}
|
|
vk.MCPConfigs = []tables.TableVirtualKeyMCPConfig{mcpConfig}
|
|
newHash, _ := configstore.GenerateVirtualKeyHash(*vk)
|
|
vk.ConfigHash = newHash
|
|
err = config1.ConfigStore.UpdateVirtualKey(ctx, vk)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update VK hash: %v", err)
|
|
}
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Update config.json with a NEW MCP config (different client)
|
|
// This triggers hash mismatch and reconciliation
|
|
vks2 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for MCP reconciliation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: mcpClient2.ID, // Different MCP client - will be created
|
|
ToolsToExecute: []string{"tool2", "tool3"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load - should trigger reconciliation
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify MCP configs after reconciliation
|
|
mcpConfigs, err := config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs after reconciliation: %v", err)
|
|
}
|
|
|
|
// Should have only client 2 (file is source of truth):
|
|
// - client 1: DELETED (was in DB but not in new file)
|
|
// - client 2: created (new from file)
|
|
if len(mcpConfigs) != 1 {
|
|
t.Errorf("Expected 1 MCP config after reconciliation (file is source of truth), got %d", len(mcpConfigs))
|
|
}
|
|
|
|
hasClient1 := false
|
|
hasClient2 := false
|
|
for _, mc := range mcpConfigs {
|
|
if mc.MCPClientID == mcpClient1.ID {
|
|
hasClient1 = true
|
|
}
|
|
if mc.MCPClientID == mcpClient2.ID {
|
|
hasClient2 = true
|
|
// Client 2 should have new tools from file
|
|
if len(mc.ToolsToExecute) != 2 {
|
|
t.Errorf("Expected client 2 to have 2 tools from file, got %d", len(mc.ToolsToExecute))
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasClient1 {
|
|
t.Error("MCP config for client 1 should be DELETED (file is source of truth)")
|
|
}
|
|
if !hasClient2 {
|
|
t.Error("MCP config for client 2 should be created from file")
|
|
}
|
|
|
|
if !hasClient1 && hasClient2 {
|
|
t.Logf("✓ MCP config reconciled successfully: client 1 deleted, client 2 created from file")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_DashboardProviderConfig_PreservedOnFileChange tests that provider configs
|
|
// added via dashboard to a VK that also exists in config.json are PRESERVED when config.json changes.
|
|
//
|
|
// SCENARIO:
|
|
// 1. config.json has VK "vk-1" with "openai" provider config (weight=1.0)
|
|
// 2. Bootstrap load creates VK in DB
|
|
// 3. User adds "anthropic" provider config via dashboard (simulated by CreateVirtualKeyProviderConfig)
|
|
// 4. User modifies config.json (changes openai weight to 2.0 → hash mismatch)
|
|
// 5. Reload config
|
|
//
|
|
// EXPECTED: "anthropic" provider config should be DELETED (file is source of truth when hash changes)
|
|
func TestSQLite_VirtualKey_DashboardProviderConfig_DeletedOnFileChange(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Step 1: Create config.json with VK that has openai provider config
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-openai-123"), Weight: 1},
|
|
},
|
|
},
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "anthropic-key", Value: *schemas.NewEnvVar("sk-anthropic-123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for dashboard provider config preservation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Step 2: First load - bootstrap path
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify initial state
|
|
providerConfigs1, err := config1.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs after first load: %v", err)
|
|
}
|
|
if len(providerConfigs1) != 1 {
|
|
t.Fatalf("Expected 1 provider config after first load, got %d", len(providerConfigs1))
|
|
}
|
|
if providerConfigs1[0].Provider != "openai" {
|
|
t.Fatalf("Expected openai provider config, got %s", providerConfigs1[0].Provider)
|
|
}
|
|
t.Logf("✓ Initial state: VK has 1 provider config (openai)")
|
|
|
|
// Step 3: Simulate dashboard adding "anthropic" provider config
|
|
anthropicConfig := tables.TableVirtualKeyProviderConfig{
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"claude-3-opus"},
|
|
}
|
|
err = config1.ConfigStore.CreateVirtualKeyProviderConfig(ctx, &anthropicConfig)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add anthropic provider config via dashboard: %v", err)
|
|
}
|
|
|
|
// Verify dashboard-added config exists
|
|
providerConfigs2, err := config1.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs after dashboard add: %v", err)
|
|
}
|
|
if len(providerConfigs2) != 2 {
|
|
t.Fatalf("Expected 2 provider configs after dashboard add, got %d", len(providerConfigs2))
|
|
}
|
|
t.Logf("✓ Dashboard added anthropic provider config, now have 2 configs")
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Step 4: Modify config.json - change openai weight (causes hash mismatch)
|
|
vks2 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for dashboard provider config preservation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(2.0), // Changed from 1.0 to 2.0 - triggers hash mismatch
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Step 5: Second load - merge path with hash mismatch
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// CRITICAL ASSERTION: Dashboard-added "anthropic" config should be DELETED (file is source of truth)
|
|
providerConfigs3, err := config2.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs after second load: %v", err)
|
|
}
|
|
|
|
// Check that only the file's provider config remains
|
|
hasOpenAI := false
|
|
hasAnthropic := false
|
|
for _, pc := range providerConfigs3 {
|
|
if pc.Provider == "openai" {
|
|
hasOpenAI = true
|
|
if pc.Weight == nil || *pc.Weight != 2.0 {
|
|
t.Errorf("Expected openai weight to be updated to 2.0, got %v", pc.Weight)
|
|
}
|
|
}
|
|
if pc.Provider == "anthropic" {
|
|
hasAnthropic = true
|
|
}
|
|
}
|
|
|
|
if !hasOpenAI {
|
|
t.Error("openai provider config should exist after file change")
|
|
}
|
|
|
|
// File is source of truth - dashboard-added config should be deleted when hash changes
|
|
if hasAnthropic {
|
|
t.Error("anthropic provider config should be DELETED when config.json changed (file is source of truth)")
|
|
}
|
|
|
|
if len(providerConfigs3) != 1 {
|
|
t.Errorf("Expected 1 provider config (only openai from file), got %d. File is source of truth when hash changes.", len(providerConfigs3))
|
|
}
|
|
|
|
if hasOpenAI && !hasAnthropic {
|
|
t.Logf("✓ Only file provider config remains: openai (from file, updated). Dashboard-added anthropic correctly deleted.")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VirtualKey_DashboardMCPConfig_DeletedOnFileChange tests that MCP configs
|
|
// added via dashboard to a VK that also exists in config.json are DELETED when config.json changes.
|
|
//
|
|
// SCENARIO:
|
|
// 1. config.json has VK "vk-1" with MCP config for client 1
|
|
// 2. Bootstrap load creates VK in DB
|
|
// 3. User adds MCP config for client 2 via dashboard (simulated by CreateVirtualKeyMCPConfig)
|
|
// 4. User modifies config.json (changes tools for client 1 → hash mismatch)
|
|
// 5. Reload config
|
|
//
|
|
// EXPECTED: MCP config for client 2 should be DELETED (file is source of truth when hash changes)
|
|
func TestSQLite_VirtualKey_DashboardMCPConfig_DeletedOnFileChange(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Step 1: Create config.json with VK (no MCP configs initially - we'll add them via DB)
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-openai-123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for dashboard MCP config preservation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Step 2: First load - bootstrap path
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Create two MCP clients in the DB
|
|
mcpClient1Config := schemas.MCPClientConfig{
|
|
ID: "mcp-client-1",
|
|
Name: "mcp-client-1",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
err = config1.ConfigStore.CreateMCPClientConfig(ctx, &mcpClient1Config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP client 1: %v", err)
|
|
}
|
|
|
|
mcpClient2Config := schemas.MCPClientConfig{
|
|
ID: "mcp-client-2",
|
|
Name: "mcp-client-2",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
}
|
|
err = config1.ConfigStore.CreateMCPClientConfig(ctx, &mcpClient2Config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP client 2: %v", err)
|
|
}
|
|
|
|
mcpClient1, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client-1")
|
|
mcpClient2, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client-2")
|
|
|
|
// Add MCP config for client 1 (will be in config.json)
|
|
mcpConfig1 := tables.TableVirtualKeyMCPConfig{
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: mcpClient1.ID,
|
|
ToolsToExecute: []string{"tool1"},
|
|
}
|
|
err = config1.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &mcpConfig1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MCP config 1: %v", err)
|
|
}
|
|
|
|
// Update VK hash to include MCP config 1
|
|
vk, _ := config1.ConfigStore.GetVirtualKey(ctx, "vk-1")
|
|
vk.MCPConfigs = []tables.TableVirtualKeyMCPConfig{mcpConfig1}
|
|
newHash, _ := configstore.GenerateVirtualKeyHash(*vk)
|
|
vk.ConfigHash = newHash
|
|
config1.ConfigStore.UpdateVirtualKey(ctx, vk)
|
|
|
|
// Step 3: Simulate dashboard adding MCP config for client 2
|
|
mcpConfig2 := tables.TableVirtualKeyMCPConfig{
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: mcpClient2.ID,
|
|
ToolsToExecute: []string{"dashboard-tool"},
|
|
}
|
|
err = config1.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &mcpConfig2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add MCP config 2 via dashboard: %v", err)
|
|
}
|
|
|
|
// Verify both MCP configs exist
|
|
mcpConfigs1, err := config1.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs after dashboard add: %v", err)
|
|
}
|
|
if len(mcpConfigs1) != 2 {
|
|
t.Fatalf("Expected 2 MCP configs after dashboard add, got %d", len(mcpConfigs1))
|
|
}
|
|
t.Logf("✓ Dashboard added MCP config for client 2, now have 2 MCP configs")
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Step 4: Modify config.json - change tools for client 1 (causes hash mismatch)
|
|
vks2 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for dashboard MCP config preservation test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: mcpClient1.ID,
|
|
ToolsToExecute: []string{"tool1", "tool2"}, // Changed - adds tool2, triggers hash mismatch
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Step 5: Second load - merge path with hash mismatch
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// CRITICAL ASSERTION: Dashboard-added MCP config for client 2 should be DELETED (file is source of truth)
|
|
mcpConfigs2, err := config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs after second load: %v", err)
|
|
}
|
|
|
|
// Check that only the file's MCP config remains
|
|
hasClient1 := false
|
|
hasClient2 := false
|
|
for _, mc := range mcpConfigs2 {
|
|
if mc.MCPClientID == mcpClient1.ID {
|
|
hasClient1 = true
|
|
if len(mc.ToolsToExecute) != 2 {
|
|
t.Errorf("Expected client 1 to have 2 tools after update, got %d", len(mc.ToolsToExecute))
|
|
}
|
|
}
|
|
if mc.MCPClientID == mcpClient2.ID {
|
|
hasClient2 = true
|
|
}
|
|
}
|
|
|
|
if !hasClient1 {
|
|
t.Error("MCP config for client 1 should exist after file change")
|
|
}
|
|
|
|
// File is source of truth - dashboard-added config should be deleted when hash changes
|
|
if hasClient2 {
|
|
t.Error("MCP config for client 2 should be DELETED when config.json changed (file is source of truth)")
|
|
}
|
|
|
|
if len(mcpConfigs2) != 1 {
|
|
t.Errorf("Expected 1 MCP config (only client 1 from file), got %d. File is source of truth when hash changes.", len(mcpConfigs2))
|
|
}
|
|
|
|
if hasClient1 && !hasClient2 {
|
|
t.Logf("✓ Only file MCP config remains: client 1 (from file, updated). Dashboard-added client 2 correctly deleted.")
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VKMCPConfig_AddRemove tests adding MCP configs via config.json and verifies
|
|
// that removing from config.json DELETES them (file is source of truth when hash changes).
|
|
//
|
|
// Behavior:
|
|
// - Adding via config.json: configs are created
|
|
// - Removing from config.json: configs are DELETED (file is source of truth)
|
|
func TestSQLite_VKMCPConfig_AddRemove(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Create initial config without MCP
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for add/remove test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Create MCP clients
|
|
config1.ConfigStore.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{ID: "mcp-1", Name: "mcp-1", ConnectionType: schemas.MCPConnectionTypeHTTP})
|
|
config1.ConfigStore.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{ID: "mcp-2", Name: "mcp-2", ConnectionType: schemas.MCPConnectionTypeHTTP})
|
|
|
|
mcpClient1, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-1")
|
|
mcpClient2, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-2")
|
|
|
|
// Verify no MCP configs initially
|
|
mcpConfigs, _ := config1.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if len(mcpConfigs) != 0 {
|
|
t.Errorf("Expected 0 MCP configs initially, got %d", len(mcpConfigs))
|
|
}
|
|
t.Log("✓ Initial state: No MCP configs")
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Update config.json to ADD MCP configs
|
|
vks2 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for add/remove test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{MCPClientID: mcpClient1.ID, ToolsToExecute: []string{"tool1"}},
|
|
{MCPClientID: mcpClient2.ID, ToolsToExecute: []string{"tool2"}},
|
|
},
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks2, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load - should add MCP configs
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
|
|
mcpConfigs, _ = config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if len(mcpConfigs) != 2 {
|
|
t.Errorf("Expected 2 MCP configs after add, got %d", len(mcpConfigs))
|
|
}
|
|
t.Logf("✓ After add: %d MCP configs", len(mcpConfigs))
|
|
|
|
config2.Close(ctx)
|
|
|
|
// Update config.json to remove one MCP config from the file
|
|
// With file-is-source-of-truth, configs ARE deleted when removed from file (hash change)
|
|
vks3 := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "VK for add/remove test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{MCPClientID: mcpClient1.ID, ToolsToExecute: []string{"tool1"}},
|
|
// mcpClient2 removed from file
|
|
},
|
|
},
|
|
}
|
|
configData3 := makeConfigDataWithVirtualKeysAndDir(providers, vks3, tempDir)
|
|
createConfigFile(t, tempDir, configData3)
|
|
|
|
// Third load - mcpClient2 config should be DELETED (file is source of truth)
|
|
config3, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Third LoadConfig failed: %v", err)
|
|
}
|
|
defer config3.Close(ctx)
|
|
|
|
mcpConfigs, _ = config3.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
// Only client1 config should remain (file is source of truth)
|
|
if len(mcpConfigs) != 1 {
|
|
t.Errorf("Expected 1 MCP config after file change (file is source of truth), got %d", len(mcpConfigs))
|
|
}
|
|
|
|
hasClient1 := false
|
|
hasClient2 := false
|
|
for _, mc := range mcpConfigs {
|
|
if mc.MCPClientID == mcpClient1.ID {
|
|
hasClient1 = true
|
|
}
|
|
if mc.MCPClientID == mcpClient2.ID {
|
|
hasClient2 = true
|
|
}
|
|
}
|
|
|
|
if !hasClient1 {
|
|
t.Error("MCP config for client 1 should exist")
|
|
}
|
|
if hasClient2 {
|
|
t.Error("MCP config for client 2 should be DELETED (file is source of truth)")
|
|
}
|
|
t.Logf("✓ After file change: %d MCP config(s) - file is source of truth", len(mcpConfigs))
|
|
}
|
|
|
|
// TestSQLite_VKMCPConfig_UpdateTools tests updating tools in MCP config
|
|
func TestSQLite_VKMCPConfig_UpdateTools(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create initial config data
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Create MCP client
|
|
config1.ConfigStore.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{ID: "mcp-client", Name: "mcp-client", ConnectionType: schemas.MCPConnectionTypeHTTP})
|
|
mcpClient, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client")
|
|
|
|
// Create VK with MCP config
|
|
vk := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{MCPClientID: mcpClient.ID, ToolsToExecute: []string{"tool1", "tool2"}},
|
|
},
|
|
}
|
|
hash, _ := configstore.GenerateVirtualKeyHash(vk)
|
|
vk.ConfigHash = hash
|
|
config1.ConfigStore.CreateVirtualKey(ctx, &vk)
|
|
|
|
mcpConfigToCreate := tables.TableVirtualKeyMCPConfig{
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: mcpClient.ID,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
}
|
|
config1.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &mcpConfigToCreate)
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Update config.json with different tools for same MCP client
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{MCPClientID: mcpClient.ID, ToolsToExecute: []string{"tool3", "tool4", "tool5"}}, // Different tools
|
|
},
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Second load - should update tools
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
mcpConfigs, _ := config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if len(mcpConfigs) != 1 {
|
|
t.Errorf("Expected 1 MCP config, got %d", len(mcpConfigs))
|
|
}
|
|
if len(mcpConfigs) > 0 {
|
|
if len(mcpConfigs[0].ToolsToExecute) != 3 {
|
|
t.Errorf("Expected 3 tools after update, got %d", len(mcpConfigs[0].ToolsToExecute))
|
|
}
|
|
expectedTools := map[string]bool{"tool3": true, "tool4": true, "tool5": true}
|
|
for _, tool := range mcpConfigs[0].ToolsToExecute {
|
|
if !expectedTools[tool] {
|
|
t.Errorf("Unexpected tool: %s", tool)
|
|
}
|
|
}
|
|
t.Logf("✓ Tools updated successfully: %v", mcpConfigs[0].ToolsToExecute)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VK_ProviderAndMCPConfigs_Combined tests VK with both provider and MCP configs
|
|
func TestSQLite_VK_ProviderAndMCPConfigs_Combined(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create initial config data
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load to set up DB
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Create MCP client
|
|
config1.ConfigStore.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{ID: "mcp-client", Name: "mcp-client", ConnectionType: schemas.MCPConnectionTypeHTTP})
|
|
mcpClient, _ := config1.ConfigStore.GetMCPClientByName(ctx, "mcp-client")
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Create config.json with VK having both provider and MCP configs
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "combined-vk",
|
|
Description: "VK with both provider and MCP configs",
|
|
Value: "vk_combined123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
MCPClientID: mcpClient.ID,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
configData2 := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
// Load config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify VK exists
|
|
vk, err := config2.ConfigStore.GetVirtualKey(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get VK: %v", err)
|
|
}
|
|
if vk == nil {
|
|
t.Fatal("VK not found in DB")
|
|
}
|
|
|
|
// Verify provider configs
|
|
providerConfigs, err := config2.ConfigStore.GetVirtualKeyProviderConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get provider configs: %v", err)
|
|
}
|
|
if len(providerConfigs) != 1 {
|
|
t.Errorf("Expected 1 provider config, got %d", len(providerConfigs))
|
|
}
|
|
if len(providerConfigs) > 0 {
|
|
if providerConfigs[0].Provider != "openai" {
|
|
t.Errorf("Expected provider 'openai', got '%s'", providerConfigs[0].Provider)
|
|
}
|
|
if providerConfigs[0].Weight == nil || *providerConfigs[0].Weight != 1.5 {
|
|
t.Errorf("Expected weight 1.5, got %v", providerConfigs[0].Weight)
|
|
}
|
|
t.Logf("✓ Provider config: provider=%s, weight=%v", providerConfigs[0].Provider, providerConfigs[0].Weight)
|
|
}
|
|
|
|
// Verify MCP configs
|
|
mcpConfigs, err := config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs: %v", err)
|
|
}
|
|
if len(mcpConfigs) != 1 {
|
|
t.Errorf("Expected 1 MCP config, got %d", len(mcpConfigs))
|
|
}
|
|
if len(mcpConfigs) > 0 {
|
|
if mcpConfigs[0].MCPClientID != mcpClient.ID {
|
|
t.Errorf("Expected MCPClientID %d, got %d", mcpClient.ID, mcpConfigs[0].MCPClientID)
|
|
}
|
|
if len(mcpConfigs[0].ToolsToExecute) != 2 {
|
|
t.Errorf("Expected 2 tools, got %d", len(mcpConfigs[0].ToolsToExecute))
|
|
}
|
|
t.Logf("✓ MCP config: MCPClientID=%d, tools=%v", mcpConfigs[0].MCPClientID, mcpConfigs[0].ToolsToExecute)
|
|
}
|
|
|
|
t.Log("✓ VK with combined provider and MCP configs created successfully")
|
|
}
|
|
|
|
// TestSQLite_VKMCPConfig_MCPClientNameResolution tests that mcp_client_name is resolved to MCPClientID
|
|
// when loading virtual keys from config.json. This tests the fix for the foreign key constraint violation
|
|
// that occurred when config.json used mcp_client_name but the database expected mcp_client_id.
|
|
func TestSQLite_VKMCPConfig_MCPClientNameResolution(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
// First, create config.json with MCP client configs
|
|
mcpConfig := &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{
|
|
ID: "weather-mcp",
|
|
Name: "WeatherService",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
ConnectionString: schemas.NewEnvVar("http://localhost:8080/mcp"),
|
|
},
|
|
{
|
|
ID: "calendar-mcp",
|
|
Name: "CalendarService",
|
|
ConnectionType: schemas.MCPConnectionTypeHTTP,
|
|
ConnectionString: schemas.NewEnvVar("http://localhost:8081/mcp"),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create initial config data with MCP but no virtual keys
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
configData.MCP = mcpConfig
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// First load to set up MCP clients in DB
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify MCP clients were created
|
|
weatherClient, err := config1.ConfigStore.GetMCPClientByName(ctx, "WeatherService")
|
|
if err != nil || weatherClient == nil {
|
|
t.Fatalf("WeatherService MCP client not found: %v", err)
|
|
}
|
|
calendarClient, err := config1.ConfigStore.GetMCPClientByName(ctx, "CalendarService")
|
|
if err != nil || calendarClient == nil {
|
|
t.Fatalf("CalendarService MCP client not found: %v", err)
|
|
}
|
|
t.Logf("MCP clients created: WeatherService ID=%d, CalendarService ID=%d", weatherClient.ID, calendarClient.ID)
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Now create config.json with virtual key using mcp_client_name (not mcp_client_id)
|
|
// This simulates the real-world scenario where config.json uses human-readable names
|
|
dbPath := filepath.Join(tempDir, "config.db")
|
|
cfgPath := filepath.Join(tempDir, "config.json")
|
|
configJSON := fmt.Sprintf(`{
|
|
"$schema": "https://www.getbifrost.ai/schema",
|
|
"config_store": {
|
|
"enabled": true,
|
|
"type": "sqlite",
|
|
"config": {
|
|
"path": %s
|
|
}
|
|
},
|
|
"providers": {
|
|
"openai": {
|
|
"keys": [
|
|
{
|
|
"id": "%s",
|
|
"name": "openai-key",
|
|
"value": "sk-test123",
|
|
"weight": 1
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"mcp": {
|
|
"client_configs": [
|
|
{
|
|
"id": "weather-mcp",
|
|
"name": "WeatherService",
|
|
"connection_type": "http",
|
|
"http_url": "http://localhost:8080/mcp"
|
|
},
|
|
{
|
|
"id": "calendar-mcp",
|
|
"name": "CalendarService",
|
|
"connection_type": "http",
|
|
"http_url": "http://localhost:8081/mcp"
|
|
}
|
|
]
|
|
},
|
|
"governance": {
|
|
"virtual_keys": [
|
|
{
|
|
"id": "vk-with-mcp-names",
|
|
"name": "test-vk-mcp-names",
|
|
"description": "VK using mcp_client_name instead of mcp_client_id",
|
|
"value": "vk_test_mcp_names_123",
|
|
"is_active": true,
|
|
"mcp_configs": [
|
|
{
|
|
"mcp_client_name": "WeatherService",
|
|
"tools_to_execute": ["get_weather", "get_forecast"]
|
|
},
|
|
{
|
|
"mcp_client_name": "CalendarService",
|
|
"tools_to_execute": ["*"]
|
|
}
|
|
],
|
|
"provider_configs": [
|
|
{
|
|
"provider": "openai",
|
|
"weight": 1.0
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`, fmt.Sprintf("%q", dbPath), keyID)
|
|
|
|
// Write the config file directly
|
|
err = os.WriteFile(cfgPath, []byte(configJSON), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write config.json: %v", err)
|
|
}
|
|
|
|
// Load config - this should resolve mcp_client_name to MCPClientID
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig with mcp_client_name failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify VK was created
|
|
vk, err := config2.ConfigStore.GetVirtualKey(ctx, "vk-with-mcp-names")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get VK: %v", err)
|
|
}
|
|
if vk == nil {
|
|
t.Fatal("VK not found in DB")
|
|
}
|
|
t.Logf("✓ VK created: %s", vk.ID)
|
|
|
|
// Verify MCP configs were created with correct MCPClientIDs
|
|
mcpConfigs, err := config2.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-with-mcp-names")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs: %v", err)
|
|
}
|
|
|
|
if len(mcpConfigs) != 2 {
|
|
t.Fatalf("Expected 2 MCP configs, got %d", len(mcpConfigs))
|
|
}
|
|
|
|
// Build a map of MCPClientID to config for easier verification
|
|
configByClientID := make(map[uint]tables.TableVirtualKeyMCPConfig)
|
|
for _, mc := range mcpConfigs {
|
|
configByClientID[mc.MCPClientID] = mc
|
|
}
|
|
|
|
// Verify WeatherService config
|
|
weatherConfig, ok := configByClientID[weatherClient.ID]
|
|
if !ok {
|
|
t.Errorf("MCP config for WeatherService (ID=%d) not found", weatherClient.ID)
|
|
} else {
|
|
if len(weatherConfig.ToolsToExecute) != 2 {
|
|
t.Errorf("Expected 2 tools for WeatherService, got %d", len(weatherConfig.ToolsToExecute))
|
|
}
|
|
t.Logf("✓ WeatherService MCP config: MCPClientID=%d, tools=%v", weatherConfig.MCPClientID, weatherConfig.ToolsToExecute)
|
|
}
|
|
|
|
// Verify CalendarService config
|
|
calendarConfig, ok := configByClientID[calendarClient.ID]
|
|
if !ok {
|
|
t.Errorf("MCP config for CalendarService (ID=%d) not found", calendarClient.ID)
|
|
} else {
|
|
if len(calendarConfig.ToolsToExecute) != 1 || calendarConfig.ToolsToExecute[0] != "*" {
|
|
t.Errorf("Expected tools=[\"*\"] for CalendarService, got %v", calendarConfig.ToolsToExecute)
|
|
}
|
|
t.Logf("✓ CalendarService MCP config: MCPClientID=%d, tools=%v", calendarConfig.MCPClientID, calendarConfig.ToolsToExecute)
|
|
}
|
|
|
|
t.Log("✓ mcp_client_name was successfully resolved to MCPClientID")
|
|
}
|
|
|
|
// TestSQLite_VKMCPConfig_MCPClientNameNotFound tests graceful handling when mcp_client_name doesn't exist
|
|
func TestSQLite_VKMCPConfig_MCPClientNameNotFound(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
keyID := uuid.NewString()
|
|
|
|
// Create config.json with a virtual key that references a non-existent MCP client
|
|
configJSON := fmt.Sprintf(`{
|
|
"$schema": "https://www.getbifrost.ai/schema",
|
|
"config_store": {
|
|
"enabled": true,
|
|
"type": "sqlite",
|
|
"config": {
|
|
"path": "%s/config.db"
|
|
}
|
|
},
|
|
"providers": {
|
|
"openai": {
|
|
"keys": [
|
|
{
|
|
"id": "%s",
|
|
"name": "openai-key",
|
|
"value": "sk-test123",
|
|
"weight": 1
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"governance": {
|
|
"virtual_keys": [
|
|
{
|
|
"id": "vk-missing-mcp",
|
|
"name": "test-vk-missing-mcp",
|
|
"description": "VK referencing non-existent MCP client",
|
|
"value": "vk_test_missing_123",
|
|
"is_active": true,
|
|
"mcp_configs": [
|
|
{
|
|
"mcp_client_name": "NonExistentService",
|
|
"tools_to_execute": ["some_tool"]
|
|
}
|
|
],
|
|
"provider_configs": [
|
|
{
|
|
"provider": "openai",
|
|
"weight": 1.0
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`, tempDir, keyID)
|
|
|
|
// Write the config file
|
|
err := os.WriteFile(tempDir+"/config.json", []byte(configJSON), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write config.json: %v", err)
|
|
}
|
|
|
|
// Load config - should not fail, but should skip the unresolvable MCP config
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig should not fail when MCP client name is not found: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify VK was still created
|
|
vk, err := config.ConfigStore.GetVirtualKey(ctx, "vk-missing-mcp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get VK: %v", err)
|
|
}
|
|
if vk == nil {
|
|
t.Fatal("VK should have been created even with unresolvable MCP config")
|
|
}
|
|
t.Logf("✓ VK created despite unresolvable MCP client: %s", vk.ID)
|
|
|
|
// Verify MCP configs - should be empty since the client doesn't exist
|
|
mcpConfigs, err := config.ConfigStore.GetVirtualKeyMCPConfigs(ctx, "vk-missing-mcp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get MCP configs: %v", err)
|
|
}
|
|
|
|
if len(mcpConfigs) != 0 {
|
|
t.Errorf("Expected 0 MCP configs (unresolvable should be skipped), got %d", len(mcpConfigs))
|
|
} else {
|
|
t.Log("✓ Unresolvable MCP config was gracefully skipped")
|
|
}
|
|
}
|
|
|
|
// TestGenerateKeyHash_StableOrdering verifies that key hash is stable regardless of Models slice order
|
|
func TestGenerateKeyHash_StableOrdering(t *testing.T) {
|
|
// Key with models in order A
|
|
keyOrderA := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4", "gpt-3.5-turbo", "gpt-4-turbo"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
// Key with models in order B (reverse)
|
|
keyOrderB := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-4-turbo", "gpt-3.5-turbo", "gpt-4"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
// Key with models in order C (mixed)
|
|
keyOrderC := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: []string{"gpt-3.5-turbo", "gpt-4-turbo", "gpt-4"},
|
|
Weight: 1.5,
|
|
}
|
|
|
|
hashA, err := configstore.GenerateKeyHash(keyOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateKeyHash(keyOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateKeyHash(keyOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of Models order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of Models order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ Key hash is stable across different Models orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableProviderConfigOrdering verifies hash stability with different provider config orderings
|
|
func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) {
|
|
// VK with provider configs in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"claude-3"},
|
|
},
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "cohere",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"command"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with provider configs in order B (reversed)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "cohere",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"command"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"claude-3"},
|
|
},
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with provider configs in order C (mixed)
|
|
vkOrderC := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"claude-3"},
|
|
},
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "cohere",
|
|
Weight: ptrFloat64(1.5),
|
|
AllowedModels: []string{"command"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateVirtualKeyHash(vkOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of ProviderConfigs order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of ProviderConfigs order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable across different ProviderConfigs orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableAllowedModelsOrdering verifies hash stability with different AllowedModels orderings
|
|
func TestGenerateVirtualKeyHash_StableAllowedModelsOrdering(t *testing.T) {
|
|
// VK with AllowedModels in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo", "gpt-4-turbo", "gpt-4o"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with AllowedModels in order B (reversed)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo", "gpt-4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with AllowedModels in order C (mixed)
|
|
vkOrderC := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-3.5-turbo", "gpt-4o", "gpt-4", "gpt-4-turbo"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateVirtualKeyHash(vkOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of AllowedModels order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of AllowedModels order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable across different AllowedModels orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableKeyIDsOrdering verifies hash stability with different KeyIDs orderings
|
|
func TestGenerateVirtualKeyHash_StableKeyIDsOrdering(t *testing.T) {
|
|
// VK with KeyIDs in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-1", Name: "key-1"},
|
|
{KeyID: "key-2", Name: "key-2"},
|
|
{KeyID: "key-3", Name: "key-3"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with KeyIDs in order B (reversed)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-3", Name: "key-3"},
|
|
{KeyID: "key-2", Name: "key-2"},
|
|
{KeyID: "key-1", Name: "key-1"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with KeyIDs in order C (mixed)
|
|
vkOrderC := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4"},
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-2", Name: "key-2"},
|
|
{KeyID: "key-1", Name: "key-1"},
|
|
{KeyID: "key-3", Name: "key-3"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateVirtualKeyHash(vkOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of KeyIDs order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of KeyIDs order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable across different KeyIDs orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableMCPConfigOrdering verifies hash stability with different MCP config orderings
|
|
func TestGenerateVirtualKeyHash_StableMCPConfigOrdering(t *testing.T) {
|
|
// VK with MCP configs in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool2"},
|
|
},
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 3,
|
|
ToolsToExecute: []string{"tool3"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with MCP configs in order B (reversed)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 3,
|
|
ToolsToExecute: []string{"tool3"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool2"},
|
|
},
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with MCP configs in order C (mixed)
|
|
vkOrderC := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 2,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool2"},
|
|
},
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1"},
|
|
},
|
|
{
|
|
ID: 3,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 3,
|
|
ToolsToExecute: []string{"tool3"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateVirtualKeyHash(vkOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of MCPConfigs order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of MCPConfigs order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable across different MCPConfigs orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableToolsToExecuteOrdering verifies hash stability with different ToolsToExecute orderings
|
|
func TestGenerateVirtualKeyHash_StableToolsToExecuteOrdering(t *testing.T) {
|
|
// VK with ToolsToExecute in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool-a", "tool-b", "tool-c", "tool-d"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with ToolsToExecute in order B (reversed)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool-d", "tool-c", "tool-b", "tool-a"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with ToolsToExecute in order C (mixed)
|
|
vkOrderC := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
VirtualKeyID: "vk-1",
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool-c", "tool-a", "tool-d", "tool-b"},
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
hashC, err := configstore.GenerateVirtualKeyHash(vkOrderC)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order C: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of ToolsToExecute order: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
if hashA != hashC {
|
|
t.Errorf("Hash should be stable regardless of ToolsToExecute order: hashA=%s, hashC=%s", hashA, hashC)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable across different ToolsToExecute orderings: %s", hashA[:16])
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_StableCombinedOrdering verifies hash stability with all nested orderings randomized
|
|
func TestGenerateVirtualKeyHash_StableCombinedOrdering(t *testing.T) {
|
|
// VK with all elements in order A
|
|
vkOrderA := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 1,
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"},
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-1"},
|
|
{KeyID: "key-2"},
|
|
},
|
|
},
|
|
{
|
|
ID: 2,
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"claude-3", "claude-2"},
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-3"},
|
|
},
|
|
},
|
|
},
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 1,
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
},
|
|
{
|
|
ID: 2,
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool3", "tool4"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// VK with all elements in order B (everything reversed/shuffled)
|
|
vkOrderB := tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
Description: "Test virtual key",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
ID: 2,
|
|
Provider: "anthropic",
|
|
Weight: ptrFloat64(2.0),
|
|
AllowedModels: []string{"claude-2", "claude-3"}, // reversed
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-3"},
|
|
},
|
|
},
|
|
{
|
|
ID: 1,
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(1.0),
|
|
AllowedModels: []string{"gpt-3.5-turbo", "gpt-4"}, // reversed
|
|
Keys: []tables.TableKey{
|
|
{KeyID: "key-2"}, // reversed
|
|
{KeyID: "key-1"},
|
|
},
|
|
},
|
|
},
|
|
MCPConfigs: []tables.TableVirtualKeyMCPConfig{
|
|
{
|
|
ID: 2,
|
|
MCPClientID: 2,
|
|
ToolsToExecute: []string{"tool4", "tool3"}, // reversed
|
|
},
|
|
{
|
|
ID: 1,
|
|
MCPClientID: 1,
|
|
ToolsToExecute: []string{"tool2", "tool1"}, // reversed
|
|
},
|
|
},
|
|
}
|
|
|
|
hashA, err := configstore.GenerateVirtualKeyHash(vkOrderA)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order A: %v", err)
|
|
}
|
|
|
|
hashB, err := configstore.GenerateVirtualKeyHash(vkOrderB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for order B: %v", err)
|
|
}
|
|
|
|
if hashA != hashB {
|
|
t.Errorf("Hash should be stable regardless of all nested orderings: hashA=%s, hashB=%s", hashA, hashB)
|
|
}
|
|
|
|
t.Logf("✓ VirtualKey hash is stable with all nested orderings shuffled: %s", hashA[:16])
|
|
}
|
|
|
|
// ===================================================================================
|
|
// BUDGET HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateBudgetHash tests hash generation for budgets
|
|
func TestGenerateBudgetHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
// Test basic hash generation
|
|
budget1 := tables.TableBudget{
|
|
ID: "budget-1",
|
|
MaxLimit: 100.0,
|
|
ResetDuration: "1d",
|
|
}
|
|
|
|
hash1, err := configstore.GenerateBudgetHash(budget1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same budget should produce same hash
|
|
hash1Again, _ := configstore.GenerateBudgetHash(budget1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same budget should produce same hash")
|
|
}
|
|
|
|
// Different ID should produce different hash
|
|
budget2 := budget1
|
|
budget2.ID = "budget-2"
|
|
hash2, _ := configstore.GenerateBudgetHash(budget2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different ID should produce different hash")
|
|
}
|
|
|
|
// Different MaxLimit should produce different hash
|
|
budget3 := budget1
|
|
budget3.MaxLimit = 200.0
|
|
hash3, _ := configstore.GenerateBudgetHash(budget3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different MaxLimit should produce different hash")
|
|
}
|
|
|
|
// Different ResetDuration should produce different hash
|
|
budget4 := budget1
|
|
budget4.ResetDuration = "1h"
|
|
hash4, _ := configstore.GenerateBudgetHash(budget4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different ResetDuration should produce different hash")
|
|
}
|
|
|
|
t.Log("✓ Budget hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestSQLite_Budget_NewFromFile tests new budget from config file
|
|
func TestSQLite_Budget_NewFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config with governance containing a budget
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1d"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify budget in memory
|
|
if config.GovernanceConfig == nil || len(config.GovernanceConfig.Budgets) != 1 {
|
|
t.Fatal("Expected 1 budget in governance config")
|
|
}
|
|
if config.GovernanceConfig.Budgets[0].ID != "budget-1" {
|
|
t.Error("Budget ID mismatch")
|
|
}
|
|
|
|
// Verify budget in DB
|
|
govConfig, err := config.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config: %v", err)
|
|
}
|
|
if len(govConfig.Budgets) != 1 {
|
|
t.Fatalf("Expected 1 budget in DB, got %d", len(govConfig.Budgets))
|
|
}
|
|
if govConfig.Budgets[0].ConfigHash == "" {
|
|
t.Error("Expected budget config hash to be set")
|
|
}
|
|
|
|
t.Log("✓ New budget from file added to DB with hash")
|
|
}
|
|
|
|
// TestSQLite_Budget_HashMatch_DBPreserved tests DB budget preserved when hash matches
|
|
func TestSQLite_Budget_HashMatch_DBPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1d"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Get hash from first load
|
|
gov1, _ := config1.ConfigStore.GetGovernanceConfig(ctx)
|
|
firstHash := gov1.Budgets[0].ConfigHash
|
|
config1.Close(ctx)
|
|
|
|
// Second load - same config
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if gov2.Budgets[0].ConfigHash != firstHash {
|
|
t.Error("Hash should remain unchanged on reload")
|
|
}
|
|
|
|
t.Log("✓ Budget hash match - DB preserved")
|
|
}
|
|
|
|
// TestSQLite_Budget_HashMismatch_FileSync tests file sync when hash differs
|
|
func TestSQLite_Budget_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1d"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Update config file with different MaxLimit
|
|
configData.Governance.Budgets[0].MaxLimit = 200.0
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Second load - should sync from file
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if gov2.Budgets[0].MaxLimit != 200.0 {
|
|
t.Errorf("Expected MaxLimit 200.0, got %f", gov2.Budgets[0].MaxLimit)
|
|
}
|
|
|
|
t.Log("✓ Budget hash mismatch - synced from file")
|
|
}
|
|
|
|
// TestSQLite_Budget_DBOnly_Preserved tests dashboard-added budget is preserved
|
|
func TestSQLite_Budget_DBOnly_Preserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1d"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Add budget via "dashboard" (directly to DB)
|
|
dashboardBudget := &tables.TableBudget{
|
|
ID: "budget-dashboard",
|
|
MaxLimit: 500.0,
|
|
ResetDuration: "1w",
|
|
}
|
|
if err := config1.ConfigStore.CreateBudget(ctx, dashboardBudget); err != nil {
|
|
t.Fatalf("Failed to create dashboard budget: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Reload - dashboard budget should be preserved
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if len(gov2.Budgets) != 2 {
|
|
t.Fatalf("Expected 2 budgets, got %d", len(gov2.Budgets))
|
|
}
|
|
|
|
// Find dashboard budget
|
|
found := false
|
|
for _, b := range gov2.Budgets {
|
|
if b.ID == "budget-dashboard" {
|
|
found = true
|
|
if b.MaxLimit != 500.0 {
|
|
t.Error("Dashboard budget MaxLimit changed")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Dashboard-added budget was not preserved")
|
|
}
|
|
|
|
t.Log("✓ Dashboard-added budget preserved")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// RATE LIMIT HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateRateLimitHash tests hash generation for rate limits
|
|
func TestGenerateRateLimitHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
tokenMax := int64(1000)
|
|
tokenDur := "1h"
|
|
reqMax := int64(100)
|
|
reqDur := "1m"
|
|
|
|
rl1 := tables.TableRateLimit{
|
|
ID: "rl-1",
|
|
TokenMaxLimit: &tokenMax,
|
|
TokenResetDuration: &tokenDur,
|
|
RequestMaxLimit: &reqMax,
|
|
RequestResetDuration: &reqDur,
|
|
}
|
|
|
|
hash1, err := configstore.GenerateRateLimitHash(rl1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same rate limit should produce same hash
|
|
hash1Again, _ := configstore.GenerateRateLimitHash(rl1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same rate limit should produce same hash")
|
|
}
|
|
|
|
// Different ID should produce different hash
|
|
rl2 := rl1
|
|
rl2.ID = "rl-2"
|
|
hash2, _ := configstore.GenerateRateLimitHash(rl2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different ID should produce different hash")
|
|
}
|
|
|
|
// Different TokenMaxLimit should produce different hash
|
|
newTokenMax := int64(2000)
|
|
rl3 := rl1
|
|
rl3.TokenMaxLimit = &newTokenMax
|
|
hash3, _ := configstore.GenerateRateLimitHash(rl3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different TokenMaxLimit should produce different hash")
|
|
}
|
|
|
|
// Different TokenResetDuration should produce different hash
|
|
newTokenDur := "2h"
|
|
rl4 := rl1
|
|
rl4.TokenResetDuration = &newTokenDur
|
|
hash4, _ := configstore.GenerateRateLimitHash(rl4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different TokenResetDuration should produce different hash")
|
|
}
|
|
|
|
// Different RequestMaxLimit should produce different hash
|
|
newReqMax := int64(200)
|
|
rl5 := rl1
|
|
rl5.RequestMaxLimit = &newReqMax
|
|
hash5, _ := configstore.GenerateRateLimitHash(rl5)
|
|
if hash1 == hash5 {
|
|
t.Error("Different RequestMaxLimit should produce different hash")
|
|
}
|
|
|
|
// Different RequestResetDuration should produce different hash
|
|
newReqDur := "2m"
|
|
rl6 := rl1
|
|
rl6.RequestResetDuration = &newReqDur
|
|
hash6, _ := configstore.GenerateRateLimitHash(rl6)
|
|
if hash1 == hash6 {
|
|
t.Error("Different RequestResetDuration should produce different hash")
|
|
}
|
|
|
|
// Nil vs set fields should produce different hash
|
|
rl7 := tables.TableRateLimit{ID: "rl-1"}
|
|
hash7, _ := configstore.GenerateRateLimitHash(rl7)
|
|
if hash1 == hash7 {
|
|
t.Error("Nil fields should produce different hash than set fields")
|
|
}
|
|
|
|
t.Log("✓ RateLimit hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestSQLite_RateLimit_NewFromFile tests new rate limit from config file
|
|
func TestSQLite_RateLimit_NewFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
tokenMax := int64(1000)
|
|
tokenDur := "1h"
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
RateLimits: []tables.TableRateLimit{
|
|
{ID: "rl-1", TokenMaxLimit: &tokenMax, TokenResetDuration: &tokenDur},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify in DB
|
|
govConfig, err := config.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config: %v", err)
|
|
}
|
|
if len(govConfig.RateLimits) != 1 {
|
|
t.Fatalf("Expected 1 rate limit in DB, got %d", len(govConfig.RateLimits))
|
|
}
|
|
if govConfig.RateLimits[0].ConfigHash == "" {
|
|
t.Error("Expected rate limit config hash to be set")
|
|
}
|
|
|
|
t.Log("✓ New rate limit from file added to DB with hash")
|
|
}
|
|
|
|
// TestSQLite_RateLimit_HashMismatch_FileSync tests file sync when hash differs
|
|
func TestSQLite_RateLimit_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
tokenMax := int64(1000)
|
|
tokenDur := "1h"
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
RateLimits: []tables.TableRateLimit{
|
|
{ID: "rl-1", TokenMaxLimit: &tokenMax, TokenResetDuration: &tokenDur},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Update config file
|
|
newTokenMax := int64(2000)
|
|
configData.Governance.RateLimits[0].TokenMaxLimit = &newTokenMax
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Second load
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if *gov2.RateLimits[0].TokenMaxLimit != 2000 {
|
|
t.Errorf("Expected TokenMaxLimit 2000, got %d", *gov2.RateLimits[0].TokenMaxLimit)
|
|
}
|
|
|
|
t.Log("✓ RateLimit hash mismatch - synced from file")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// CUSTOMER HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateCustomerHash tests hash generation for customers
|
|
func TestGenerateCustomerHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
budgetID := "budget-1"
|
|
customer1 := tables.TableCustomer{
|
|
ID: "customer-1",
|
|
Name: "Test Customer",
|
|
BudgetID: &budgetID,
|
|
}
|
|
|
|
hash1, err := configstore.GenerateCustomerHash(customer1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same customer should produce same hash
|
|
hash1Again, _ := configstore.GenerateCustomerHash(customer1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same customer should produce same hash")
|
|
}
|
|
|
|
// Different ID should produce different hash
|
|
customer2 := customer1
|
|
customer2.ID = "customer-2"
|
|
hash2, _ := configstore.GenerateCustomerHash(customer2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different ID should produce different hash")
|
|
}
|
|
|
|
// Different Name should produce different hash
|
|
customer3 := customer1
|
|
customer3.Name = "Different Customer"
|
|
hash3, _ := configstore.GenerateCustomerHash(customer3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different Name should produce different hash")
|
|
}
|
|
|
|
// Different BudgetID should produce different hash
|
|
newBudgetID := "budget-2"
|
|
customer4 := customer1
|
|
customer4.BudgetID = &newBudgetID
|
|
hash4, _ := configstore.GenerateCustomerHash(customer4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different BudgetID should produce different hash")
|
|
}
|
|
|
|
// Nil BudgetID should produce different hash
|
|
customer5 := customer1
|
|
customer5.BudgetID = nil
|
|
hash5, _ := configstore.GenerateCustomerHash(customer5)
|
|
if hash1 == hash5 {
|
|
t.Error("Nil BudgetID should produce different hash")
|
|
}
|
|
|
|
t.Log("✓ Customer hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestSQLite_Customer_NewFromFile tests new customer from config file
|
|
func TestSQLite_Customer_NewFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Customers: []tables.TableCustomer{
|
|
{ID: "customer-1", Name: "Test Customer"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
govConfig, err := config.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config: %v", err)
|
|
}
|
|
if len(govConfig.Customers) != 1 {
|
|
t.Fatalf("Expected 1 customer in DB, got %d", len(govConfig.Customers))
|
|
}
|
|
if govConfig.Customers[0].ConfigHash == "" {
|
|
t.Error("Expected customer config hash to be set")
|
|
}
|
|
|
|
t.Log("✓ New customer from file added to DB with hash")
|
|
}
|
|
|
|
// TestSQLite_Customer_HashMismatch_FileSync tests file sync when hash differs
|
|
func TestSQLite_Customer_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Customers: []tables.TableCustomer{
|
|
{ID: "customer-1", Name: "Test Customer"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Update config file
|
|
configData.Governance.Customers[0].Name = "Updated Customer"
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if gov2.Customers[0].Name != "Updated Customer" {
|
|
t.Errorf("Expected Name 'Updated Customer', got '%s'", gov2.Customers[0].Name)
|
|
}
|
|
|
|
t.Log("✓ Customer hash mismatch - synced from file")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// TEAM HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateTeamHash tests hash generation for teams
|
|
func TestGenerateTeamHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
customerID := "customer-1"
|
|
budgetID := "budget-1"
|
|
|
|
team1 := tables.TableTeam{
|
|
ID: "team-1",
|
|
Name: "Test Team",
|
|
CustomerID: &customerID,
|
|
Budgets: []tables.TableBudget{{ID: budgetID}},
|
|
ParsedProfile: map[string]interface{}{"key": "value"},
|
|
ParsedConfig: map[string]interface{}{"setting": true},
|
|
ParsedClaims: map[string]interface{}{"role": "admin"},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateTeamHash(team1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same team should produce same hash
|
|
hash1Again, _ := configstore.GenerateTeamHash(team1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same team should produce same hash")
|
|
}
|
|
|
|
// Different ID should produce different hash
|
|
team2 := team1
|
|
team2.ID = "team-2"
|
|
hash2, _ := configstore.GenerateTeamHash(team2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different ID should produce different hash")
|
|
}
|
|
|
|
// Different Name should produce different hash
|
|
team3 := team1
|
|
team3.Name = "Different Team"
|
|
hash3, _ := configstore.GenerateTeamHash(team3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different Name should produce different hash")
|
|
}
|
|
|
|
// Different CustomerID should produce different hash
|
|
newCustomerID := "customer-2"
|
|
team4 := team1
|
|
team4.CustomerID = &newCustomerID
|
|
hash4, _ := configstore.GenerateTeamHash(team4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different CustomerID should produce different hash")
|
|
}
|
|
|
|
// Different BudgetID should produce different hash
|
|
newBudgetID := "budget-2"
|
|
team5 := team1
|
|
team5.Budgets = []tables.TableBudget{{ID: newBudgetID}}
|
|
hash5, _ := configstore.GenerateTeamHash(team5)
|
|
if hash1 == hash5 {
|
|
t.Error("Different BudgetID should produce different hash")
|
|
}
|
|
|
|
// Different ParsedProfile should produce different hash
|
|
team6 := team1
|
|
team6.ParsedProfile = map[string]interface{}{"key": "different"}
|
|
hash6, _ := configstore.GenerateTeamHash(team6)
|
|
if hash1 == hash6 {
|
|
t.Error("Different ParsedProfile should produce different hash")
|
|
}
|
|
|
|
// Different ParsedConfig should produce different hash
|
|
team7 := team1
|
|
team7.ParsedConfig = map[string]interface{}{"setting": false}
|
|
hash7, _ := configstore.GenerateTeamHash(team7)
|
|
if hash1 == hash7 {
|
|
t.Error("Different ParsedConfig should produce different hash")
|
|
}
|
|
|
|
// Different ParsedClaims should produce different hash
|
|
team8 := team1
|
|
team8.ParsedClaims = map[string]interface{}{"role": "user"}
|
|
hash8, _ := configstore.GenerateTeamHash(team8)
|
|
if hash1 == hash8 {
|
|
t.Error("Different ParsedClaims should produce different hash")
|
|
}
|
|
|
|
// Nil optional fields should produce different hash
|
|
team9 := tables.TableTeam{ID: "team-1", Name: "Test Team"}
|
|
hash9, _ := configstore.GenerateTeamHash(team9)
|
|
if hash1 == hash9 {
|
|
t.Error("Nil optional fields should produce different hash")
|
|
}
|
|
|
|
t.Log("✓ Team hash generation works correctly for all fields")
|
|
}
|
|
|
|
// TestSQLite_Team_NewFromFile tests new team from config file
|
|
func TestSQLite_Team_NewFromFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Teams: []tables.TableTeam{
|
|
{ID: "team-1", Name: "Test Team"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
govConfig, err := config.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config: %v", err)
|
|
}
|
|
if len(govConfig.Teams) != 1 {
|
|
t.Fatalf("Expected 1 team in DB, got %d", len(govConfig.Teams))
|
|
}
|
|
if govConfig.Teams[0].ConfigHash == "" {
|
|
t.Error("Expected team config hash to be set")
|
|
}
|
|
|
|
t.Log("✓ New team from file added to DB with hash")
|
|
}
|
|
|
|
// TestSQLite_Team_HashMismatch_FileSync tests file sync when hash differs
|
|
func TestSQLite_Team_HashMismatch_FileSync(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Note: Profile field has json:"-", so we use ParsedProfile for JSON config
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Teams: []tables.TableTeam{
|
|
{ID: "team-1", Name: "Test Team", ParsedProfile: map[string]interface{}{"key": "value"}},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Update config file with different Name (which affects hash)
|
|
configData.Governance.Teams[0].Name = "Updated Team"
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if gov2.Teams[0].Name != "Updated Team" {
|
|
t.Errorf("Expected Name 'Updated Team', got '%s'", gov2.Teams[0].Name)
|
|
}
|
|
|
|
t.Log("✓ Team hash mismatch - synced from file")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// MCP CLIENT HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateMCPClientHash tests hash generation for MCP clients
|
|
func TestGenerateMCPClientHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
connStr := "http://localhost:8080"
|
|
mcp1 := tables.TableMCPClient{
|
|
ClientID: "mcp-1",
|
|
Name: "Test MCP",
|
|
ConnectionType: "sse",
|
|
ConnectionString: schemas.NewEnvVar(connStr),
|
|
ToolsToExecute: []string{"tool1", "tool2"},
|
|
Headers: map[string]schemas.EnvVar{"Authorization": *schemas.NewEnvVar("Bearer token")},
|
|
}
|
|
|
|
hash1, err := configstore.GenerateMCPClientHash(mcp1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same MCP should produce same hash
|
|
hash1Again, _ := configstore.GenerateMCPClientHash(mcp1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same MCP should produce same hash")
|
|
}
|
|
|
|
// Different ClientID should produce different hash
|
|
mcp2 := mcp1
|
|
mcp2.ClientID = "mcp-2"
|
|
hash2, _ := configstore.GenerateMCPClientHash(mcp2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different ClientID should produce different hash")
|
|
}
|
|
|
|
// Different Name should produce different hash
|
|
mcp3 := mcp1
|
|
mcp3.Name = "Different MCP"
|
|
hash3, _ := configstore.GenerateMCPClientHash(mcp3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different Name should produce different hash")
|
|
}
|
|
|
|
// Different ConnectionType should produce different hash
|
|
mcp4 := mcp1
|
|
mcp4.ConnectionType = "stdio"
|
|
hash4, _ := configstore.GenerateMCPClientHash(mcp4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different ConnectionType should produce different hash")
|
|
}
|
|
|
|
// Different ConnectionString should produce different hash
|
|
newConnStr := "http://localhost:9090"
|
|
mcp5 := mcp1
|
|
mcp5.ConnectionString = schemas.NewEnvVar(newConnStr)
|
|
hash5, _ := configstore.GenerateMCPClientHash(mcp5)
|
|
if hash1 == hash5 {
|
|
t.Error("Different ConnectionString should produce different hash")
|
|
}
|
|
|
|
// Different ToolsToExecute should produce different hash
|
|
mcp6 := mcp1
|
|
mcp6.ToolsToExecute = []string{"tool3", "tool4"}
|
|
hash6, _ := configstore.GenerateMCPClientHash(mcp6)
|
|
if hash1 == hash6 {
|
|
t.Error("Different ToolsToExecute should produce different hash")
|
|
}
|
|
|
|
// Different Headers should produce different hash
|
|
mcp7 := mcp1
|
|
mcp7.Headers = map[string]schemas.EnvVar{"X-Custom": *schemas.NewEnvVar("value")}
|
|
hash7, _ := configstore.GenerateMCPClientHash(mcp7)
|
|
if hash1 == hash7 {
|
|
t.Error("Different Headers should produce different hash")
|
|
}
|
|
|
|
// ToolsToExecute order should not matter (sorted)
|
|
mcp8 := mcp1
|
|
mcp8.ToolsToExecute = []string{"tool2", "tool1"} // Reversed order
|
|
hash8, _ := configstore.GenerateMCPClientHash(mcp8)
|
|
if hash1 != hash8 {
|
|
t.Error("ToolsToExecute order should not affect hash")
|
|
}
|
|
|
|
// Headers order should not matter (sorted by key)
|
|
mcp9 := mcp1
|
|
mcp9.Headers = map[string]schemas.EnvVar{"Authorization": *schemas.NewEnvVar("Bearer token")} // Same content
|
|
hash9, _ := configstore.GenerateMCPClientHash(mcp9)
|
|
if hash1 != hash9 {
|
|
t.Error("Same headers should produce same hash")
|
|
}
|
|
|
|
t.Log("✓ MCPClient hash generation works correctly for all fields")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// PLUGIN HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGeneratePluginHash tests hash generation for plugins
|
|
func TestGeneratePluginHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
path := "/path/to/plugin"
|
|
plugin1 := tables.TablePlugin{
|
|
Name: "test-plugin",
|
|
Enabled: true,
|
|
Path: &path,
|
|
ConfigJSON: `{"setting": "value"}`,
|
|
Version: 1,
|
|
}
|
|
|
|
hash1, err := configstore.GeneratePluginHash(plugin1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same plugin should produce same hash
|
|
hash1Again, _ := configstore.GeneratePluginHash(plugin1)
|
|
if hash1 != hash1Again {
|
|
t.Error("Same plugin should produce same hash")
|
|
}
|
|
|
|
// Different Name should produce different hash
|
|
plugin2 := plugin1
|
|
plugin2.Name = "different-plugin"
|
|
hash2, _ := configstore.GeneratePluginHash(plugin2)
|
|
if hash1 == hash2 {
|
|
t.Error("Different Name should produce different hash")
|
|
}
|
|
|
|
// Different Enabled should produce different hash
|
|
plugin3 := plugin1
|
|
plugin3.Enabled = false
|
|
hash3, _ := configstore.GeneratePluginHash(plugin3)
|
|
if hash1 == hash3 {
|
|
t.Error("Different Enabled should produce different hash")
|
|
}
|
|
|
|
// Different Path should produce different hash
|
|
newPath := "/different/path"
|
|
plugin4 := plugin1
|
|
plugin4.Path = &newPath
|
|
hash4, _ := configstore.GeneratePluginHash(plugin4)
|
|
if hash1 == hash4 {
|
|
t.Error("Different Path should produce different hash")
|
|
}
|
|
|
|
// Different ConfigJSON should produce different hash
|
|
plugin5 := plugin1
|
|
plugin5.ConfigJSON = `{"setting": "different"}`
|
|
hash5, _ := configstore.GeneratePluginHash(plugin5)
|
|
if hash1 == hash5 {
|
|
t.Error("Different ConfigJSON should produce different hash")
|
|
}
|
|
|
|
// Different Version should produce different hash
|
|
plugin6 := plugin1
|
|
plugin6.Version = 2
|
|
hash6, _ := configstore.GeneratePluginHash(plugin6)
|
|
if hash1 == hash6 {
|
|
t.Error("Different Version should produce different hash")
|
|
}
|
|
|
|
// Nil Path should produce different hash
|
|
plugin7 := plugin1
|
|
plugin7.Path = nil
|
|
hash7, _ := configstore.GeneratePluginHash(plugin7)
|
|
if hash1 == hash7 {
|
|
t.Error("Nil Path should produce different hash")
|
|
}
|
|
|
|
t.Log("✓ Plugin hash generation works correctly for all fields")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// PLUGIN SEQUENCING TESTS
|
|
// ===================================================================================
|
|
|
|
// mockPlugin is a minimal BasePlugin implementation for ordering tests.
|
|
type mockPlugin struct {
|
|
name string
|
|
}
|
|
|
|
func (p *mockPlugin) GetName() string { return p.name }
|
|
func (p *mockPlugin) Cleanup() error { return nil }
|
|
|
|
// mockLLMPlugin extends mockPlugin with LLMPlugin interface for cache rebuild tests.
|
|
type mockLLMPlugin struct {
|
|
mockPlugin
|
|
}
|
|
|
|
func (p *mockLLMPlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) {
|
|
return req, nil, nil
|
|
}
|
|
|
|
func (p *mockLLMPlugin) PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
|
|
return resp, bifrostErr, nil
|
|
}
|
|
|
|
// newTestConfigForPlugins creates a minimal Config suitable for plugin ordering tests.
|
|
func newTestConfigForPlugins() *Config {
|
|
initTestLogger()
|
|
return &Config{}
|
|
}
|
|
|
|
// TestSetPluginOrderInfo_Defaults verifies that nil placement defaults to "post_builtin" and nil order defaults to 0.
|
|
func TestSetPluginOrderInfo_Defaults(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// nil placement → post_builtin, nil order → 0
|
|
config.SetPluginOrderInfo("plugin-a", nil, nil)
|
|
info := config.pluginOrderMap["plugin-a"]
|
|
require.Equal(t, schemas.PluginPlacementPostBuiltin, info.Placement, "nil placement should default to post_builtin")
|
|
require.Equal(t, 0, info.Order, "nil order should default to 0")
|
|
|
|
// Explicit values are preserved
|
|
config.SetPluginOrderInfo("plugin-b", schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(5))
|
|
info = config.pluginOrderMap["plugin-b"]
|
|
require.Equal(t, schemas.PluginPlacementPreBuiltin, info.Placement)
|
|
require.Equal(t, 5, info.Order)
|
|
|
|
// Explicit builtin placement
|
|
config.SetPluginOrderInfo("plugin-c", schemas.Ptr(schemas.PluginPlacementBuiltin), schemas.Ptr(1))
|
|
info = config.pluginOrderMap["plugin-c"]
|
|
require.Equal(t, schemas.PluginPlacementBuiltin, info.Placement)
|
|
require.Equal(t, 1, info.Order)
|
|
}
|
|
|
|
// TestSortAndRebuildPlugins_PlacementGroups verifies plugins sort into pre_builtin → builtin → post_builtin.
|
|
func TestSortAndRebuildPlugins_PlacementGroups(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// Register plugins in deliberately wrong order: post, builtin, pre, post, pre
|
|
plugins := []struct {
|
|
name string
|
|
placement schemas.PluginPlacement
|
|
order int
|
|
}{
|
|
{"post-1", schemas.PluginPlacementPostBuiltin, 0},
|
|
{"builtin-1", schemas.PluginPlacementBuiltin, 1},
|
|
{"pre-1", schemas.PluginPlacementPreBuiltin, 0},
|
|
{"post-2", schemas.PluginPlacementPostBuiltin, 1},
|
|
{"pre-2", schemas.PluginPlacementPreBuiltin, 1},
|
|
}
|
|
for _, p := range plugins {
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: p.name}))
|
|
config.SetPluginOrderInfo(p.name, schemas.Ptr(p.placement), schemas.Ptr(p.order))
|
|
}
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
got := config.GetPluginOrder()
|
|
expected := []string{"pre-1", "pre-2", "builtin-1", "post-1", "post-2"}
|
|
require.Equal(t, expected, got, "plugins should be sorted: pre_builtin → builtin → post_builtin")
|
|
}
|
|
|
|
// TestSortAndRebuildPlugins_OrderWithinGroup verifies that within a group, lower order comes first.
|
|
func TestSortAndRebuildPlugins_OrderWithinGroup(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
names := []string{"plugin-order-2", "plugin-order-0", "plugin-order-1"}
|
|
orders := []int{2, 0, 1}
|
|
for i, name := range names {
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: name}))
|
|
config.SetPluginOrderInfo(name, schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(orders[i]))
|
|
}
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
got := config.GetPluginOrder()
|
|
expected := []string{"plugin-order-0", "plugin-order-1", "plugin-order-2"}
|
|
require.Equal(t, expected, got, "within same placement group, plugins should sort by ascending order")
|
|
}
|
|
|
|
// TestSortAndRebuildPlugins_StableSort verifies that plugins with same placement and order
|
|
// preserve their registration order (stable sort).
|
|
func TestSortAndRebuildPlugins_StableSort(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// Register 3 plugins with identical placement and order
|
|
names := []string{"alpha", "beta", "gamma"}
|
|
for _, name := range names {
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: name}))
|
|
config.SetPluginOrderInfo(name, schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(0))
|
|
}
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
got := config.GetPluginOrder()
|
|
require.Equal(t, names, got, "same placement+order should preserve registration order")
|
|
}
|
|
|
|
// TestSortAndRebuildPlugins_UnknownPlacement verifies that plugins with unknown placement
|
|
// get default rank (treated as post_builtin, not pre_builtin).
|
|
func TestSortAndRebuildPlugins_UnknownPlacement(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// Register a pre_builtin, a post_builtin, and one with an invalid placement
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "pre"}))
|
|
config.SetPluginOrderInfo("pre", schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(0))
|
|
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "post"}))
|
|
config.SetPluginOrderInfo("post", schemas.Ptr(schemas.PluginPlacementPostBuiltin), schemas.Ptr(0))
|
|
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "unknown"}))
|
|
// Directly manipulate pluginOrderMap to simulate an invalid placement
|
|
config.pluginOrderMap["unknown"] = pluginOrderInfo{Placement: "invalid_placement", Order: 0}
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
got := config.GetPluginOrder()
|
|
require.Equal(t, "pre", got[0], "pre_builtin should be first")
|
|
// "unknown" should NOT be before "pre" (i.e., unknown placement should not get rank 0)
|
|
require.Equal(t, "unknown", got[len(got)-1], "unknown placement should sort to the end (default rank)")
|
|
}
|
|
|
|
// TestLoadDefaultPlugins_PreservesPlacementAndOrder is the primary regression test:
|
|
// verifies that loading plugins from store correctly maps Placement and Order from DB rows.
|
|
func TestLoadDefaultPlugins_PreservesPlacementAndOrder(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
preBuiltin := schemas.PluginPlacement("pre_builtin")
|
|
order2 := 2
|
|
postBuiltin := schemas.PluginPlacement("post_builtin")
|
|
order5 := 5
|
|
|
|
mock := &MockConfigStore{
|
|
plugins: []*tables.TablePlugin{
|
|
{
|
|
Name: "plugin-pre",
|
|
Enabled: true,
|
|
Placement: &preBuiltin,
|
|
Order: &order2,
|
|
},
|
|
{
|
|
Name: "plugin-post",
|
|
Enabled: true,
|
|
Placement: &postBuiltin,
|
|
Order: &order5,
|
|
},
|
|
{
|
|
Name: "plugin-nil",
|
|
Enabled: true,
|
|
// Placement and Order intentionally nil
|
|
},
|
|
},
|
|
}
|
|
|
|
config := &Config{ConfigStore: mock}
|
|
loadPlugins(context.Background(), config, &ConfigData{})
|
|
require.Len(t, config.PluginConfigs, 3)
|
|
|
|
// Verify pre_builtin plugin
|
|
require.NotNil(t, config.PluginConfigs[0].Placement, "Placement should not be nil for plugin-pre")
|
|
require.Equal(t, schemas.PluginPlacementPreBuiltin, *config.PluginConfigs[0].Placement)
|
|
require.NotNil(t, config.PluginConfigs[0].Order)
|
|
require.Equal(t, 2, *config.PluginConfigs[0].Order)
|
|
|
|
// Verify post_builtin plugin
|
|
require.NotNil(t, config.PluginConfigs[1].Placement, "Placement should not be nil for plugin-post")
|
|
require.Equal(t, schemas.PluginPlacementPostBuiltin, *config.PluginConfigs[1].Placement)
|
|
require.NotNil(t, config.PluginConfigs[1].Order)
|
|
require.Equal(t, 5, *config.PluginConfigs[1].Order)
|
|
|
|
// Verify nil placement/order are preserved as nil (not silently defaulted here)
|
|
require.Nil(t, config.PluginConfigs[2].Placement, "nil Placement in DB should stay nil in PluginConfig")
|
|
require.Nil(t, config.PluginConfigs[2].Order, "nil Order in DB should stay nil in PluginConfig")
|
|
}
|
|
|
|
// TestGetPluginOrder_MatchesSortedOrder verifies GetPluginOrder returns names
|
|
// in the same order as the sorted BasePlugins.
|
|
func TestGetPluginOrder_MatchesSortedOrder(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// Register in reverse order
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "c-post"}))
|
|
config.SetPluginOrderInfo("c-post", schemas.Ptr(schemas.PluginPlacementPostBuiltin), schemas.Ptr(0))
|
|
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "a-pre"}))
|
|
config.SetPluginOrderInfo("a-pre", schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(0))
|
|
|
|
require.NoError(t, config.ReloadPlugin(&mockPlugin{name: "b-builtin"}))
|
|
config.SetPluginOrderInfo("b-builtin", schemas.Ptr(schemas.PluginPlacementBuiltin), schemas.Ptr(0))
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
order := config.GetPluginOrder()
|
|
require.Equal(t, []string{"a-pre", "b-builtin", "c-post"}, order)
|
|
|
|
// Also verify BasePlugins directly matches
|
|
basePlugins := config.BasePlugins.Load()
|
|
require.NotNil(t, basePlugins)
|
|
for i, name := range order {
|
|
require.Equal(t, name, (*basePlugins)[i].GetName(), "BasePlugins[%d] should match GetPluginOrder[%d]", i, i)
|
|
}
|
|
}
|
|
|
|
// TestSortAndRebuildPlugins_RebuildsCaches verifies that LLMPlugins interface cache
|
|
// is rebuilt in the correct sorted order after SortAndRebuildPlugins.
|
|
func TestSortAndRebuildPlugins_RebuildsCaches(t *testing.T) {
|
|
config := newTestConfigForPlugins()
|
|
|
|
// Register LLM plugins in reverse order
|
|
require.NoError(t, config.ReloadPlugin(&mockLLMPlugin{mockPlugin{name: "llm-post"}}))
|
|
config.SetPluginOrderInfo("llm-post", schemas.Ptr(schemas.PluginPlacementPostBuiltin), schemas.Ptr(0))
|
|
|
|
require.NoError(t, config.ReloadPlugin(&mockLLMPlugin{mockPlugin{name: "llm-pre"}}))
|
|
config.SetPluginOrderInfo("llm-pre", schemas.Ptr(schemas.PluginPlacementPreBuiltin), schemas.Ptr(0))
|
|
|
|
config.SortAndRebuildPlugins()
|
|
|
|
// Verify LLMPlugins cache is sorted
|
|
llmPlugins := config.LLMPlugins.Load()
|
|
require.NotNil(t, llmPlugins)
|
|
require.Len(t, *llmPlugins, 2)
|
|
require.Equal(t, "llm-pre", (*llmPlugins)[0].GetName(), "LLM cache should have pre_builtin first")
|
|
require.Equal(t, "llm-post", (*llmPlugins)[1].GetName(), "LLM cache should have post_builtin second")
|
|
}
|
|
|
|
// TestMergePluginsFromFile_PlacementChange verifies that mergePlugins
|
|
// replaces a plugin when its placement or order changes, even without a version bump.
|
|
func TestMergePluginsFromFile_PlacementChange(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
preBuiltin := schemas.PluginPlacement("pre_builtin")
|
|
postBuiltin := schemas.PluginPlacement("post_builtin")
|
|
order0 := 0
|
|
order1 := 1
|
|
version1 := int16(1)
|
|
|
|
// Simulate DB state: plugin-a is post_builtin with order 0
|
|
mock := &MockConfigStore{
|
|
plugins: []*tables.TablePlugin{
|
|
{
|
|
Name: "plugin-a",
|
|
Enabled: true,
|
|
Placement: &postBuiltin,
|
|
Order: &order0,
|
|
Version: 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
config := &Config{ConfigStore: mock}
|
|
loadPlugins(context.Background(), config, &ConfigData{})
|
|
require.Len(t, config.PluginConfigs, 1)
|
|
require.Equal(t, schemas.PluginPlacementPostBuiltin, *config.PluginConfigs[0].Placement)
|
|
|
|
// Config file says plugin-a should be pre_builtin with order 1, same version
|
|
configData := &ConfigData{
|
|
Plugins: []*schemas.PluginConfig{
|
|
{
|
|
Name: "plugin-a",
|
|
Enabled: true,
|
|
Version: &version1,
|
|
Placement: &preBuiltin,
|
|
Order: &order1,
|
|
},
|
|
},
|
|
}
|
|
|
|
mergePlugins(context.Background(), config, configData)
|
|
|
|
// Should have been replaced because placement changed
|
|
require.Len(t, config.PluginConfigs, 1)
|
|
require.NotNil(t, config.PluginConfigs[0].Placement)
|
|
require.Equal(t, schemas.PluginPlacementPreBuiltin, *config.PluginConfigs[0].Placement, "placement should be updated from file")
|
|
require.NotNil(t, config.PluginConfigs[0].Order)
|
|
require.Equal(t, 1, *config.PluginConfigs[0].Order, "order should be updated from file")
|
|
}
|
|
|
|
// TestMergePluginsFromFile_NoChangeSkipsMerge verifies that mergePlugins
|
|
// does NOT replace a plugin when version, placement, and order are all unchanged.
|
|
func TestMergePluginsFromFile_NoChangeSkipsMerge(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
postBuiltin := schemas.PluginPlacement("post_builtin")
|
|
order0 := 0
|
|
version1 := int16(1)
|
|
|
|
mock := &MockConfigStore{
|
|
plugins: []*tables.TablePlugin{
|
|
{
|
|
Name: "plugin-a",
|
|
Enabled: true,
|
|
Placement: &postBuiltin,
|
|
Order: &order0,
|
|
Version: 1,
|
|
ConfigJSON: `{"setting":"db-value"}`,
|
|
Config: map[string]any{"setting": "db-value"},
|
|
},
|
|
},
|
|
}
|
|
|
|
config := &Config{ConfigStore: mock}
|
|
loadPlugins(context.Background(), config, &ConfigData{})
|
|
|
|
// Config file has same version, placement, order but different config value
|
|
configData := &ConfigData{
|
|
Plugins: []*schemas.PluginConfig{
|
|
{
|
|
Name: "plugin-a",
|
|
Enabled: true,
|
|
Version: &version1,
|
|
Placement: &postBuiltin,
|
|
Order: &order0,
|
|
Config: map[string]any{"setting": "file-value"},
|
|
},
|
|
},
|
|
}
|
|
|
|
mergePlugins(context.Background(), config, configData)
|
|
|
|
// Should NOT have been replaced (version and placement unchanged)
|
|
configMap, ok := config.PluginConfigs[0].Config.(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "db-value", configMap["setting"], "config should remain from DB when version and placement are unchanged")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// CLIENT CONFIG HASH TESTS
|
|
// ===================================================================================
|
|
|
|
// TestGenerateClientConfigHash tests hash generation for client config
|
|
func TestGenerateClientConfigHash(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
cc1 := configstore.ClientConfig{
|
|
DropExcessRequests: true,
|
|
InitialPoolSize: 300,
|
|
PrometheusLabels: []string{"label1", "label2"},
|
|
EnableLogging: new(true),
|
|
DisableContentLogging: false,
|
|
LogRetentionDays: 30,
|
|
EnforceAuthOnInference: false,
|
|
AllowDirectKeys: true,
|
|
AllowedOrigins: []string{"http://localhost:3000"},
|
|
MaxRequestBodySizeMB: 100,
|
|
}
|
|
|
|
hash1, err := cc1.GenerateClientConfigHash()
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash: %v", err)
|
|
}
|
|
if hash1 == "" {
|
|
t.Error("Expected non-empty hash")
|
|
}
|
|
|
|
// Same config should produce same hash
|
|
hash1Again, _ := cc1.GenerateClientConfigHash()
|
|
if hash1 != hash1Again {
|
|
t.Error("Same config should produce same hash")
|
|
}
|
|
|
|
// Different DropExcessRequests should produce different hash
|
|
cc2 := cc1
|
|
cc2.DropExcessRequests = false
|
|
hash2, _ := cc2.GenerateClientConfigHash()
|
|
if hash1 == hash2 {
|
|
t.Error("Different DropExcessRequests should produce different hash")
|
|
}
|
|
|
|
// Different InitialPoolSize should produce different hash
|
|
cc3 := cc1
|
|
cc3.InitialPoolSize = 500
|
|
hash3, _ := cc3.GenerateClientConfigHash()
|
|
if hash1 == hash3 {
|
|
t.Error("Different InitialPoolSize should produce different hash")
|
|
}
|
|
|
|
// Different PrometheusLabels should produce different hash
|
|
cc4 := cc1
|
|
cc4.PrometheusLabels = []string{"label3"}
|
|
hash4, _ := cc4.GenerateClientConfigHash()
|
|
if hash1 == hash4 {
|
|
t.Error("Different PrometheusLabels should produce different hash")
|
|
}
|
|
|
|
// Different EnableLogging should produce different hash
|
|
cc5 := cc1
|
|
cc5.EnableLogging = new(false)
|
|
hash5, _ := cc5.GenerateClientConfigHash()
|
|
if hash1 == hash5 {
|
|
t.Error("Different EnableLogging should produce different hash")
|
|
}
|
|
|
|
// Different DisableContentLogging should produce different hash
|
|
cc6 := cc1
|
|
cc6.DisableContentLogging = true
|
|
hash6, _ := cc6.GenerateClientConfigHash()
|
|
if hash1 == hash6 {
|
|
t.Error("Different DisableContentLogging should produce different hash")
|
|
}
|
|
|
|
// Different LogRetentionDays should produce different hash
|
|
cc7 := cc1
|
|
cc7.LogRetentionDays = 60
|
|
hash7, _ := cc7.GenerateClientConfigHash()
|
|
if hash1 == hash7 {
|
|
t.Error("Different LogRetentionDays should produce different hash")
|
|
}
|
|
|
|
// Different EnforceAuthOnInference should produce different hash
|
|
cc9 := cc1
|
|
cc9.EnforceAuthOnInference = true
|
|
hash9, _ := cc9.GenerateClientConfigHash()
|
|
if hash1 == hash9 {
|
|
t.Error("Different EnforceAuthOnInference should produce different hash")
|
|
}
|
|
|
|
// Different AllowDirectKeys should produce different hash
|
|
cc10 := cc1
|
|
cc10.AllowDirectKeys = false
|
|
hash10, _ := cc10.GenerateClientConfigHash()
|
|
if hash1 == hash10 {
|
|
t.Error("Different AllowDirectKeys should produce different hash")
|
|
}
|
|
|
|
// Different AllowedOrigins should produce different hash
|
|
cc11 := cc1
|
|
cc11.AllowedOrigins = []string{"http://example.com"}
|
|
hash11, _ := cc11.GenerateClientConfigHash()
|
|
if hash1 == hash11 {
|
|
t.Error("Different AllowedOrigins should produce different hash")
|
|
}
|
|
|
|
// Different MaxRequestBodySizeMB should produce different hash
|
|
cc12 := cc1
|
|
cc12.MaxRequestBodySizeMB = 200
|
|
hash12, _ := cc12.GenerateClientConfigHash()
|
|
if hash1 == hash12 {
|
|
t.Error("Different MaxRequestBodySizeMB should produce different hash")
|
|
}
|
|
|
|
// Different Compat should produce different hash
|
|
cc13 := cc1
|
|
cc13.Compat.ConvertTextToChat = true
|
|
hash13, _ := cc13.GenerateClientConfigHash()
|
|
if hash1 == hash13 {
|
|
t.Error("Different Compat.ConvertTextToChat should produce different hash")
|
|
}
|
|
|
|
// PrometheusLabels order should not matter (sorted)
|
|
cc14 := cc1
|
|
cc14.PrometheusLabels = []string{"label2", "label1"} // Reversed
|
|
hash14, _ := cc14.GenerateClientConfigHash()
|
|
if hash1 != hash14 {
|
|
t.Error("PrometheusLabels order should not affect hash")
|
|
}
|
|
|
|
// AllowedOrigins order should not matter (sorted)
|
|
cc15 := cc1
|
|
cc15.AllowedOrigins = []string{"http://localhost:3000"} // Same
|
|
hash15, _ := cc15.GenerateClientConfigHash()
|
|
if hash1 != hash15 {
|
|
t.Error("Same AllowedOrigins should produce same hash")
|
|
}
|
|
|
|
t.Log("✓ ClientConfig hash generation works correctly for all fields")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// COMBINED GOVERNANCE RECONCILIATION TEST
|
|
// ===================================================================================
|
|
|
|
// TestSQLite_Governance_FullReconciliation tests full governance reconciliation
|
|
func TestSQLite_Governance_FullReconciliation(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
tokenMax := int64(1000)
|
|
tokenDur := "1h"
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1d"},
|
|
},
|
|
RateLimits: []tables.TableRateLimit{
|
|
{ID: "rl-1", TokenMaxLimit: &tokenMax, TokenResetDuration: &tokenDur},
|
|
},
|
|
Customers: []tables.TableCustomer{
|
|
{ID: "customer-1", Name: "Test Customer"},
|
|
},
|
|
Teams: []tables.TableTeam{
|
|
{ID: "team-1", Name: "Test Team"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify all entities have hashes
|
|
gov1, _ := config1.ConfigStore.GetGovernanceConfig(ctx)
|
|
if gov1.Budgets[0].ConfigHash == "" {
|
|
t.Error("Budget hash not set")
|
|
}
|
|
if gov1.RateLimits[0].ConfigHash == "" {
|
|
t.Error("RateLimit hash not set")
|
|
}
|
|
if gov1.Customers[0].ConfigHash == "" {
|
|
t.Error("Customer hash not set")
|
|
}
|
|
if gov1.Teams[0].ConfigHash == "" {
|
|
t.Error("Team hash not set")
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Update all entities in config file
|
|
configData.Governance.Budgets[0].MaxLimit = 200.0
|
|
newTokenMax := int64(2000)
|
|
configData.Governance.RateLimits[0].TokenMaxLimit = &newTokenMax
|
|
configData.Governance.Customers[0].Name = "Updated Customer"
|
|
configData.Governance.Teams[0].Name = "Updated Team"
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
// Reload and verify all entities are updated
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
|
|
if gov2.Budgets[0].MaxLimit != 200.0 {
|
|
t.Errorf("Budget MaxLimit not updated: got %f", gov2.Budgets[0].MaxLimit)
|
|
}
|
|
if *gov2.RateLimits[0].TokenMaxLimit != 2000 {
|
|
t.Errorf("RateLimit TokenMaxLimit not updated: got %d", *gov2.RateLimits[0].TokenMaxLimit)
|
|
}
|
|
if gov2.Customers[0].Name != "Updated Customer" {
|
|
t.Errorf("Customer Name not updated: got %s", gov2.Customers[0].Name)
|
|
}
|
|
if gov2.Teams[0].Name != "Updated Team" {
|
|
t.Errorf("Team Name not updated: got %s", gov2.Teams[0].Name)
|
|
}
|
|
|
|
t.Log("✓ Full governance reconciliation works correctly for all entities")
|
|
}
|
|
|
|
// TestSQLite_Governance_DBOnly_AllPreserved tests all dashboard-added entities preserved
|
|
func TestSQLite_Governance_DBOnly_AllPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Start with minimal config
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Add entities via dashboard
|
|
tokenMax := int64(1000)
|
|
tokenDur := "1h"
|
|
|
|
if err := config1.ConfigStore.CreateBudget(ctx, &tables.TableBudget{
|
|
ID: "dashboard-budget", MaxLimit: 500.0, ResetDuration: "1w",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to create dashboard budget: %v", err)
|
|
}
|
|
|
|
if err := config1.ConfigStore.CreateRateLimit(ctx, &tables.TableRateLimit{
|
|
ID: "dashboard-rl", TokenMaxLimit: &tokenMax, TokenResetDuration: &tokenDur,
|
|
}); err != nil {
|
|
t.Fatalf("Failed to create dashboard rate limit: %v", err)
|
|
}
|
|
|
|
if err := config1.ConfigStore.CreateCustomer(ctx, &tables.TableCustomer{
|
|
ID: "dashboard-customer", Name: "Dashboard Customer",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to create dashboard customer: %v", err)
|
|
}
|
|
|
|
if err := config1.ConfigStore.CreateTeam(ctx, &tables.TableTeam{
|
|
ID: "dashboard-team", Name: "Dashboard Team",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to create dashboard team: %v", err)
|
|
}
|
|
|
|
config1.Close(ctx)
|
|
|
|
// Reload - all dashboard entities should be preserved
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
gov2, _ := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
|
|
if len(gov2.Budgets) != 1 || gov2.Budgets[0].ID != "dashboard-budget" {
|
|
t.Error("Dashboard budget not preserved")
|
|
}
|
|
if len(gov2.RateLimits) != 1 || gov2.RateLimits[0].ID != "dashboard-rl" {
|
|
t.Error("Dashboard rate limit not preserved")
|
|
}
|
|
if len(gov2.Customers) != 1 || gov2.Customers[0].ID != "dashboard-customer" {
|
|
t.Error("Dashboard customer not preserved")
|
|
}
|
|
if len(gov2.Teams) != 1 || gov2.Teams[0].ID != "dashboard-team" {
|
|
t.Error("Dashboard team not preserved")
|
|
}
|
|
|
|
t.Log("✓ All dashboard-added entities preserved on reload")
|
|
}
|
|
|
|
// TestSQLite_Governance_PricingOverrides_Reconciliation tests that pricing overrides
|
|
// defined in config.json are properly reconciled on reload (create, update, preserve).
|
|
func TestSQLite_Governance_PricingOverrides_Reconciliation(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
configData := makeConfigDataWithProvidersAndDir(nil, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
PricingOverrides: []tables.TablePricingOverride{
|
|
{
|
|
ID: "po-1",
|
|
Name: "Override One",
|
|
ScopeKind: "global",
|
|
MatchType: "exact",
|
|
Pattern: "gpt-4",
|
|
RequestTypes: []schemas.RequestType{
|
|
schemas.ChatCompletionRequest,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
|
|
// First load: pricing override should be created in the DB
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
gov1, err := config1.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config after first load: %v", err)
|
|
}
|
|
if len(gov1.PricingOverrides) != 1 {
|
|
t.Fatalf("Expected 1 pricing override after first load, got %d", len(gov1.PricingOverrides))
|
|
}
|
|
if gov1.PricingOverrides[0].ID != "po-1" {
|
|
t.Errorf("Expected pricing override ID 'po-1', got '%s'", gov1.PricingOverrides[0].ID)
|
|
}
|
|
if gov1.PricingOverrides[0].ConfigHash == "" {
|
|
t.Error("Pricing override hash not set after first load")
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load (unchanged config): should NOT fail with duplicate key error
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed (duplicate key bug): %v", err)
|
|
}
|
|
|
|
gov2, err := config2.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config after second load: %v", err)
|
|
}
|
|
if len(gov2.PricingOverrides) != 1 {
|
|
t.Fatalf("Expected 1 pricing override after second load, got %d", len(gov2.PricingOverrides))
|
|
}
|
|
config2.Close(ctx)
|
|
|
|
// Third load (updated config): should update the existing override, not create a duplicate
|
|
configData.Governance.PricingOverrides[0].Pattern = "gpt-4o"
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config3, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Third LoadConfig failed: %v", err)
|
|
}
|
|
defer config3.Close(ctx)
|
|
|
|
gov3, err := config3.ConfigStore.GetGovernanceConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get governance config after third load: %v", err)
|
|
}
|
|
if len(gov3.PricingOverrides) != 1 {
|
|
t.Fatalf("Expected 1 pricing override after update, got %d", len(gov3.PricingOverrides))
|
|
}
|
|
if gov3.PricingOverrides[0].Pattern != "gpt-4o" {
|
|
t.Errorf("Pricing override pattern not updated: got '%s', want 'gpt-4o'", gov3.PricingOverrides[0].Pattern)
|
|
}
|
|
|
|
t.Log("✓ Pricing overrides reconciliation works correctly (create, idempotent reload, update)")
|
|
}
|
|
|
|
// ===================================================================================
|
|
// RUNTIME VS MIGRATION HASH PARITY TESTS (SQLite Integration)
|
|
// ===================================================================================
|
|
// These tests verify that hash generation produces identical results when:
|
|
// 1. Virtual fields are populated (runtime after AfterFind hook)
|
|
// 2. Data is loaded from SQLite via GORM Find() (simulating migration context)
|
|
//
|
|
// This tests whether GORM's AfterFind hooks properly populate virtual fields
|
|
// during database reads, ensuring hash consistency between file configs and DB configs.
|
|
// ===================================================================================
|
|
|
|
// setupTestDB creates an in-memory SQLite database for testing
|
|
func setupTestDB(t *testing.T) *gorm.DB {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test database: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// TestGenerateMCPClientHash_RuntimeVsMigrationParity tests that MCP client hash
|
|
// is identical whether generated from config file (virtual fields) or DB (via GORM Find)
|
|
func TestGenerateMCPClientHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
if err := db.AutoMigrate(&tables.TableMCPClient{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
connStr := "http://localhost:8080"
|
|
|
|
// Test case 1: StdioConfig field - verify AfterFind populates virtual field
|
|
t.Run("StdioConfig_GORMRoundTrip", func(t *testing.T) {
|
|
stdioConfig := &schemas.MCPStdioConfig{
|
|
Command: "npx",
|
|
Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
|
|
Envs: []string{"NODE_ENV=production"},
|
|
}
|
|
|
|
// Create MCP client with virtual field populated (simulates config file load)
|
|
mcpToSave := tables.TableMCPClient{
|
|
ClientID: uuid.New().String(),
|
|
Name: "Test MCP StdioConfig " + uuid.New().String(),
|
|
ConnectionType: "stdio",
|
|
StdioConfig: stdioConfig,
|
|
ToolsToExecute: []string{},
|
|
Headers: map[string]schemas.EnvVar{},
|
|
}
|
|
|
|
// Generate hash BEFORE saving (this is what config file processing does)
|
|
hashBeforeSave, err := configstore.GenerateMCPClientHash(mcpToSave)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash before save: %v", err)
|
|
}
|
|
|
|
// Save to DB (BeforeSave hook serializes virtual fields to JSON columns)
|
|
if err := db.Create(&mcpToSave).Error; err != nil {
|
|
t.Fatalf("Failed to save MCP client: %v", err)
|
|
}
|
|
|
|
// Read back from DB (AfterFind hook should deserialize JSON to virtual fields)
|
|
var mcpFromDB tables.TableMCPClient
|
|
if err := db.Where("id = ?", mcpToSave.ID).First(&mcpFromDB).Error; err != nil {
|
|
t.Fatalf("Failed to read MCP client: %v", err)
|
|
}
|
|
|
|
// Generate hash AFTER reading from DB (simulates migration context)
|
|
hashAfterLoad, err := configstore.GenerateMCPClientHash(mcpFromDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash after load: %v", err)
|
|
}
|
|
|
|
// Verify AfterFind populated the virtual field
|
|
if mcpFromDB.StdioConfig == nil {
|
|
t.Error("AfterFind did not populate StdioConfig virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch after GORM round-trip for StdioConfig\nBefore save: %s\nAfter load: %s\nStdioConfig populated: %v",
|
|
hashBeforeSave, hashAfterLoad, mcpFromDB.StdioConfig != nil)
|
|
}
|
|
})
|
|
|
|
// Test case 2: ToolsToExecute field
|
|
t.Run("ToolsToExecute_GORMRoundTrip", func(t *testing.T) {
|
|
tools := []string{"tool1", "tool2", "tool3"}
|
|
|
|
mcpToSave := tables.TableMCPClient{
|
|
ClientID: uuid.New().String(),
|
|
Name: "Test MCP Tools " + uuid.New().String(),
|
|
ConnectionType: "sse",
|
|
ConnectionString: schemas.NewEnvVar(connStr),
|
|
ToolsToExecute: tools,
|
|
Headers: map[string]schemas.EnvVar{},
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateMCPClientHash(mcpToSave)
|
|
db.Create(&mcpToSave)
|
|
|
|
var mcpFromDB tables.TableMCPClient
|
|
db.Where("id = ?", mcpToSave.ID).First(&mcpFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateMCPClientHash(mcpFromDB)
|
|
|
|
if len(mcpFromDB.ToolsToExecute) == 0 {
|
|
t.Error("AfterFind did not populate ToolsToExecute virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch after GORM round-trip for ToolsToExecute\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 3: Headers field
|
|
t.Run("Headers_GORMRoundTrip", func(t *testing.T) {
|
|
headers := map[string]schemas.EnvVar{
|
|
"Authorization": *schemas.NewEnvVar("Bearer token123"),
|
|
"X-Custom": *schemas.NewEnvVar("value"),
|
|
}
|
|
|
|
mcpToSave := tables.TableMCPClient{
|
|
ClientID: uuid.New().String(),
|
|
Name: "Test MCP Headers " + uuid.New().String(),
|
|
ConnectionType: "sse",
|
|
ConnectionString: schemas.NewEnvVar(connStr),
|
|
ToolsToExecute: []string{},
|
|
Headers: headers,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateMCPClientHash(mcpToSave)
|
|
db.Create(&mcpToSave)
|
|
|
|
var mcpFromDB tables.TableMCPClient
|
|
db.Where("id = ?", mcpToSave.ID).First(&mcpFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateMCPClientHash(mcpFromDB)
|
|
|
|
if len(mcpFromDB.Headers) == 0 {
|
|
t.Error("AfterFind did not populate Headers virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch after GORM round-trip for Headers\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 4: All fields combined
|
|
t.Run("AllFields_GORMRoundTrip", func(t *testing.T) {
|
|
stdioConfig := &schemas.MCPStdioConfig{
|
|
Command: "npx",
|
|
Args: []string{"-y", "server"},
|
|
}
|
|
tools := []string{"tool1", "tool2"}
|
|
headers := map[string]schemas.EnvVar{"Auth": *schemas.NewEnvVar("token")}
|
|
|
|
mcpToSave := tables.TableMCPClient{
|
|
ClientID: uuid.New().String(),
|
|
Name: "Test MCP AllFields " + uuid.New().String(),
|
|
ConnectionType: "stdio",
|
|
StdioConfig: stdioConfig,
|
|
ToolsToExecute: tools,
|
|
Headers: headers,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateMCPClientHash(mcpToSave)
|
|
db.Create(&mcpToSave)
|
|
|
|
var mcpFromDB tables.TableMCPClient
|
|
db.Where("id = ?", mcpToSave.ID).First(&mcpFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateMCPClientHash(mcpFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch after GORM round-trip for all fields\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 5: Verify tx.Find() also triggers AfterFind (migration scenario)
|
|
t.Run("TxFind_TriggersAfterFind", func(t *testing.T) {
|
|
tools := []string{"zebra", "apple", "mango"}
|
|
|
|
mcpToSave := tables.TableMCPClient{
|
|
ClientID: uuid.New().String(),
|
|
Name: "Test MCP TxFind " + uuid.New().String(),
|
|
ConnectionType: "sse",
|
|
ConnectionString: schemas.NewEnvVar(connStr),
|
|
ToolsToExecute: tools,
|
|
Headers: map[string]schemas.EnvVar{},
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateMCPClientHash(mcpToSave)
|
|
db.Create(&mcpToSave)
|
|
|
|
// Use Find() like migrations do (not First())
|
|
var mcpClients []tables.TableMCPClient
|
|
if err := db.Where("id = ?", mcpToSave.ID).Find(&mcpClients).Error; err != nil {
|
|
t.Fatalf("Failed to find MCP clients: %v", err)
|
|
}
|
|
|
|
if len(mcpClients) == 0 {
|
|
t.Fatal("No MCP clients found")
|
|
}
|
|
|
|
mcpFromDB := mcpClients[0]
|
|
hashAfterLoad, _ := configstore.GenerateMCPClientHash(mcpFromDB)
|
|
|
|
if len(mcpFromDB.ToolsToExecute) == 0 {
|
|
t.Error("AfterFind did not run during Find() - ToolsToExecute is empty")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch when using Find() (migration pattern)\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGeneratePluginHash_RuntimeVsMigrationParity tests plugin hash with real DB
|
|
func TestGeneratePluginHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
if err := db.AutoMigrate(&tables.TablePlugin{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
path := "/path/to/plugin"
|
|
|
|
// Test case 1: Simple config object
|
|
t.Run("SimpleConfig_GORMRoundTrip", func(t *testing.T) {
|
|
config := map[string]interface{}{
|
|
"setting": "value",
|
|
"enabled": true,
|
|
"maxItems": float64(100),
|
|
}
|
|
|
|
pluginToSave := tables.TablePlugin{
|
|
Name: "test-plugin-" + uuid.New().String(),
|
|
Enabled: true,
|
|
Path: &path,
|
|
Version: 1,
|
|
Config: config,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GeneratePluginHash(pluginToSave)
|
|
db.Create(&pluginToSave)
|
|
|
|
var pluginFromDB tables.TablePlugin
|
|
db.Where("id = ?", pluginToSave.ID).First(&pluginFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GeneratePluginHash(pluginFromDB)
|
|
|
|
if pluginFromDB.Config == nil {
|
|
t.Error("AfterFind did not populate Config virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch after GORM round-trip for plugin Config\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 2: Nested config object
|
|
t.Run("NestedConfig_GORMRoundTrip", func(t *testing.T) {
|
|
config := map[string]interface{}{
|
|
"database": map[string]interface{}{
|
|
"host": "localhost",
|
|
"port": float64(5432),
|
|
},
|
|
}
|
|
|
|
pluginToSave := tables.TablePlugin{
|
|
Name: "test-plugin-nested-" + uuid.New().String(),
|
|
Enabled: true,
|
|
Version: 1,
|
|
Config: config,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GeneratePluginHash(pluginToSave)
|
|
db.Create(&pluginToSave)
|
|
|
|
var pluginFromDB tables.TablePlugin
|
|
db.Where("id = ?", pluginToSave.ID).First(&pluginFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GeneratePluginHash(pluginFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for nested config\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 3: Empty config
|
|
t.Run("EmptyConfig_GORMRoundTrip", func(t *testing.T) {
|
|
pluginToSave := tables.TablePlugin{
|
|
Name: "test-plugin-empty-" + uuid.New().String(),
|
|
Enabled: true,
|
|
Version: 1,
|
|
Config: nil,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GeneratePluginHash(pluginToSave)
|
|
db.Create(&pluginToSave)
|
|
|
|
var pluginFromDB tables.TablePlugin
|
|
db.Where("id = ?", pluginToSave.ID).First(&pluginFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GeneratePluginHash(pluginFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for empty config\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGenerateTeamHash_RuntimeVsMigrationParity tests team hash with real DB
|
|
func TestGenerateTeamHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
if err := db.AutoMigrate(&tables.TableBudget{}, &tables.TableTeam{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
// Test case 1: ParsedProfile
|
|
t.Run("Profile_GORMRoundTrip", func(t *testing.T) {
|
|
profile := map[string]interface{}{
|
|
"department": "engineering",
|
|
"level": float64(3),
|
|
}
|
|
|
|
teamToSave := tables.TableTeam{
|
|
ID: uuid.New().String(),
|
|
Name: "Test Team Profile",
|
|
ParsedProfile: profile,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateTeamHash(teamToSave)
|
|
db.Create(&teamToSave)
|
|
|
|
var teamFromDB tables.TableTeam
|
|
db.Where("id = ?", teamToSave.ID).First(&teamFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateTeamHash(teamFromDB)
|
|
|
|
if teamFromDB.ParsedProfile == nil {
|
|
t.Error("AfterFind did not populate ParsedProfile virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for Profile\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 2: ParsedConfig
|
|
t.Run("Config_GORMRoundTrip", func(t *testing.T) {
|
|
config := map[string]interface{}{
|
|
"maxTokens": float64(4096),
|
|
"temperature": 0.7,
|
|
}
|
|
|
|
teamToSave := tables.TableTeam{
|
|
ID: uuid.New().String(),
|
|
Name: "Test Team Config",
|
|
ParsedConfig: config,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateTeamHash(teamToSave)
|
|
db.Create(&teamToSave)
|
|
|
|
var teamFromDB tables.TableTeam
|
|
db.Where("id = ?", teamToSave.ID).First(&teamFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateTeamHash(teamFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for Config\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 3: ParsedClaims with array
|
|
t.Run("Claims_GORMRoundTrip", func(t *testing.T) {
|
|
claims := map[string]interface{}{
|
|
"role": "admin",
|
|
"permissions": []interface{}{"read", "write", "delete"},
|
|
}
|
|
|
|
teamToSave := tables.TableTeam{
|
|
ID: uuid.New().String(),
|
|
Name: "Test Team Claims",
|
|
ParsedClaims: claims,
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateTeamHash(teamToSave)
|
|
db.Create(&teamToSave)
|
|
|
|
var teamFromDB tables.TableTeam
|
|
db.Where("id = ?", teamToSave.ID).First(&teamFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateTeamHash(teamFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for Claims\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 4: All fields
|
|
t.Run("AllFields_GORMRoundTrip", func(t *testing.T) {
|
|
customerID := "customer-1"
|
|
budgetID := uuid.New().String()
|
|
|
|
teamToSave := tables.TableTeam{
|
|
ID: uuid.New().String(),
|
|
Name: "Test Team All",
|
|
CustomerID: &customerID,
|
|
Budgets: []tables.TableBudget{{
|
|
ID: budgetID,
|
|
ResetDuration: "1h",
|
|
MaxLimit: 100.0,
|
|
}},
|
|
ParsedProfile: map[string]interface{}{"key": "value"},
|
|
ParsedConfig: map[string]interface{}{"setting": true},
|
|
ParsedClaims: map[string]interface{}{"role": "user"},
|
|
}
|
|
|
|
hashBeforeSave, _ := configstore.GenerateTeamHash(teamToSave)
|
|
db.Create(&teamToSave)
|
|
|
|
var teamFromDB tables.TableTeam
|
|
db.Preload("Budgets").Where("id = ?", teamToSave.ID).First(&teamFromDB)
|
|
|
|
hashAfterLoad, _ := configstore.GenerateTeamHash(teamFromDB)
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for all fields\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGenerateProviderHash_RuntimeVsMigrationParity tests provider hash with real DB
|
|
func TestGenerateProviderHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
if err := db.AutoMigrate(&tables.TableProvider{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
// Test case 1: NetworkConfig
|
|
t.Run("NetworkConfig_GORMRoundTrip", func(t *testing.T) {
|
|
networkConfig := &schemas.NetworkConfig{
|
|
BaseURL: "https://api.custom.com",
|
|
DefaultRequestTimeoutInSeconds: 30,
|
|
}
|
|
|
|
providerToSave := tables.TableProvider{
|
|
Name: networkConfig.BaseURL, // Use unique name
|
|
NetworkConfig: networkConfig,
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
// Generate hash from the virtual field (before save)
|
|
providerConfig := configstore.ProviderConfig{
|
|
NetworkConfig: providerToSave.NetworkConfig,
|
|
SendBackRawResponse: providerToSave.SendBackRawResponse,
|
|
}
|
|
hashBeforeSave, _ := providerConfig.GenerateConfigHash("openai")
|
|
|
|
db.Create(&providerToSave)
|
|
|
|
var providerFromDB tables.TableProvider
|
|
db.Where("id = ?", providerToSave.ID).First(&providerFromDB)
|
|
|
|
// Generate hash from loaded data
|
|
providerConfigFromDB := configstore.ProviderConfig{
|
|
NetworkConfig: providerFromDB.NetworkConfig,
|
|
SendBackRawResponse: providerFromDB.SendBackRawResponse,
|
|
}
|
|
hashAfterLoad, _ := providerConfigFromDB.GenerateConfigHash("openai")
|
|
|
|
if providerFromDB.NetworkConfig == nil {
|
|
t.Error("AfterFind did not populate NetworkConfig virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for NetworkConfig\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 2: ConcurrencyAndBufferSize
|
|
t.Run("ConcurrencyAndBufferSize_GORMRoundTrip", func(t *testing.T) {
|
|
concurrencyConfig := &schemas.ConcurrencyAndBufferSize{
|
|
Concurrency: 10,
|
|
BufferSize: 100,
|
|
}
|
|
|
|
providerToSave := tables.TableProvider{
|
|
Name: "test-provider-concurrency-" + uuid.New().String(),
|
|
ConcurrencyAndBufferSize: concurrencyConfig,
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
providerConfig := configstore.ProviderConfig{
|
|
ConcurrencyAndBufferSize: providerToSave.ConcurrencyAndBufferSize,
|
|
SendBackRawResponse: providerToSave.SendBackRawResponse,
|
|
}
|
|
hashBeforeSave, _ := providerConfig.GenerateConfigHash("openai")
|
|
|
|
db.Create(&providerToSave)
|
|
|
|
var providerFromDB tables.TableProvider
|
|
db.Where("id = ?", providerToSave.ID).First(&providerFromDB)
|
|
|
|
providerConfigFromDB := configstore.ProviderConfig{
|
|
ConcurrencyAndBufferSize: providerFromDB.ConcurrencyAndBufferSize,
|
|
SendBackRawResponse: providerFromDB.SendBackRawResponse,
|
|
}
|
|
hashAfterLoad, _ := providerConfigFromDB.GenerateConfigHash("openai")
|
|
|
|
if providerFromDB.ConcurrencyAndBufferSize == nil {
|
|
t.Error("AfterFind did not populate ConcurrencyAndBufferSize virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for ConcurrencyAndBufferSize\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 3: ProxyConfig
|
|
t.Run("ProxyConfig_GORMRoundTrip", func(t *testing.T) {
|
|
proxyConfig := &schemas.ProxyConfig{
|
|
Type: schemas.HTTPProxy,
|
|
URL: "http://proxy.example.com:8080",
|
|
}
|
|
|
|
providerToSave := tables.TableProvider{
|
|
Name: "test-provider-proxy-" + uuid.New().String(),
|
|
ProxyConfig: proxyConfig,
|
|
SendBackRawResponse: false,
|
|
}
|
|
|
|
providerConfig := configstore.ProviderConfig{
|
|
ProxyConfig: providerToSave.ProxyConfig,
|
|
SendBackRawResponse: providerToSave.SendBackRawResponse,
|
|
}
|
|
hashBeforeSave, _ := providerConfig.GenerateConfigHash("openai")
|
|
|
|
db.Create(&providerToSave)
|
|
|
|
var providerFromDB tables.TableProvider
|
|
db.Where("id = ?", providerToSave.ID).First(&providerFromDB)
|
|
|
|
providerConfigFromDB := configstore.ProviderConfig{
|
|
ProxyConfig: providerFromDB.ProxyConfig,
|
|
SendBackRawResponse: providerFromDB.SendBackRawResponse,
|
|
}
|
|
hashAfterLoad, _ := providerConfigFromDB.GenerateConfigHash("openai")
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for ProxyConfig\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 4: CustomProviderConfig
|
|
t.Run("CustomProviderConfig_GORMRoundTrip", func(t *testing.T) {
|
|
customConfig := &schemas.CustomProviderConfig{
|
|
IsKeyLess: true,
|
|
BaseProviderType: schemas.OpenAI,
|
|
}
|
|
|
|
providerToSave := tables.TableProvider{
|
|
Name: "test-provider-custom-" + uuid.New().String(),
|
|
CustomProviderConfig: customConfig,
|
|
SendBackRawResponse: true,
|
|
}
|
|
|
|
providerConfig := configstore.ProviderConfig{
|
|
CustomProviderConfig: providerToSave.CustomProviderConfig,
|
|
SendBackRawResponse: providerToSave.SendBackRawResponse,
|
|
}
|
|
hashBeforeSave, _ := providerConfig.GenerateConfigHash("custom")
|
|
|
|
db.Create(&providerToSave)
|
|
|
|
var providerFromDB tables.TableProvider
|
|
db.Where("id = ?", providerToSave.ID).First(&providerFromDB)
|
|
|
|
providerConfigFromDB := configstore.ProviderConfig{
|
|
CustomProviderConfig: providerFromDB.CustomProviderConfig,
|
|
SendBackRawResponse: providerFromDB.SendBackRawResponse,
|
|
}
|
|
hashAfterLoad, _ := providerConfigFromDB.GenerateConfigHash("custom")
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for CustomProviderConfig\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGenerateKeyHash_RuntimeVsMigrationParity tests key hash with real DB
|
|
func TestGenerateKeyHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
// Need to migrate provider first due to foreign key
|
|
if err := db.AutoMigrate(&tables.TableProvider{}, &tables.TableKey{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
// Create a provider for foreign key
|
|
provider := tables.TableProvider{Name: "test-provider-for-keys"}
|
|
db.Create(&provider)
|
|
|
|
// Test case 1: Models field
|
|
t.Run("Models_GORMRoundTrip", func(t *testing.T) {
|
|
models := []string{"gpt-4", "gpt-3.5-turbo", "gpt-4-turbo"}
|
|
|
|
keyToSave := tables.TableKey{
|
|
Name: "test-key-models-" + uuid.New().String(),
|
|
KeyID: uuid.New().String(),
|
|
ProviderID: provider.ID,
|
|
Provider: "openai",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: models,
|
|
Weight: ptrFloat64(1.5),
|
|
}
|
|
|
|
// Generate hash using schemas.Key (what the hash function expects)
|
|
schemaKey := schemas.Key{
|
|
Name: keyToSave.Name,
|
|
Value: keyToSave.Value,
|
|
Models: keyToSave.Models,
|
|
Weight: getWeight(keyToSave.Weight),
|
|
}
|
|
hashBeforeSave, _ := configstore.GenerateKeyHash(schemaKey)
|
|
|
|
db.Create(&keyToSave)
|
|
|
|
var keyFromDB tables.TableKey
|
|
db.Where("id = ?", keyToSave.ID).First(&keyFromDB)
|
|
|
|
schemaKeyFromDB := schemas.Key{
|
|
Name: keyFromDB.Name,
|
|
Value: keyFromDB.Value,
|
|
Models: keyFromDB.Models,
|
|
Weight: getWeight(keyFromDB.Weight),
|
|
}
|
|
hashAfterLoad, _ := configstore.GenerateKeyHash(schemaKeyFromDB)
|
|
|
|
if len(keyFromDB.Models) == 0 {
|
|
t.Error("AfterFind did not populate Models virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for Models\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 2: AzureKeyConfig
|
|
t.Run("AzureKeyConfig_GORMRoundTrip", func(t *testing.T) {
|
|
apiVersion := "2024-02-01"
|
|
azureConfig := &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myresource.openai.azure.com"),
|
|
APIVersion: schemas.NewEnvVar(apiVersion),
|
|
}
|
|
|
|
keyToSave := tables.TableKey{
|
|
Name: "test-key-azure-" + uuid.New().String(),
|
|
KeyID: uuid.New().String(),
|
|
ProviderID: provider.ID,
|
|
Provider: "azure",
|
|
Value: *schemas.NewEnvVar("azure-key-value"),
|
|
Weight: ptrFloat64(1.0),
|
|
AzureKeyConfig: azureConfig,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
}
|
|
|
|
schemaKey := schemas.Key{
|
|
Name: keyToSave.Name,
|
|
Value: keyToSave.Value,
|
|
Weight: getWeight(keyToSave.Weight),
|
|
AzureKeyConfig: keyToSave.AzureKeyConfig,
|
|
Aliases: keyToSave.Aliases,
|
|
}
|
|
hashBeforeSave, _ := configstore.GenerateKeyHash(schemaKey)
|
|
|
|
db.Create(&keyToSave)
|
|
|
|
var keyFromDB tables.TableKey
|
|
db.Where("id = ?", keyToSave.ID).First(&keyFromDB)
|
|
|
|
schemaKeyFromDB := schemas.Key{
|
|
Name: keyFromDB.Name,
|
|
Value: keyFromDB.Value,
|
|
Weight: getWeight(keyFromDB.Weight),
|
|
AzureKeyConfig: keyFromDB.AzureKeyConfig,
|
|
Aliases: keyFromDB.Aliases,
|
|
}
|
|
hashAfterLoad, _ := configstore.GenerateKeyHash(schemaKeyFromDB)
|
|
|
|
if keyFromDB.AzureKeyConfig == nil {
|
|
t.Error("AfterFind did not populate AzureKeyConfig virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for AzureKeyConfig\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 3: Models ordering should not affect hash
|
|
t.Run("Models_OrderingParity", func(t *testing.T) {
|
|
models1 := []string{"gpt-4", "gpt-3.5-turbo", "claude-3"}
|
|
models2 := []string{"claude-3", "gpt-4", "gpt-3.5-turbo"} // Different order
|
|
|
|
key1 := schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: models1,
|
|
Weight: 1.0,
|
|
}
|
|
|
|
key2 := schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Models: models2,
|
|
Weight: 1.0,
|
|
}
|
|
|
|
hash1, _ := configstore.GenerateKeyHash(key1)
|
|
hash2, _ := configstore.GenerateKeyHash(key2)
|
|
|
|
if hash1 != hash2 {
|
|
t.Errorf("Hash should be same regardless of Models order\nHash1: %s\nHash2: %s", hash1, hash2)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGenerateClientConfigHash_RuntimeVsMigrationParity tests client config hash with real DB
|
|
func TestGenerateClientConfigHash_RuntimeVsMigrationParity(t *testing.T) {
|
|
initTestLogger()
|
|
|
|
db := setupTestDB(t)
|
|
if err := db.AutoMigrate(&tables.TableClientConfig{}); err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
// Test case 1: PrometheusLabels
|
|
t.Run("PrometheusLabels_GORMRoundTrip", func(t *testing.T) {
|
|
labels := []string{"provider", "model", "status"}
|
|
|
|
ccToSave := tables.TableClientConfig{
|
|
DropExcessRequests: true,
|
|
InitialPoolSize: 300,
|
|
PrometheusLabels: labels,
|
|
EnableLogging: new(true),
|
|
DisableContentLogging: false,
|
|
LogRetentionDays: 30,
|
|
EnforceAuthOnInference: false,
|
|
AllowDirectKeys: true,
|
|
MaxRequestBodySizeMB: 100,
|
|
}
|
|
|
|
// Generate hash from config
|
|
clientConfig := configstore.ClientConfig{
|
|
DropExcessRequests: ccToSave.DropExcessRequests,
|
|
InitialPoolSize: ccToSave.InitialPoolSize,
|
|
PrometheusLabels: ccToSave.PrometheusLabels,
|
|
EnableLogging: ccToSave.EnableLogging,
|
|
DisableContentLogging: ccToSave.DisableContentLogging,
|
|
LogRetentionDays: ccToSave.LogRetentionDays,
|
|
EnforceAuthOnInference: ccToSave.EnforceAuthOnInference,
|
|
AllowDirectKeys: ccToSave.AllowDirectKeys,
|
|
MaxRequestBodySizeMB: ccToSave.MaxRequestBodySizeMB,
|
|
Compat: configstore.CompatConfig{
|
|
ConvertTextToChat: ccToSave.CompatConvertTextToChat,
|
|
ConvertChatToResponses: ccToSave.CompatConvertChatToResponses,
|
|
ShouldDropParams: ccToSave.CompatShouldDropParams,
|
|
ShouldConvertParams: ccToSave.CompatShouldConvertParams,
|
|
},
|
|
}
|
|
hashBeforeSave, _ := clientConfig.GenerateClientConfigHash()
|
|
|
|
db.Create(&ccToSave)
|
|
|
|
var ccFromDB tables.TableClientConfig
|
|
db.Where("id = ?", ccToSave.ID).First(&ccFromDB)
|
|
|
|
clientConfigFromDB := configstore.ClientConfig{
|
|
DropExcessRequests: ccFromDB.DropExcessRequests,
|
|
InitialPoolSize: ccFromDB.InitialPoolSize,
|
|
PrometheusLabels: ccFromDB.PrometheusLabels,
|
|
EnableLogging: ccFromDB.EnableLogging,
|
|
DisableContentLogging: ccFromDB.DisableContentLogging,
|
|
LogRetentionDays: ccFromDB.LogRetentionDays,
|
|
EnforceAuthOnInference: ccFromDB.EnforceAuthOnInference,
|
|
AllowDirectKeys: ccFromDB.AllowDirectKeys,
|
|
MaxRequestBodySizeMB: ccFromDB.MaxRequestBodySizeMB,
|
|
Compat: configstore.CompatConfig{
|
|
ConvertTextToChat: ccFromDB.CompatConvertTextToChat,
|
|
ConvertChatToResponses: ccFromDB.CompatConvertChatToResponses,
|
|
ShouldDropParams: ccFromDB.CompatShouldDropParams,
|
|
ShouldConvertParams: ccFromDB.CompatShouldConvertParams,
|
|
},
|
|
}
|
|
hashAfterLoad, _ := clientConfigFromDB.GenerateClientConfigHash()
|
|
|
|
if len(ccFromDB.PrometheusLabels) == 0 {
|
|
t.Error("AfterFind did not populate PrometheusLabels virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for PrometheusLabels\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
|
|
// Test case 2: AllowedOrigins
|
|
t.Run("AllowedOrigins_GORMRoundTrip", func(t *testing.T) {
|
|
origins := []string{"https://example.com", "https://app.example.com"}
|
|
|
|
ccToSave := tables.TableClientConfig{
|
|
DropExcessRequests: true,
|
|
InitialPoolSize: 300,
|
|
AllowedOrigins: origins,
|
|
EnableLogging: new(true),
|
|
LogRetentionDays: 30,
|
|
MaxRequestBodySizeMB: 100,
|
|
}
|
|
|
|
clientConfig := configstore.ClientConfig{
|
|
DropExcessRequests: ccToSave.DropExcessRequests,
|
|
InitialPoolSize: ccToSave.InitialPoolSize,
|
|
AllowedOrigins: ccToSave.AllowedOrigins,
|
|
EnableLogging: ccToSave.EnableLogging,
|
|
LogRetentionDays: ccToSave.LogRetentionDays,
|
|
MaxRequestBodySizeMB: ccToSave.MaxRequestBodySizeMB,
|
|
}
|
|
hashBeforeSave, _ := clientConfig.GenerateClientConfigHash()
|
|
|
|
db.Create(&ccToSave)
|
|
|
|
var ccFromDB tables.TableClientConfig
|
|
db.Where("id = ?", ccToSave.ID).First(&ccFromDB)
|
|
|
|
clientConfigFromDB := configstore.ClientConfig{
|
|
DropExcessRequests: ccFromDB.DropExcessRequests,
|
|
InitialPoolSize: ccFromDB.InitialPoolSize,
|
|
AllowedOrigins: ccFromDB.AllowedOrigins,
|
|
EnableLogging: ccFromDB.EnableLogging,
|
|
LogRetentionDays: ccFromDB.LogRetentionDays,
|
|
MaxRequestBodySizeMB: ccFromDB.MaxRequestBodySizeMB,
|
|
}
|
|
hashAfterLoad, _ := clientConfigFromDB.GenerateClientConfigHash()
|
|
|
|
if len(ccFromDB.AllowedOrigins) == 0 {
|
|
t.Error("AfterFind did not populate AllowedOrigins virtual field")
|
|
}
|
|
|
|
if hashBeforeSave != hashAfterLoad {
|
|
t.Errorf("Hash mismatch for AllowedOrigins\nBefore save: %s\nAfter load: %s",
|
|
hashBeforeSave, hashAfterLoad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// Weight=0 Handling Tests
|
|
// =============================================================================
|
|
// These tests verify that a weight of 0 is correctly preserved (not defaulted to 1.0)
|
|
// This is critical because weight=0 should disable a key from weighted random selection.
|
|
|
|
// TestKeyWeight_ZeroPreserved verifies that a key with weight: 0 in config.json
|
|
// is preserved as 0, not incorrectly defaulted to 1.0.
|
|
func TestKeyWeight_ZeroPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "zero-weight-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 0}, // Explicit zero
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(context.Background())
|
|
|
|
openaiConfig, exists := config.Providers[schemas.OpenAI]
|
|
if !exists {
|
|
t.Fatal("Expected openai provider to exist")
|
|
}
|
|
|
|
if len(openaiConfig.Keys) != 1 {
|
|
t.Fatalf("Expected 1 key, got %d", len(openaiConfig.Keys))
|
|
}
|
|
|
|
if openaiConfig.Keys[0].Weight != 0 {
|
|
t.Errorf("Expected weight 0 (explicitly set), got %f", openaiConfig.Keys[0].Weight)
|
|
}
|
|
}
|
|
|
|
// TestKeyWeight_DefaultToOneWhenNotSet verifies that a key without an explicit weight
|
|
// defaults to 1.0 when not specified (the expected default behavior).
|
|
func TestKeyWeight_DefaultToOneWhenNotSet(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "default-weight-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1}, // Explicit 1 (default)
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(context.Background())
|
|
|
|
openaiConfig, exists := config.Providers[schemas.OpenAI]
|
|
if !exists {
|
|
t.Fatal("Expected openai provider to exist")
|
|
}
|
|
|
|
if openaiConfig.Keys[0].Weight != 1.0 {
|
|
t.Errorf("Expected weight 1.0 (default), got %f", openaiConfig.Keys[0].Weight)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_WeightZero_RoundTrip tests that a key with weight=0 survives
|
|
// a database round-trip correctly (not defaulted to 1.0 by GORM).
|
|
func TestSQLite_Key_WeightZero_RoundTrip(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "zero-weight-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 0},
|
|
},
|
|
},
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
|
|
// First load - creates DB entries
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
if config1.Providers[schemas.OpenAI].Keys[0].Weight != 0 {
|
|
t.Errorf("First load: Expected weight 0, got %f", config1.Providers[schemas.OpenAI].Keys[0].Weight)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load - reads from DB
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
if config2.Providers[schemas.OpenAI].Keys[0].Weight != 0 {
|
|
t.Errorf("Second load (from DB): Expected weight 0, got %f - weight=0 was incorrectly defaulted to 1.0",
|
|
config2.Providers[schemas.OpenAI].Keys[0].Weight)
|
|
}
|
|
}
|
|
|
|
// ptrFloat64 is a helper function to create a pointer to a float64 value
|
|
func ptrFloat64(v float64) *float64 {
|
|
return &v
|
|
}
|
|
|
|
// TestVKProviderConfig_WeightZeroPreserved verifies that a virtual key provider config
|
|
// with weight=0 is preserved correctly and hash generation works.
|
|
func TestVKProviderConfig_WeightZeroPreserved(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create providers
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test123", "https://api.openai.com"),
|
|
}
|
|
|
|
// Create virtual key with provider config that has weight=0
|
|
vk := tables.TableVirtualKey{
|
|
ID: "vk-zero-weight",
|
|
Name: "test-vk",
|
|
Value: "vk_test123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(0.0), // Explicit zero weight
|
|
},
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, []tables.TableVirtualKey{vk}, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(ctx)
|
|
|
|
// Verify virtual key exists and has provider config with weight=0
|
|
if config.GovernanceConfig == nil || len(config.GovernanceConfig.VirtualKeys) == 0 {
|
|
t.Fatal("Expected virtual key in governance config")
|
|
}
|
|
|
|
vkFromConfig := config.GovernanceConfig.VirtualKeys[0]
|
|
if len(vkFromConfig.ProviderConfigs) == 0 {
|
|
t.Fatal("Expected provider config in virtual key")
|
|
}
|
|
|
|
pc := vkFromConfig.ProviderConfigs[0]
|
|
if pc.Weight == nil {
|
|
t.Fatal("Expected Weight to be set (not nil)")
|
|
}
|
|
if *pc.Weight != 0.0 {
|
|
t.Errorf("Expected provider config weight 0, got %f", *pc.Weight)
|
|
}
|
|
}
|
|
|
|
// TestSQLite_VKProviderConfig_WeightZero_RoundTrip tests that a virtual key provider config
|
|
// with weight=0 survives a database round-trip correctly.
|
|
func TestSQLite_VKProviderConfig_WeightZero_RoundTrip(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: keyID, Name: "openai-key", Value: *schemas.NewEnvVar("sk-test123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-zero-weight",
|
|
Name: "test-vk",
|
|
Value: "vk_abc123",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: ptrFloat64(0.0), // Explicit zero weight
|
|
AllowedModels: []string{"gpt-4"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
configData := makeConfigDataWithVirtualKeysAndDir(providers, vks, tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
ctx := context.Background()
|
|
|
|
// First load
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
vk1 := config1.GovernanceConfig.VirtualKeys[0]
|
|
if len(vk1.ProviderConfigs) == 0 {
|
|
t.Fatal("First load: Expected provider configs")
|
|
}
|
|
if vk1.ProviderConfigs[0].Weight == nil {
|
|
t.Fatal("First load: Expected Weight to be set")
|
|
}
|
|
if *vk1.ProviderConfigs[0].Weight != 0 {
|
|
t.Errorf("First load: Expected provider config weight 0, got %f", *vk1.ProviderConfigs[0].Weight)
|
|
}
|
|
config1.Close(ctx)
|
|
|
|
// Second load from DB
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(ctx)
|
|
|
|
vk2 := config2.GovernanceConfig.VirtualKeys[0]
|
|
if len(vk2.ProviderConfigs) == 0 {
|
|
t.Fatal("Second load: Expected provider configs")
|
|
}
|
|
if vk2.ProviderConfigs[0].Weight == nil {
|
|
t.Fatal("Second load: Expected Weight to be set (not nil) - it was incorrectly defaulted")
|
|
}
|
|
if *vk2.ProviderConfigs[0].Weight != 0 {
|
|
t.Errorf("Second load (from DB): Expected provider config weight 0, got %f - incorrectly defaulted to 1.0",
|
|
*vk2.ProviderConfigs[0].Weight)
|
|
}
|
|
}
|
|
|
|
// TestKeyWeight_HashDiffersBetweenZeroAndOne verifies that key hashes are different
|
|
// for weight=0 vs weight=1, ensuring the change is detected during sync.
|
|
func TestKeyWeight_HashDiffersBetweenZeroAndOne(t *testing.T) {
|
|
keyWithZeroWeight := schemas.Key{
|
|
ID: "test-key",
|
|
Name: "test",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 0,
|
|
}
|
|
keyWithOneWeight := schemas.Key{
|
|
ID: "test-key",
|
|
Name: "test",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
}
|
|
|
|
hash0, err := configstore.GenerateKeyHash(keyWithZeroWeight)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for weight=0: %v", err)
|
|
}
|
|
|
|
hash1, err := configstore.GenerateKeyHash(keyWithOneWeight)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for weight=1: %v", err)
|
|
}
|
|
|
|
if hash0 == hash1 {
|
|
t.Error("Expected different hashes for weight=0 vs weight=1, but they are the same")
|
|
}
|
|
}
|
|
|
|
// TestGenerateKeyHash_EnabledField verifies that the Enabled field affects hash generation.
|
|
// Different Enabled values should produce different hashes.
|
|
func TestGenerateKeyHash_EnabledField(t *testing.T) {
|
|
enabledTrue := true
|
|
enabledFalse := false
|
|
|
|
tests := []struct {
|
|
name string
|
|
key1 schemas.Key
|
|
key2 schemas.Key
|
|
expectEqual bool
|
|
}{
|
|
{
|
|
name: "enabled_true_vs_false_different_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledTrue,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledFalse,
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "enabled_nil_vs_true_different_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: nil,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledTrue,
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "enabled_nil_vs_false_same_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: nil,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledFalse,
|
|
},
|
|
expectEqual: true,
|
|
},
|
|
{
|
|
name: "same_enabled_true_same_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledTrue,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledTrue,
|
|
},
|
|
expectEqual: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err := configstore.GenerateKeyHash(tt.key1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for key1: %v", err)
|
|
}
|
|
|
|
hash2, err := configstore.GenerateKeyHash(tt.key2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for key2: %v", err)
|
|
}
|
|
|
|
if tt.expectEqual && hash1 != hash2 {
|
|
t.Errorf("Expected equal hashes, got hash1=%s, hash2=%s", hash1, hash2)
|
|
}
|
|
if !tt.expectEqual && hash1 == hash2 {
|
|
t.Errorf("Expected different hashes, but both are %s", hash1)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_EnabledChange_Detected verifies that changes to the Enabled field
|
|
// are detected during config reconciliation and properly synced.
|
|
func TestSQLite_Key_EnabledChange_Detected(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
enabledTrue := true
|
|
enabledFalse := false
|
|
keyID := uuid.NewString()
|
|
|
|
// Initial config with Enabled=true
|
|
initialConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-test-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledTrue,
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
// First load
|
|
createConfigFile(t, tempDir, initialConfig)
|
|
config1, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify initial state in the in-memory config
|
|
openaiConfig1 := config1.Providers[schemas.OpenAI]
|
|
if openaiConfig1.Keys[0].Enabled == nil || !*openaiConfig1.Keys[0].Enabled {
|
|
t.Fatal("Expected Enabled=true after first load")
|
|
}
|
|
|
|
// Close first config before second load
|
|
config1.Close(context.Background())
|
|
|
|
// Update config with Enabled=false
|
|
updatedConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-test-123"),
|
|
Weight: 1,
|
|
Enabled: &enabledFalse,
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
// Second load with changed Enabled value
|
|
createConfigFile(t, tempDir, updatedConfig)
|
|
config2, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(context.Background())
|
|
|
|
// Verify Enabled changed to false in the in-memory config
|
|
openaiConfig2 := config2.Providers[schemas.OpenAI]
|
|
if openaiConfig2.Keys[0].Enabled == nil || *openaiConfig2.Keys[0].Enabled {
|
|
t.Error("Expected Enabled=false after second load, but got true or nil")
|
|
}
|
|
}
|
|
|
|
// TestGenerateKeyHash_UseForBatchAPIField verifies that the UseForBatchAPI field affects hash generation.
|
|
// Different UseForBatchAPI values should produce different hashes.
|
|
func TestGenerateKeyHash_UseForBatchAPIField(t *testing.T) {
|
|
batchTrue := true
|
|
batchFalse := false
|
|
|
|
tests := []struct {
|
|
name string
|
|
key1 schemas.Key
|
|
key2 schemas.Key
|
|
expectEqual bool
|
|
}{
|
|
{
|
|
name: "batch_true_vs_false_different_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchTrue,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchFalse,
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "batch_nil_vs_true_different_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: nil,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchTrue,
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "batch_nil_vs_false_same_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: nil,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchFalse,
|
|
},
|
|
expectEqual: true,
|
|
},
|
|
{
|
|
name: "same_batch_true_same_hash",
|
|
key1: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchTrue,
|
|
},
|
|
key2: schemas.Key{
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchTrue,
|
|
},
|
|
expectEqual: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err := configstore.GenerateKeyHash(tt.key1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for key1: %v", err)
|
|
}
|
|
|
|
hash2, err := configstore.GenerateKeyHash(tt.key2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for key2: %v", err)
|
|
}
|
|
|
|
if tt.expectEqual && hash1 != hash2 {
|
|
t.Errorf("Expected equal hashes, got hash1=%s, hash2=%s", hash1, hash2)
|
|
}
|
|
if !tt.expectEqual && hash1 == hash2 {
|
|
t.Errorf("Expected different hashes, but both are %s", hash1)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSQLite_Key_UseForBatchAPIChange_Detected verifies that changes to the UseForBatchAPI field
|
|
// are detected during config reconciliation and properly synced.
|
|
func TestSQLite_Key_UseForBatchAPIChange_Detected(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
batchTrue := true
|
|
batchFalse := false
|
|
keyID := uuid.NewString()
|
|
|
|
// Initial config with UseForBatchAPI=false
|
|
initialConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-test-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchFalse,
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
// First load
|
|
createConfigFile(t, tempDir, initialConfig)
|
|
config1, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
defer config1.Close(context.Background())
|
|
|
|
// Verify initial state in the in-memory config
|
|
openaiConfig1 := config1.Providers[schemas.OpenAI]
|
|
if openaiConfig1.Keys[0].UseForBatchAPI == nil || *openaiConfig1.Keys[0].UseForBatchAPI {
|
|
t.Fatal("Expected UseForBatchAPI=false after first load")
|
|
}
|
|
|
|
// Update config with UseForBatchAPI=true
|
|
updatedConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "test-key",
|
|
Value: *schemas.NewEnvVar("sk-test-123"),
|
|
Weight: 1,
|
|
UseForBatchAPI: &batchTrue,
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
// Second load with changed UseForBatchAPI value
|
|
createConfigFile(t, tempDir, updatedConfig)
|
|
config2, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(context.Background())
|
|
|
|
// Verify UseForBatchAPI changed to true in the in-memory config
|
|
openaiConfig2 := config2.Providers[schemas.OpenAI]
|
|
if openaiConfig2.Keys[0].UseForBatchAPI == nil || !*openaiConfig2.Keys[0].UseForBatchAPI {
|
|
t.Error("Expected UseForBatchAPI=true after second load, but got false or nil")
|
|
}
|
|
}
|
|
|
|
// TestGenerateVirtualKeyHash_ProviderConfigRateLimit verifies that RateLimitID
|
|
// in VK provider configs affects hash generation.
|
|
func TestGenerateVirtualKeyHash_ProviderConfigRateLimit(t *testing.T) {
|
|
rateLimitID1 := "rate-limit-1"
|
|
rateLimitID2 := "rate-limit-2"
|
|
weight := 1.0
|
|
|
|
tests := []struct {
|
|
name string
|
|
vk1 tables.TableVirtualKey
|
|
vk2 tables.TableVirtualKey
|
|
expectEqual bool
|
|
}{
|
|
{
|
|
name: "different_rate_limit_id_different_hash",
|
|
vk1: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: &rateLimitID1,
|
|
},
|
|
},
|
|
},
|
|
vk2: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: &rateLimitID2,
|
|
},
|
|
},
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "nil_vs_set_rate_limit_id_different_hash",
|
|
vk1: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: nil,
|
|
},
|
|
},
|
|
},
|
|
vk2: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: &rateLimitID1,
|
|
},
|
|
},
|
|
},
|
|
expectEqual: false,
|
|
},
|
|
{
|
|
name: "same_rate_limit_same_hash",
|
|
vk1: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: &rateLimitID1,
|
|
},
|
|
},
|
|
},
|
|
vk2: tables.TableVirtualKey{
|
|
ID: "vk-1",
|
|
Name: "test-vk",
|
|
IsActive: true,
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{
|
|
Provider: "openai",
|
|
Weight: &weight,
|
|
RateLimitID: &rateLimitID1,
|
|
},
|
|
},
|
|
},
|
|
expectEqual: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err := configstore.GenerateVirtualKeyHash(tt.vk1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for vk1: %v", err)
|
|
}
|
|
|
|
hash2, err := configstore.GenerateVirtualKeyHash(tt.vk2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate hash for vk2: %v", err)
|
|
}
|
|
|
|
if tt.expectEqual && hash1 != hash2 {
|
|
t.Errorf("Expected equal hashes, got hash1=%s, hash2=%s", hash1, hash2)
|
|
}
|
|
if !tt.expectEqual && hash1 == hash2 {
|
|
t.Errorf("Expected different hashes, but both are %s", hash1)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// intPtr is a helper to create a pointer to an int
|
|
func intPtr(i int) *int {
|
|
return &i
|
|
}
|
|
|
|
// int64Ptr is a helper to create a pointer to an int64
|
|
func int64Ptr(i int64) *int64 {
|
|
return &i
|
|
}
|
|
|
|
// TestKeyHashComparison_VertexConfigSyncScenarios tests full lifecycle for Vertex key configs
|
|
func TestKeyHashComparison_VertexConfigSyncScenarios(t *testing.T) {
|
|
// === Scenario 1: Vertex config in DB + same in file -> hash matches, no update ===
|
|
t.Run("SameVertexConfig_NoUpdate", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
ProjectNumber: *schemas.NewEnvVar("123456789"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
ProjectNumber: *schemas.NewEnvVar("123456789"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash != fileHash {
|
|
t.Errorf("Expected same hash for identical Vertex configs. DB: %s, File: %s", dbHash[:16], fileHash[:16])
|
|
}
|
|
t.Log("✓ Same Vertex config produces same hash - no update needed")
|
|
})
|
|
|
|
// === Scenario 2: Vertex config in DB + different ProjectID in file -> hash differs ===
|
|
t.Run("DifferentProjectID_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("different-project-456"), // Changed!
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex ProjectID changes")
|
|
}
|
|
t.Log("✓ Different Vertex ProjectID produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 3: Vertex config in DB + different Region in file -> hash differs ===
|
|
t.Run("DifferentRegion_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("europe-west1"), // Changed!
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex Region changes")
|
|
}
|
|
t.Log("✓ Different Vertex Region produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 4: Vertex config in DB + different AuthCredentials in file -> hash differs ===
|
|
t.Run("DifferentAuthCredentials_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account","client_id":"old"}`),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account","client_id":"new"}`), // Changed!
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex AuthCredentials changes")
|
|
}
|
|
t.Log("✓ Different Vertex AuthCredentials produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 5: Vertex config in DB + different Deployments map in file -> hash differs ===
|
|
t.Run("DifferentDeployments_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint", "gemini-1.5-pro": "gemini-15-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex Deployments map changes")
|
|
}
|
|
t.Log("✓ Different Vertex Deployments produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 6: Vertex config added to file when not in DB -> new key detected ===
|
|
t.Run("VertexConfigAdded_NewKeyDetected", func(t *testing.T) {
|
|
// DB key has no Vertex config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
// No VertexKeyConfig
|
|
}
|
|
|
|
// File key has Vertex config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex config is added")
|
|
}
|
|
t.Log("✓ Vertex config added produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 7: Vertex config removed from file -> hash differs ===
|
|
t.Run("VertexConfigRemoved_UpdateTriggered", func(t *testing.T) {
|
|
// DB key has Vertex config
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
}
|
|
|
|
// File key has no Vertex config
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
// No VertexKeyConfig
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex config is removed")
|
|
}
|
|
t.Log("✓ Vertex config removed produces different hash - update triggered")
|
|
})
|
|
|
|
// === Scenario 8: ProjectNumber nil vs set -> hash differs ===
|
|
t.Run("ProjectNumberNilVsSet_UpdateTriggered", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
// ProjectNumber is not set (empty EnvVar)
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-api-key-123"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
ProjectNumber: *schemas.NewEnvVar("123456789"), // Explicitly set
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when ProjectNumber goes from empty to set")
|
|
}
|
|
t.Log("✓ ProjectNumber empty vs set produces different hash - update triggered")
|
|
})
|
|
}
|
|
|
|
// TestProviderHashComparison_VertexProviderFullLifecycle tests the complete Vertex provider lifecycle
|
|
func TestProviderHashComparison_VertexProviderFullLifecycle(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
|
|
// Phase 1: Initial load with Vertex config
|
|
initialConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-service-account-json"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, initialConfig)
|
|
config1, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
defer config1.Close(context.Background())
|
|
|
|
// Verify initial state
|
|
providers1, _ := config1.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig1 := providers1[schemas.Vertex]
|
|
if vertexConfig1.Keys[0].VertexKeyConfig == nil {
|
|
t.Fatal("Expected VertexKeyConfig after first load")
|
|
}
|
|
if vertexConfig1.Keys[0].VertexKeyConfig.ProjectID.GetValue() != "my-project-123" {
|
|
t.Errorf("Expected ProjectID='my-project-123', got '%s'", vertexConfig1.Keys[0].VertexKeyConfig.ProjectID.GetValue())
|
|
}
|
|
|
|
// Phase 2: Update config with different Region
|
|
updatedConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-service-account-json"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project-123"),
|
|
Region: *schemas.NewEnvVar("europe-west1"), // Changed!
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, updatedConfig)
|
|
config2, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(context.Background())
|
|
|
|
// Verify Region changed
|
|
providers2, _ := config2.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig2 := providers2[schemas.Vertex]
|
|
if vertexConfig2.Keys[0].VertexKeyConfig.Region.GetValue() != "europe-west1" {
|
|
t.Errorf("Expected Region='europe-west1', got '%s'", vertexConfig2.Keys[0].VertexKeyConfig.Region.GetValue())
|
|
}
|
|
|
|
// Phase 3: Same config again - should not trigger update (hash matches)
|
|
config3, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Third LoadConfig failed: %v", err)
|
|
}
|
|
defer config3.Close(context.Background())
|
|
|
|
providers3, _ := config3.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig3 := providers3[schemas.Vertex]
|
|
if vertexConfig3.Keys[0].VertexKeyConfig.Region.GetValue() != "europe-west1" {
|
|
t.Errorf("Expected Region='europe-west1' preserved, got '%s'", vertexConfig3.Keys[0].VertexKeyConfig.Region.GetValue())
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_VertexNewProviderFromConfig tests adding a new Vertex provider from config
|
|
func TestProviderHashComparison_VertexNewProviderFromConfig(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
// Create config with Vertex provider
|
|
configData := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: uuid.NewString(),
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("new-project-456"),
|
|
Region: *schemas.NewEnvVar("us-west1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
config, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig failed: %v", err)
|
|
}
|
|
defer config.Close(context.Background())
|
|
|
|
// Verify Vertex provider was created
|
|
providers, _ := config.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig, exists := providers[schemas.Vertex]
|
|
if !exists {
|
|
t.Fatal("Expected Vertex provider to exist in DB")
|
|
}
|
|
if len(vertexConfig.Keys) != 1 {
|
|
t.Fatalf("Expected 1 key, got %d", len(vertexConfig.Keys))
|
|
}
|
|
if vertexConfig.Keys[0].VertexKeyConfig == nil {
|
|
t.Fatal("Expected VertexKeyConfig to exist")
|
|
}
|
|
if vertexConfig.Keys[0].VertexKeyConfig.ProjectID.GetValue() != "new-project-456" {
|
|
t.Errorf("Expected ProjectID='new-project-456', got '%s'", vertexConfig.Keys[0].VertexKeyConfig.ProjectID.GetValue())
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_VertexDBValuePreservedWhenHashMatches tests that DB values are preserved when hash matches
|
|
func TestProviderHashComparison_VertexDBValuePreservedWhenHashMatches(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
|
|
// Initial config
|
|
configData := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
AuthCredentials: *schemas.NewEnvVar(`{"type":"service_account"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
config1, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
|
|
// Manually update the DB with a different AuthCredentials (simulating dashboard edit)
|
|
providers1, _ := config1.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig := providers1[schemas.Vertex]
|
|
vertexConfig.Keys[0].VertexKeyConfig.AuthCredentials = *schemas.NewEnvVar(`{"type":"service_account","edited":true}`)
|
|
config1.ConfigStore.UpdateProvidersConfig(context.Background(), providers1)
|
|
config1.Close(context.Background())
|
|
|
|
// Reload with same config file - DB value should be preserved since hash matches
|
|
config2, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(context.Background())
|
|
|
|
// Note: With hash-based reconciliation, when the file hash doesn't change,
|
|
// the DB values are preserved. Since we modified the DB but not the file,
|
|
// the file hash still matches the original, so DB values are kept.
|
|
providers2, _ := config2.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig2 := providers2[schemas.Vertex]
|
|
// The AuthCredentials should be the DB-modified value since file hash matches original
|
|
if vertexConfig2.Keys[0].VertexKeyConfig.AuthCredentials.GetValue() != `{"type":"service_account","edited":true}` {
|
|
t.Logf("AuthCredentials: %s", vertexConfig2.Keys[0].VertexKeyConfig.AuthCredentials.GetValue())
|
|
// This is expected behavior - when file hasn't changed, DB value is preserved
|
|
}
|
|
}
|
|
|
|
// TestProviderHashComparison_VertexConfigChangedInFile tests that file changes override DB when hash differs
|
|
func TestProviderHashComparison_VertexConfigChangedInFile(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
|
|
keyID := uuid.NewString()
|
|
|
|
// Initial config
|
|
initialConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("original-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, initialConfig)
|
|
config1, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("First LoadConfig failed: %v", err)
|
|
}
|
|
config1.Close(context.Background())
|
|
|
|
// Update config file with different ProjectID
|
|
updatedConfig := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{
|
|
"vertex": {
|
|
Keys: []schemas.Key{
|
|
{
|
|
ID: keyID,
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("updated-project"), // Changed!
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, tempDir)
|
|
|
|
createConfigFile(t, tempDir, updatedConfig)
|
|
config2, err := LoadConfig(context.Background(), tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Second LoadConfig failed: %v", err)
|
|
}
|
|
defer config2.Close(context.Background())
|
|
|
|
// Verify file value wins
|
|
providers2, _ := config2.ConfigStore.GetProvidersConfig(context.Background())
|
|
vertexConfig2 := providers2[schemas.Vertex]
|
|
if vertexConfig2.Keys[0].VertexKeyConfig.ProjectID.GetValue() != "updated-project" {
|
|
t.Errorf("Expected ProjectID='updated-project' from file, got '%s'", vertexConfig2.Keys[0].VertexKeyConfig.ProjectID.GetValue())
|
|
}
|
|
}
|
|
|
|
// TestKeyHashComparison_AzureDeploymentsChange tests various deployment map change scenarios for Azure
|
|
func TestKeyHashComparison_AzureDeploymentsChange(t *testing.T) {
|
|
t.Run("AddDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment", "gpt-4o": "gpt-4o-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when adding Azure deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("RemoveDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment", "gpt-4o": "gpt-4o-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when removing Azure deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("ModifyDeploymentValue", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment-v1"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment-v2"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when modifying Azure deployment value")
|
|
}
|
|
})
|
|
|
|
t.Run("EmptyToNonEmpty", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "azure-key",
|
|
Value: *schemas.NewEnvVar("azure-api-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gpt-4": "gpt-4-deployment"},
|
|
AzureKeyConfig: &schemas.AzureKeyConfig{
|
|
Endpoint: *schemas.NewEnvVar("https://myazure.openai.azure.com"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Azure deployments go from nil to non-empty")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestKeyHashComparison_BedrockDeploymentsChange tests various deployment map change scenarios for Bedrock
|
|
func TestKeyHashComparison_BedrockDeploymentsChange(t *testing.T) {
|
|
t.Run("AddDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3", "claude-3.5": "arn:aws:bedrock:us-east-1::inference-profile/claude-3.5"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when adding Bedrock deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("RemoveDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3", "claude-3.5": "arn:aws:bedrock:us-east-1::inference-profile/claude-3.5"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when removing Bedrock deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("ModifyDeploymentValue", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3-old"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "bedrock-key",
|
|
Value: *schemas.NewEnvVar("bedrock-key"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"claude-3": "arn:aws:bedrock:us-east-1::inference-profile/claude-3-new"},
|
|
BedrockKeyConfig: &schemas.BedrockKeyConfig{
|
|
AccessKey: *schemas.NewEnvVar("AKIAIOSFODNN7EXAMPLE"),
|
|
SecretKey: *schemas.NewEnvVar("wJalrXUtnFEMI"),
|
|
Region: schemas.NewEnvVar("us-east-1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when modifying Bedrock deployment value")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestKeyHashComparison_VertexDeploymentsChange tests various deployment map change scenarios for Vertex
|
|
func TestKeyHashComparison_VertexDeploymentsChange(t *testing.T) {
|
|
t.Run("AddDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint", "gemini-1.5-pro": "gemini-15-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when adding Vertex deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("RemoveDeployment", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint", "gemini-1.5-pro": "gemini-15-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when removing Vertex deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("ModifyDeploymentValue", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint-v1"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint-v2"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when modifying Vertex deployment value")
|
|
}
|
|
})
|
|
|
|
t.Run("EmptyToNonEmpty", func(t *testing.T) {
|
|
dbKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
fileKey := schemas.Key{
|
|
ID: "key-1",
|
|
Name: "vertex-key",
|
|
Value: *schemas.NewEnvVar("vertex-creds"),
|
|
Weight: 1,
|
|
Aliases: schemas.KeyAliases{"gemini-pro": "gemini-pro-endpoint"},
|
|
VertexKeyConfig: &schemas.VertexKeyConfig{
|
|
ProjectID: *schemas.NewEnvVar("my-project"),
|
|
Region: *schemas.NewEnvVar("us-central1"),
|
|
},
|
|
}
|
|
|
|
dbHash, _ := configstore.GenerateKeyHash(dbKey)
|
|
fileHash, _ := configstore.GenerateKeyHash(fileKey)
|
|
|
|
if dbHash == fileHash {
|
|
t.Error("Expected different hash when Vertex deployments go from nil to non-empty")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ===================================================================================
|
|
// CONFIG SCHEMA SYNC TEST
|
|
// ===================================================================================
|
|
// This test ensures that the JSON schema (config.schema.json) and the Go structs
|
|
// remain synchronized at ALL levels (not just top-level). It validates that:
|
|
// - All properties in the JSON schema have corresponding fields in Go structs
|
|
// - All JSON-tagged fields in Go structs have corresponding properties in the schema
|
|
// - Nested types (ClientConfig, GovernanceConfig, etc.) match their schema definitions
|
|
//
|
|
// This prevents schema drift when new configuration options are added.
|
|
// ===================================================================================
|
|
|
|
// schemaTypeMapping defines a mapping between a JSON schema path and its corresponding Go type
|
|
type schemaTypeMapping struct {
|
|
SchemaPath string // Path in schema (e.g., "client", "governance.budgets")
|
|
GoType reflect.Type // The Go type to validate against
|
|
IsArray bool // True if the schema path refers to array items
|
|
}
|
|
|
|
// getSchemaTypeMappings returns all mappings between JSON schema paths and Go types
|
|
func getSchemaTypeMappings() []schemaTypeMapping {
|
|
return []schemaTypeMapping{
|
|
// Top-level ConfigData fields
|
|
{"", reflect.TypeOf(ConfigData{}), false},
|
|
|
|
// Client config
|
|
{"client", reflect.TypeOf(configstore.ClientConfig{}), false},
|
|
{"client.header_filter_config", reflect.TypeOf(tables.GlobalHeaderFilterConfig{}), false},
|
|
|
|
// Auth config (top-level)
|
|
{"auth_config", reflect.TypeOf(configstore.AuthConfig{}), false},
|
|
|
|
// Framework config
|
|
{"framework", reflect.TypeOf(framework.FrameworkConfig{}), false},
|
|
{"framework.pricing", reflect.TypeOf(modelcatalog.Config{}), false},
|
|
|
|
// MCP config
|
|
{"mcp", reflect.TypeOf(schemas.MCPConfig{}), false},
|
|
{"mcp.client_configs", reflect.TypeOf(schemas.MCPClientConfig{}), true},
|
|
{"mcp.client_configs.stdio_config", reflect.TypeOf(schemas.MCPStdioConfig{}), false},
|
|
{"mcp.tool_manager_config", reflect.TypeOf(schemas.MCPToolManagerConfig{}), false},
|
|
|
|
// Governance config
|
|
{"governance", reflect.TypeOf(configstore.GovernanceConfig{}), false},
|
|
{"governance.budgets", reflect.TypeOf(tables.TableBudget{}), true},
|
|
{"governance.rate_limits", reflect.TypeOf(tables.TableRateLimit{}), true},
|
|
{"governance.customers", reflect.TypeOf(tables.TableCustomer{}), true},
|
|
{"governance.teams", reflect.TypeOf(tables.TableTeam{}), true},
|
|
{"governance.virtual_keys", reflect.TypeOf(tables.TableVirtualKey{}), true},
|
|
{"governance.virtual_keys.provider_configs", reflect.TypeOf(tables.TableVirtualKeyProviderConfig{}), true},
|
|
{"governance.virtual_keys.mcp_configs", reflect.TypeOf(tables.TableVirtualKeyMCPConfig{}), true},
|
|
{"governance.auth_config", reflect.TypeOf(configstore.AuthConfig{}), false},
|
|
|
|
// Plugins
|
|
{"plugins", reflect.TypeOf(schemas.PluginConfig{}), true},
|
|
}
|
|
}
|
|
|
|
// enterpriseSchemaPaths are schema paths that exist only in enterprise version
|
|
var enterpriseSchemaPaths = map[string]bool{
|
|
"$schema": true,
|
|
"audit_logs": true,
|
|
"cluster_config": true,
|
|
"scim_config": true,
|
|
"load_balancer_config": true,
|
|
"guardrails_config": true,
|
|
"large_payload_optimization": true,
|
|
}
|
|
|
|
// excludedGoFields are Go struct fields that should not be in the schema (internal use only)
|
|
// These include:
|
|
// - Database/ORM fields (created_at, updated_at, config_hash)
|
|
// - GORM relationship fields (budget, team, customer, etc.)
|
|
// - Internal state fields not meant for config files
|
|
var excludedGoFields = map[string]map[string]bool{
|
|
// ClientConfig - MCP fields are managed at MCP level, not client level
|
|
"configstore.ClientConfig": {
|
|
"ConfigHash": true,
|
|
"allowed_headers": true, // Internal use
|
|
"mcp_agent_depth": true, // Managed via MCP config
|
|
"mcp_code_mode_binding_level": true,
|
|
"mcp_tool_execution_timeout": true,
|
|
"mcp_tool_sync_interval": true,
|
|
"mcp_disable_auto_tool_inject": true,
|
|
},
|
|
"configstore.ProviderConfig": {"ConfigHash": true},
|
|
// GovernanceConfig - some fields are internal/enterprise
|
|
"configstore.GovernanceConfig": {
|
|
"model_configs": true, // Internal
|
|
"providers": true, // Internal
|
|
"routing_rules": true, // Internal
|
|
},
|
|
// Table types have DB-specific fields
|
|
"tables.TableBudget": {
|
|
"config_hash": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
"virtual_key_id": true, // Internal DB FK for multi-budget ownership
|
|
"provider_config_id": true, // Internal DB FK for multi-budget ownership
|
|
},
|
|
"tables.TableRateLimit": {
|
|
"config_hash": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
},
|
|
"tables.TableCustomer": {
|
|
"config_hash": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
"budget": true, // GORM relation
|
|
"rate_limit": true, // GORM relation
|
|
"teams": true, // GORM relation
|
|
"virtual_keys": true, // GORM relation
|
|
},
|
|
"tables.TableTeam": {
|
|
"config_hash": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
"budget": true, // GORM relation
|
|
"rate_limit": true, // GORM relation
|
|
"customer": true, // GORM relation
|
|
"virtual_keys": true, // GORM relation
|
|
},
|
|
"tables.TableVirtualKey": {
|
|
"config_hash": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
"budgets": true, // GORM relation (budgets have virtual_key_id FK)
|
|
"rate_limit": true, // GORM relation
|
|
"team": true, // GORM relation
|
|
"customer": true, // GORM relation
|
|
},
|
|
"tables.TableVirtualKeyProviderConfig": {
|
|
"rate_limit": true, // GORM relation
|
|
"allow_all_keys": true, // Internal DB field; users configure via key_ids
|
|
"keys": true, // GORM many2many relation; users configure via key_ids
|
|
"budgets": true, // GORM relation (budgets have provider_config_id FK)
|
|
},
|
|
"tables.TableVirtualKeyMCPConfig": {
|
|
"mcp_client": true, // GORM relation
|
|
},
|
|
// MCP types have internal state fields
|
|
"schemas.MCPConfig": {
|
|
"tool_sync_interval": true, // Internal
|
|
},
|
|
"schemas.MCPClientConfig": {
|
|
"client_id": true, // Internal ID
|
|
"state": true, // Runtime state
|
|
"is_code_mode_client": true, // Internal
|
|
"auth_type": true, // Internal
|
|
"oauth_config_id": true, // Internal
|
|
"is_ping_available": true, // Runtime state
|
|
"tool_sync_interval": true, // Internal
|
|
"tool_pricing": true, // Internal
|
|
"tools_to_auto_execute": true, // Internal
|
|
"tools_to_execute": true, // Moved to VK MCP config
|
|
"connection_string": true, // Use specific config types instead
|
|
"headers": true, // Internal
|
|
},
|
|
"schemas.MCPToolManagerConfig": {
|
|
"code_mode_binding_level": true, // Internal
|
|
},
|
|
"schemas.PluginConfig": {},
|
|
"framework.FrameworkConfig": {},
|
|
"modelcatalog.Config": {},
|
|
"tables.GlobalHeaderFilterConfig": {},
|
|
"configstore.AuthConfig": {},
|
|
"schemas.MCPStdioConfig": {},
|
|
"lib.ConfigData": {},
|
|
"vectorstore.Config": {},
|
|
"configstore.Config": {},
|
|
"logstore.Config": {},
|
|
}
|
|
|
|
// excludedSchemaFields are schema fields that don't exist in Go structs (schema-only documentation)
|
|
var excludedSchemaFields = map[string]map[string]bool{
|
|
"client": {
|
|
"allowed_headers": true, // Not in ClientConfig
|
|
},
|
|
"governance.virtual_keys.provider_configs": {
|
|
"keys": true, // Complex nested type, validated separately
|
|
"key_ids": true, // Config-file format; handled via custom UnmarshalJSON into allow_all_keys/keys
|
|
},
|
|
"governance.virtual_keys.mcp_configs": {
|
|
"mcp_client_name": true, // Config-file format; captured via custom UnmarshalJSON and resolved to mcp_client_id at startup
|
|
},
|
|
"mcp.client_configs": {
|
|
"websocket_config": true, // Schema documents all connection types
|
|
"http_config": true, // Schema documents all connection types
|
|
},
|
|
}
|
|
|
|
// loadJSONSchema loads and parses the JSON schema file
|
|
func loadJSONSchema(t *testing.T) map[string]interface{} {
|
|
_, currentFile, _, ok := runtime.Caller(0)
|
|
require.True(t, ok, "Failed to get current file path")
|
|
|
|
testDir := filepath.Dir(currentFile)
|
|
schemaPath := filepath.Join(testDir, "..", "..", "config.schema.json")
|
|
|
|
schemaData, err := os.ReadFile(schemaPath)
|
|
require.NoError(t, err, "Failed to read config.schema.json at %s", schemaPath)
|
|
|
|
var schema map[string]interface{}
|
|
err = json.Unmarshal(schemaData, &schema)
|
|
require.NoError(t, err, "Failed to parse config.schema.json")
|
|
|
|
return schema
|
|
}
|
|
|
|
// resolveSchemaRef resolves a $ref reference in the schema
|
|
func resolveSchemaRef(schema map[string]interface{}, ref string) map[string]interface{} {
|
|
// refs look like "#/$defs/some_type"
|
|
if !strings.HasPrefix(ref, "#/$defs/") {
|
|
return nil
|
|
}
|
|
defName := strings.TrimPrefix(ref, "#/$defs/")
|
|
|
|
defs, ok := schema["$defs"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
def, ok := defs[defName].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return def
|
|
}
|
|
|
|
// getSchemaPropertiesAtPath gets the properties object at a given path in the schema
|
|
func getSchemaPropertiesAtPath(schema map[string]interface{}, path string) map[string]interface{} {
|
|
if path == "" {
|
|
// Root level
|
|
props, _ := schema["properties"].(map[string]interface{})
|
|
return props
|
|
}
|
|
|
|
parts := strings.Split(path, ".")
|
|
current := schema["properties"].(map[string]interface{})
|
|
|
|
for i, part := range parts {
|
|
prop, ok := current[part].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Check if this is a $ref
|
|
if ref, ok := prop["$ref"].(string); ok {
|
|
prop = resolveSchemaRef(schema, ref)
|
|
if prop == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// If this is the last part, get its properties
|
|
if i == len(parts)-1 {
|
|
// Check for array items
|
|
if prop["type"] == "array" {
|
|
items, ok := prop["items"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// Check if items is a $ref
|
|
if ref, ok := items["$ref"].(string); ok {
|
|
items = resolveSchemaRef(schema, ref)
|
|
if items == nil {
|
|
return nil
|
|
}
|
|
}
|
|
props, _ := items["properties"].(map[string]interface{})
|
|
return props
|
|
}
|
|
props, _ := prop["properties"].(map[string]interface{})
|
|
return props
|
|
}
|
|
|
|
// Navigate deeper
|
|
// Check for array items
|
|
if prop["type"] == "array" {
|
|
items, ok := prop["items"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// Check if items is a $ref
|
|
if ref, ok := items["$ref"].(string); ok {
|
|
items = resolveSchemaRef(schema, ref)
|
|
if items == nil {
|
|
return nil
|
|
}
|
|
}
|
|
current, ok = items["properties"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
} else {
|
|
current, ok = prop["properties"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
// getGoStructFields extracts JSON field names from a Go struct type
|
|
func getGoStructFields(t reflect.Type) map[string]bool {
|
|
fields := make(map[string]bool)
|
|
|
|
// Handle pointer types
|
|
if t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
|
|
if t.Kind() != reflect.Struct {
|
|
return fields
|
|
}
|
|
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
jsonTag := field.Tag.Get("json")
|
|
if jsonTag == "" || jsonTag == "-" {
|
|
continue
|
|
}
|
|
// Handle tags like `json:"field_name,omitempty"`
|
|
tagParts := strings.Split(jsonTag, ",")
|
|
fieldName := tagParts[0]
|
|
if fieldName != "" && fieldName != "-" {
|
|
fields[fieldName] = true
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// getTypeName returns a short name for a type (for exclusion map lookup)
|
|
func getTypeName(t reflect.Type) string {
|
|
if t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
pkgPath := t.PkgPath()
|
|
// Extract just the package name from the full path
|
|
parts := strings.Split(pkgPath, "/")
|
|
pkgName := parts[len(parts)-1]
|
|
return pkgName + "." + t.Name()
|
|
}
|
|
|
|
// TestConfigSchemaSync validates that config.schema.json and all Go structs are in sync.
|
|
// This test recursively validates all nested types, ensuring complete synchronization.
|
|
func TestConfigSchemaSync(t *testing.T) {
|
|
schema := loadJSONSchema(t)
|
|
|
|
mappings := getSchemaTypeMappings()
|
|
var allErrors []string
|
|
|
|
for _, mapping := range mappings {
|
|
// Skip enterprise-only paths
|
|
if enterpriseSchemaPaths[mapping.SchemaPath] {
|
|
continue
|
|
}
|
|
|
|
// Get schema properties at this path
|
|
schemaProps := getSchemaPropertiesAtPath(schema, mapping.SchemaPath)
|
|
if schemaProps == nil && mapping.SchemaPath != "" {
|
|
// For struct/array mappings, missing schema path is a test failure
|
|
// Only simple-type mappings (none currently defined) would be acceptable to skip
|
|
goTypeKind := mapping.GoType.Kind()
|
|
if goTypeKind == reflect.Struct || mapping.IsArray {
|
|
t.Fatalf("Schema path not found for struct/array mapping: SchemaPath=%q, GoType=%v, IsArray=%v",
|
|
mapping.SchemaPath, mapping.GoType, mapping.IsArray)
|
|
}
|
|
// Simple types can be skipped
|
|
continue
|
|
}
|
|
|
|
// Get Go struct fields
|
|
goFields := getGoStructFields(mapping.GoType)
|
|
|
|
typeName := getTypeName(mapping.GoType)
|
|
excludedGo := excludedGoFields[typeName]
|
|
if excludedGo == nil {
|
|
excludedGo = make(map[string]bool)
|
|
}
|
|
excludedSchema := excludedSchemaFields[mapping.SchemaPath]
|
|
if excludedSchema == nil {
|
|
excludedSchema = make(map[string]bool)
|
|
}
|
|
|
|
// Find fields in schema but missing from Go struct
|
|
for prop := range schemaProps {
|
|
if !goFields[prop] && !excludedSchema[prop] && !enterpriseSchemaPaths[prop] {
|
|
allErrors = append(allErrors, fmt.Sprintf(
|
|
"[%s] Field '%s' in schema but missing from %s",
|
|
mapping.SchemaPath, prop, typeName))
|
|
}
|
|
}
|
|
|
|
// Find fields in Go struct but missing from schema
|
|
for field := range goFields {
|
|
_, inSchema := schemaProps[field]
|
|
if schemaProps != nil && !inSchema && !excludedGo[field] {
|
|
allErrors = append(allErrors, fmt.Sprintf(
|
|
"[%s] Field '%s' in %s but missing from schema",
|
|
mapping.SchemaPath, field, typeName))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort errors for consistent output
|
|
sort.Strings(allErrors)
|
|
|
|
if len(allErrors) > 0 {
|
|
t.Errorf("Schema sync errors found (%d total):\n%s\n\n"+
|
|
"To fix:\n"+
|
|
"- Add missing fields to Go structs, OR\n"+
|
|
"- Add missing fields to config.schema.json, OR\n"+
|
|
"- Add to excludedGoFields/excludedSchemaFields if intentionally different",
|
|
len(allErrors), strings.Join(allErrors, "\n"))
|
|
} else {
|
|
t.Logf("Schema sync validated: %d type mappings checked, all fields match", len(mappings))
|
|
}
|
|
}
|
|
|
|
// TestConfigSchemaSyncTopLevel is a simpler test that only checks top-level properties
|
|
// This is kept for backwards compatibility and as a quick smoke test
|
|
func TestConfigSchemaSyncTopLevel(t *testing.T) {
|
|
// Enterprise-only features: These fields exist in the JSON schema for documentation
|
|
// and validation purposes, but are only available in the enterprise version.
|
|
enterpriseSchemaFields := map[string]bool{
|
|
"$schema": true,
|
|
"audit_logs": true,
|
|
"cluster_config": true,
|
|
"scim_config": true,
|
|
"load_balancer_config": true,
|
|
"guardrails_config": true,
|
|
"large_payload_optimization": true,
|
|
}
|
|
|
|
schema := loadJSONSchema(t)
|
|
schemaProps, ok := schema["properties"].(map[string]interface{})
|
|
require.True(t, ok, "JSON schema must have a 'properties' field")
|
|
|
|
// Extract JSON tag names from ConfigData struct
|
|
structProps := getGoStructFields(reflect.TypeOf(ConfigData{}))
|
|
|
|
// Find mismatches
|
|
var missingInStruct, missingInSchema []string
|
|
|
|
for prop := range schemaProps {
|
|
if !structProps[prop] && !enterpriseSchemaFields[prop] {
|
|
missingInStruct = append(missingInStruct, prop)
|
|
}
|
|
}
|
|
|
|
for prop := range structProps {
|
|
if schemaProps[prop] == nil {
|
|
missingInSchema = append(missingInSchema, prop)
|
|
}
|
|
}
|
|
|
|
if len(missingInStruct) > 0 {
|
|
sort.Strings(missingInStruct)
|
|
t.Errorf("Fields in schema but missing from ConfigData: %v", missingInStruct)
|
|
}
|
|
|
|
if len(missingInSchema) > 0 {
|
|
sort.Strings(missingInSchema)
|
|
t.Errorf("Fields in ConfigData but missing from schema: %v", missingInSchema)
|
|
}
|
|
|
|
if len(missingInStruct) == 0 && len(missingInSchema) == 0 {
|
|
matchedCount := 0
|
|
for prop := range schemaProps {
|
|
if structProps[prop] {
|
|
matchedCount++
|
|
}
|
|
}
|
|
t.Logf("Top-level sync validated: %d properties match (%d enterprise-only excluded)",
|
|
matchedCount, len(enterpriseSchemaFields))
|
|
}
|
|
}
|
|
|
|
// ===================================================================================
|
|
// AUTH CONFIG PASSWORD HASHING TESTS
|
|
// ===================================================================================
|
|
|
|
func TestResolveFrameworkPricingConfig(t *testing.T) {
|
|
initTestLogger()
|
|
defaultURL := modelcatalog.DefaultPricingURL
|
|
defaultSyncSeconds := int64(modelcatalog.DefaultSyncInterval.Seconds())
|
|
fileURL := "https://example.com/pricing.json"
|
|
fileSyncSeconds := int64((12 * time.Hour).Seconds())
|
|
dbURL := "https://db.example.com/pricing.json"
|
|
dbSyncSeconds := int64((6 * time.Hour).Seconds())
|
|
|
|
t.Run("db values take precedence", func(t *testing.T) {
|
|
dbConfig := &tables.TableFrameworkConfig{
|
|
ID: 7,
|
|
PricingURL: &dbURL,
|
|
PricingSyncInterval: &dbSyncSeconds,
|
|
}
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingURL: &fileURL,
|
|
PricingSyncInterval: &fileSyncSeconds,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(dbConfig, fileConfig)
|
|
require.False(t, needsDBUpdate)
|
|
require.Equal(t, uint(7), normalizedTable.ID)
|
|
require.Equal(t, dbURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, dbSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, dbURL, *normalizedModelCatalog.PricingURL)
|
|
require.Equal(t, dbSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("fallback to file when db fields are missing", func(t *testing.T) {
|
|
dbConfig := &tables.TableFrameworkConfig{
|
|
ID: 3,
|
|
PricingURL: nil,
|
|
PricingSyncInterval: nil,
|
|
}
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingURL: &fileURL,
|
|
PricingSyncInterval: &fileSyncSeconds,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(dbConfig, fileConfig)
|
|
require.True(t, needsDBUpdate)
|
|
require.Equal(t, uint(3), normalizedTable.ID)
|
|
require.Equal(t, fileURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, fileSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, fileURL, *normalizedModelCatalog.PricingURL)
|
|
require.Equal(t, fileSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("fallback to defaults when db and file are missing", func(t *testing.T) {
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(nil, nil)
|
|
require.False(t, needsDBUpdate)
|
|
require.Equal(t, defaultURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, defaultURL, *normalizedModelCatalog.PricingURL)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("invalid db interval (zero) falls back and requests db update", func(t *testing.T) {
|
|
invalidDBSync := int64(0)
|
|
dbConfig := &tables.TableFrameworkConfig{
|
|
ID: 5,
|
|
PricingURL: &dbURL,
|
|
PricingSyncInterval: &invalidDBSync,
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(dbConfig, nil)
|
|
require.True(t, needsDBUpdate)
|
|
require.Equal(t, dbURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, dbURL, *normalizedModelCatalog.PricingURL)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("invalid db interval (negative) falls back and requests db update", func(t *testing.T) {
|
|
negativeDBSync := int64(-100)
|
|
dbConfig := &tables.TableFrameworkConfig{
|
|
ID: 6,
|
|
PricingURL: &dbURL,
|
|
PricingSyncInterval: &negativeDBSync,
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(dbConfig, nil)
|
|
require.True(t, needsDBUpdate)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("file interval below minimum is clamped to 3600", func(t *testing.T) {
|
|
tooLow := int64(1800) // 30 minutes — below minimum 3600
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingSyncInterval: &tooLow,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
require.False(t, needsDBUpdate)
|
|
require.Equal(t, modelcatalog.MinimumPricingSyncIntervalSec, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, modelcatalog.MinimumPricingSyncIntervalSec, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("file interval of zero is ignored and defaults apply", func(t *testing.T) {
|
|
zero := int64(0)
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingSyncInterval: &zero,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
require.False(t, needsDBUpdate)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("file interval negative is ignored and defaults apply", func(t *testing.T) {
|
|
neg := int64(-1)
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingSyncInterval: &neg,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, needsDBUpdate := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
require.False(t, needsDBUpdate)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedTable.PricingSyncInterval)
|
|
require.Equal(t, defaultSyncSeconds, *normalizedModelCatalog.PricingSyncInterval)
|
|
})
|
|
|
|
t.Run("pricing_url with missing env var falls back to literal string", func(t *testing.T) {
|
|
// Use a name that is guaranteed not to be set in the test environment
|
|
rawURL := "env.BIFROST_TEST_PRICING_URL_NONEXISTENT_XYZ"
|
|
prev, existed := os.LookupEnv("BIFROST_TEST_PRICING_URL_NONEXISTENT_XYZ")
|
|
os.Unsetenv("BIFROST_TEST_PRICING_URL_NONEXISTENT_XYZ")
|
|
t.Cleanup(func() {
|
|
if existed {
|
|
os.Setenv("BIFROST_TEST_PRICING_URL_NONEXISTENT_XYZ", prev)
|
|
}
|
|
})
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingURL: &rawURL,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, _ := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
// Should preserve the original "env.*" literal, not silently revert to default URL
|
|
require.Equal(t, rawURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, rawURL, *normalizedModelCatalog.PricingURL)
|
|
})
|
|
|
|
t.Run("pricing_url with valid env var is resolved", func(t *testing.T) {
|
|
t.Setenv("BIFROST_TEST_PRICING_URL_VALID", "https://resolved.example.com/pricing.json")
|
|
rawURL := "env.BIFROST_TEST_PRICING_URL_VALID"
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingURL: &rawURL,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, _ := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
require.Equal(t, "https://resolved.example.com/pricing.json", *normalizedTable.PricingURL)
|
|
require.Equal(t, "https://resolved.example.com/pricing.json", *normalizedModelCatalog.PricingURL)
|
|
})
|
|
|
|
t.Run("partial/embedded env string is treated as literal (no substitution)", func(t *testing.T) {
|
|
// envutils.ProcessEnvValue only substitutes full-string "env.VAR" values.
|
|
// A URL that contains env syntax mid-string must not be partially expanded.
|
|
t.Setenv("BIFROST_TEST_PRICING_HOST", "host.example.com")
|
|
embeddedURL := "https://env.BIFROST_TEST_PRICING_HOST/pricing.json"
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingURL: &embeddedURL,
|
|
},
|
|
}
|
|
|
|
normalizedTable, normalizedModelCatalog, _ := ResolveFrameworkPricingConfig(nil, fileConfig)
|
|
// The URL does not start with "env." so it must be returned verbatim.
|
|
require.Equal(t, embeddedURL, *normalizedTable.PricingURL)
|
|
require.Equal(t, embeddedURL, *normalizedModelCatalog.PricingURL)
|
|
})
|
|
|
|
t.Run("returned pointers are never nil regardless of inputs", func(t *testing.T) {
|
|
// Verify the no-nil contract for all four degenerate input combinations.
|
|
inputs := []struct {
|
|
db *tables.TableFrameworkConfig
|
|
file *framework.FrameworkConfig
|
|
}{
|
|
{nil, nil},
|
|
{&tables.TableFrameworkConfig{}, nil},
|
|
{nil, &framework.FrameworkConfig{}},
|
|
{&tables.TableFrameworkConfig{}, &framework.FrameworkConfig{}},
|
|
}
|
|
for _, tc := range inputs {
|
|
tableOut, catalogOut, _ := ResolveFrameworkPricingConfig(tc.db, tc.file)
|
|
require.NotNil(t, tableOut, "TableFrameworkConfig must never be nil")
|
|
require.NotNil(t, tableOut.PricingURL, "PricingURL must never be nil")
|
|
require.NotNil(t, tableOut.PricingSyncInterval, "PricingSyncInterval must never be nil")
|
|
require.NotNil(t, catalogOut, "modelcatalog.Config must never be nil")
|
|
require.NotNil(t, catalogOut.PricingURL, "Config.PricingURL must never be nil")
|
|
require.NotNil(t, catalogOut.PricingSyncInterval, "Config.PricingSyncInterval must never be nil")
|
|
}
|
|
})
|
|
|
|
t.Run("db corrupted (zero) with valid file interval uses file value and requests db backfill", func(t *testing.T) {
|
|
// Real-world recovery scenario: a pre-fix Bifrost wrote 0 nanoseconds (interpreted
|
|
// as 0 seconds) to the DB. The new code must heal this by preferring the valid
|
|
// file value and flagging the DB for an update so the next restart finds a sane
|
|
// value without requiring manual DB intervention.
|
|
corruptedDBSync := int64(0)
|
|
fileSync := int64(7200) // 2 hours — valid, above minimum
|
|
|
|
dbConfig := &tables.TableFrameworkConfig{
|
|
ID: 9,
|
|
PricingURL: &dbURL,
|
|
PricingSyncInterval: &corruptedDBSync,
|
|
}
|
|
fileConfig := &framework.FrameworkConfig{
|
|
Pricing: &modelcatalog.Config{
|
|
PricingSyncInterval: &fileSync,
|
|
},
|
|
}
|
|
|
|
tableOut, catalogOut, needsDBUpdate := ResolveFrameworkPricingConfig(dbConfig, fileConfig)
|
|
|
|
// DB corruption must be detected and flagged for backfill.
|
|
require.True(t, needsDBUpdate, "corrupted DB interval (zero) must trigger a DB backfill")
|
|
|
|
// The file-configured value (7200 s) must win over the corrupted DB value.
|
|
require.Equal(t, int64(7200), *tableOut.PricingSyncInterval,
|
|
"table output must reflect valid file interval, not corrupted DB value")
|
|
require.Equal(t, int64(7200), *catalogOut.PricingSyncInterval,
|
|
"catalog output must reflect valid file interval, not corrupted DB value")
|
|
|
|
// URL should still come from DB (only the interval was corrupted).
|
|
require.Equal(t, dbURL, *tableOut.PricingURL,
|
|
"URL from a valid DB field must still be used")
|
|
})
|
|
}
|
|
|
|
func TestIsBcryptHash(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected bool
|
|
}{
|
|
{"bcrypt $2a$ prefix", "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true},
|
|
{"bcrypt $2b$ prefix", "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true},
|
|
{"bcrypt $2y$ prefix", "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true},
|
|
{"plain text password", "mypassword", false},
|
|
{"empty string", "", false},
|
|
{"partial prefix $2a", "$2a", false},
|
|
{"different hash format", "$argon2id$v=19$m=65536,t=3,p=4$...", false},
|
|
{"sha256 hash", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isBcryptHash(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("isBcryptHash(%q) = %v, want %v", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadAuthConfigFromFile_PasswordHashing(t *testing.T) {
|
|
initTestLogger()
|
|
ctx := context.Background()
|
|
|
|
t.Run("plain text password gets hashed", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
plainPassword := "mysecretpassword"
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(plainPassword),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify auth config was stored
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify password was hashed (not plain text)
|
|
require.NotEqual(t, plainPassword, storedAuth.AdminPassword, "password should be hashed, not plain text")
|
|
|
|
// Verify the stored hash is a valid bcrypt hash
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "stored password should be a bcrypt hash")
|
|
|
|
// Verify the hash can be used to verify the original password
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), plainPassword)
|
|
require.NoError(t, err)
|
|
require.True(t, match, "hashed password should match original plain text password")
|
|
})
|
|
|
|
t.Run("already hashed password is not re-hashed", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
// Create a bcrypt hash of a password
|
|
originalPassword := "originalpassword"
|
|
hashedPassword, err := encrypt.Hash(originalPassword)
|
|
require.NoError(t, err)
|
|
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(hashedPassword),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify auth config was stored
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify password was NOT re-hashed (should be the same hash)
|
|
require.Equal(t, hashedPassword, storedAuth.AdminPassword.GetValue(), "already hashed password should not be re-hashed")
|
|
|
|
// Verify the stored hash still works to verify the original password
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), originalPassword)
|
|
require.NoError(t, err)
|
|
require.True(t, match, "stored hash should still verify against original password")
|
|
})
|
|
|
|
t.Run("empty password is not hashed", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(""),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify auth config was stored
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify empty password remains empty
|
|
require.Equal(t, "", storedAuth.AdminPassword.GetValue(), "empty password should remain empty")
|
|
})
|
|
|
|
t.Run("file config takes precedence over DB config", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
existingPassword := "$2a$10$existinghashvaluehere1234567890123456789012345678901234"
|
|
mockStore.authConfig = &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("existingadmin"),
|
|
AdminPassword: schemas.NewEnvVar(existingPassword),
|
|
IsEnabled: true,
|
|
}
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("newadmin"),
|
|
AdminPassword: schemas.NewEnvVar("newpassword"),
|
|
IsEnabled: false,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify file config overwrote DB config
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
require.Equal(t, "newadmin", storedAuth.AdminUserName.GetValue(), "username should be overwritten by file config")
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "password should be a bcrypt hash")
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), "newpassword")
|
|
require.NoError(t, err)
|
|
require.True(t, match, "hashed password should match the new file password")
|
|
require.False(t, storedAuth.IsEnabled, "enabled status should be overwritten by file config")
|
|
})
|
|
|
|
t.Run("file config skips update when DB already matches", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
plainPassword := "samepassword"
|
|
hashedPassword, err := encrypt.Hash(plainPassword)
|
|
require.NoError(t, err)
|
|
|
|
mockStore.authConfig = &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("sameadmin"),
|
|
AdminPassword: schemas.NewEnvVar(hashedPassword),
|
|
IsEnabled: true,
|
|
DisableAuthOnInference: false,
|
|
}
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("sameadmin"),
|
|
AdminPassword: schemas.NewEnvVar(plainPassword),
|
|
IsEnabled: true,
|
|
DisableAuthOnInference: false,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify the DB hash was reused (not re-hashed) since values match
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
require.Equal(t, hashedPassword, storedAuth.AdminPassword.GetValue(), "password hash should be unchanged when file matches DB")
|
|
require.Equal(t, "sameadmin", storedAuth.AdminUserName.GetValue(), "username should be unchanged")
|
|
require.True(t, storedAuth.IsEnabled, "enabled status should be unchanged")
|
|
})
|
|
|
|
t.Run("nil auth config in file is skipped", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: nil,
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify no auth config was stored
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedAuth, "no auth config should be stored when file config is nil")
|
|
})
|
|
|
|
t.Run("username from env variable gets resolved", func(t *testing.T) {
|
|
t.Setenv("TEST_ADMIN_USERNAME", "envadmin")
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("env.TEST_ADMIN_USERNAME"),
|
|
AdminPassword: schemas.NewEnvVar("plainpassword"),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify username was resolved from env
|
|
require.Equal(t, "envadmin", storedAuth.AdminUserName.GetValue(), "username should be resolved from env variable")
|
|
require.True(t, storedAuth.AdminUserName.IsFromEnv(), "username should be marked as from env")
|
|
require.Equal(t, "env.TEST_ADMIN_USERNAME", storedAuth.AdminUserName.EnvVar, "env var reference should be preserved")
|
|
|
|
// Verify password was hashed
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "password should be hashed")
|
|
})
|
|
|
|
t.Run("password from env variable gets resolved and hashed", func(t *testing.T) {
|
|
t.Setenv("TEST_ADMIN_PASSWORD", "envpassword123")
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar("env.TEST_ADMIN_PASSWORD"),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify password was resolved from env and hashed
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "password should be a bcrypt hash")
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), "envpassword123")
|
|
require.NoError(t, err)
|
|
require.True(t, match, "hashed password should match the env variable value")
|
|
|
|
// Verify env var reference is preserved after hashing
|
|
require.True(t, storedAuth.AdminPassword.IsFromEnv(), "password should still be marked as from env after hashing")
|
|
require.Equal(t, "env.TEST_ADMIN_PASSWORD", storedAuth.AdminPassword.EnvVar, "password env var reference should be preserved")
|
|
})
|
|
|
|
t.Run("both username and password from env variables", func(t *testing.T) {
|
|
t.Setenv("TEST_ADMIN_USER", "envuser")
|
|
t.Setenv("TEST_ADMIN_PASS", "envpass456")
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("env.TEST_ADMIN_USER"),
|
|
AdminPassword: schemas.NewEnvVar("env.TEST_ADMIN_PASS"),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify username was resolved from env
|
|
require.Equal(t, "envuser", storedAuth.AdminUserName.GetValue(), "username should be resolved from env variable")
|
|
require.True(t, storedAuth.AdminUserName.IsFromEnv(), "username should be marked as from env")
|
|
|
|
// Verify password was resolved from env and hashed
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "password should be a bcrypt hash")
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), "envpass456")
|
|
require.NoError(t, err)
|
|
require.True(t, match, "hashed password should match the env variable value")
|
|
|
|
// Verify env var reference is preserved after hashing
|
|
require.True(t, storedAuth.AdminPassword.IsFromEnv(), "password should still be marked as from env after hashing")
|
|
require.Equal(t, "env.TEST_ADMIN_PASS", storedAuth.AdminPassword.EnvVar, "password env var reference should be preserved")
|
|
})
|
|
|
|
t.Run("env variable not set results in empty value", func(t *testing.T) {
|
|
// Don't set the env variable - it should result in empty value
|
|
mockStore := NewMockConfigStore()
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("env.NONEXISTENT_USERNAME"),
|
|
AdminPassword: schemas.NewEnvVar("env.NONEXISTENT_PASSWORD"),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
|
|
// Verify username is empty but env var reference is preserved
|
|
require.Equal(t, "", storedAuth.AdminUserName.GetValue(), "username should be empty when env var not set")
|
|
require.True(t, storedAuth.AdminUserName.IsFromEnv(), "username should be marked as from env")
|
|
require.Equal(t, "env.NONEXISTENT_USERNAME", storedAuth.AdminUserName.EnvVar, "env var reference should be preserved")
|
|
|
|
// Verify password is empty (not hashed since empty)
|
|
require.Equal(t, "", storedAuth.AdminPassword.GetValue(), "password should be empty when env var not set")
|
|
require.True(t, storedAuth.AdminPassword.IsFromEnv(), "password should be marked as from env")
|
|
require.Equal(t, "env.NONEXISTENT_PASSWORD", storedAuth.AdminPassword.EnvVar, "env var reference should be preserved")
|
|
})
|
|
|
|
t.Run("password change flushes existing sessions", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
oldPassword := "oldpassword"
|
|
hashedOldPassword, err := encrypt.Hash(oldPassword)
|
|
require.NoError(t, err)
|
|
|
|
mockStore.authConfig = &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(hashedOldPassword),
|
|
IsEnabled: true,
|
|
}
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar("newpassword"),
|
|
IsEnabled: true,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify sessions were flushed because password changed
|
|
require.True(t, mockStore.flushSessionsCalled, "sessions should be flushed when password changes")
|
|
|
|
// Verify the new password was hashed and stored
|
|
storedAuth, err := mockStore.GetAuthConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAuth)
|
|
require.True(t, isBcryptHash(storedAuth.AdminPassword.GetValue()), "new password should be a bcrypt hash")
|
|
match, err := encrypt.CompareHash(storedAuth.AdminPassword.GetValue(), "newpassword")
|
|
require.NoError(t, err)
|
|
require.True(t, match, "hashed password should match the new password")
|
|
})
|
|
|
|
t.Run("matching password does not flush sessions", func(t *testing.T) {
|
|
mockStore := NewMockConfigStore()
|
|
plainPassword := "samepassword"
|
|
hashedPassword, err := encrypt.Hash(plainPassword)
|
|
require.NoError(t, err)
|
|
|
|
mockStore.authConfig = &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(hashedPassword),
|
|
IsEnabled: true,
|
|
DisableAuthOnInference: false,
|
|
}
|
|
config := &Config{
|
|
ConfigStore: mockStore,
|
|
}
|
|
configData := &ConfigData{
|
|
AuthConfig: &configstore.AuthConfig{
|
|
AdminUserName: schemas.NewEnvVar("admin"),
|
|
AdminPassword: schemas.NewEnvVar(plainPassword),
|
|
IsEnabled: true,
|
|
DisableAuthOnInference: false,
|
|
},
|
|
}
|
|
|
|
loadAuthConfig(ctx, config, configData)
|
|
|
|
// Verify sessions were NOT flushed because password did not change
|
|
require.False(t, mockStore.flushSessionsCalled, "sessions should not be flushed when password matches")
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// AddProvider Tests
|
|
// =============================================================================
|
|
|
|
// mockConfigStoreAddProvider is a ConfigStore mock that allows controlling AddProvider behavior.
|
|
type mockConfigStoreAddProvider struct {
|
|
MockConfigStore
|
|
addProviderErr error
|
|
}
|
|
|
|
func (m *mockConfigStoreAddProvider) AddProvider(ctx context.Context, provider schemas.ModelProvider, config configstore.ProviderConfig, tx ...*gorm.DB) error {
|
|
if m.addProviderErr != nil {
|
|
return m.addProviderErr
|
|
}
|
|
return m.MockConfigStore.AddProvider(ctx, provider, config, tx...)
|
|
}
|
|
|
|
func TestAddProvider_Success(t *testing.T) {
|
|
initTestLogger()
|
|
cfg := &Config{
|
|
Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
ConfigStore: NewMockConfigStore(),
|
|
}
|
|
|
|
err := cfg.AddProvider(context.Background(), "test-provider", configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if _, exists := cfg.Providers["test-provider"]; !exists {
|
|
t.Fatal("provider should be in the in-memory map after successful add")
|
|
}
|
|
}
|
|
|
|
func TestAddProvider_AlreadyExistsInMemory(t *testing.T) {
|
|
initTestLogger()
|
|
cfg := &Config{
|
|
Providers: map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"test-provider": {},
|
|
},
|
|
ConfigStore: NewMockConfigStore(),
|
|
}
|
|
|
|
err := cfg.AddProvider(context.Background(), "test-provider", configstore.ProviderConfig{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !errors.Is(err, ErrAlreadyExists) {
|
|
t.Fatalf("expected ErrAlreadyExists, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddProvider_AlreadyExistsInDB_SyncsToMemory(t *testing.T) {
|
|
initTestLogger()
|
|
// Simulate: provider exists in DB but not in the in-memory map.
|
|
// This can happen when a previous AddProvider wrote to DB but the process failed
|
|
// before syncing the in-memory state (e.g., UpdateProviderConfig failed after AddProvider).
|
|
mockStore := &mockConfigStoreAddProvider{
|
|
MockConfigStore: *NewMockConfigStore(),
|
|
addProviderErr: configstore.ErrAlreadyExists,
|
|
}
|
|
cfg := &Config{
|
|
Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
ConfigStore: mockStore,
|
|
}
|
|
|
|
config := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}},
|
|
}
|
|
err := cfg.AddProvider(context.Background(), "test-provider", config)
|
|
|
|
// Should return ErrAlreadyExists
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !errors.Is(err, ErrAlreadyExists) {
|
|
t.Fatalf("expected ErrAlreadyExists, got: %v", err)
|
|
}
|
|
|
|
// The provider should be synced to the in-memory map
|
|
// so that subsequent UpdateProviderConfig calls can succeed
|
|
if _, exists := cfg.Providers["test-provider"]; !exists {
|
|
t.Fatal("provider should be synced to in-memory map when DB returns already exists")
|
|
}
|
|
}
|
|
|
|
func TestAddProvider_DBError_DoesNotSyncToMemory(t *testing.T) {
|
|
initTestLogger()
|
|
// Non-duplicate DB errors should NOT add the provider to memory
|
|
mockStore := &mockConfigStoreAddProvider{
|
|
MockConfigStore: *NewMockConfigStore(),
|
|
addProviderErr: errors.New("connection refused"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
ConfigStore: mockStore,
|
|
}
|
|
|
|
err := cfg.AddProvider(context.Background(), "test-provider", configstore.ProviderConfig{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
if _, exists := cfg.Providers["test-provider"]; exists {
|
|
t.Fatal("provider should NOT be in memory when DB returns a non-duplicate error")
|
|
}
|
|
}
|
|
|
|
func TestAddProvider_NilConfigStore_AddsToMemoryOnly(t *testing.T) {
|
|
initTestLogger()
|
|
cfg := &Config{
|
|
Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
ConfigStore: nil,
|
|
}
|
|
|
|
err := cfg.AddProvider(context.Background(), "test-provider", configstore.ProviderConfig{
|
|
Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if _, exists := cfg.Providers["test-provider"]; !exists {
|
|
t.Fatal("provider should be in memory when ConfigStore is nil")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RemoveProvider Tests
|
|
// =============================================================================
|
|
|
|
// mockConfigStoreDeleteProvider is a ConfigStore mock that allows controlling DeleteProvider behavior.
|
|
type mockConfigStoreDeleteProvider struct {
|
|
MockConfigStore
|
|
deleteProviderErr error
|
|
}
|
|
|
|
func (m *mockConfigStoreDeleteProvider) DeleteProvider(ctx context.Context, provider schemas.ModelProvider, tx ...*gorm.DB) error {
|
|
if m.deleteProviderErr != nil {
|
|
return m.deleteProviderErr
|
|
}
|
|
return m.MockConfigStore.DeleteProvider(ctx, provider, tx...)
|
|
}
|
|
|
|
func TestRemoveProvider_Success(t *testing.T) {
|
|
initTestLogger()
|
|
mockStore := NewMockConfigStore()
|
|
mockStore.providers["test-provider"] = configstore.ProviderConfig{}
|
|
cfg := &Config{
|
|
Providers: map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"test-provider": {Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}}},
|
|
},
|
|
ConfigStore: mockStore,
|
|
}
|
|
|
|
err := cfg.RemoveProvider(context.Background(), "test-provider")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if _, exists := cfg.Providers["test-provider"]; exists {
|
|
t.Fatal("provider should be removed from in-memory map after successful delete")
|
|
}
|
|
}
|
|
|
|
func TestRemoveProvider_NotFound(t *testing.T) {
|
|
initTestLogger()
|
|
cfg := &Config{
|
|
Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig),
|
|
ConfigStore: NewMockConfigStore(),
|
|
}
|
|
|
|
err := cfg.RemoveProvider(context.Background(), "nonexistent-provider")
|
|
if err != nil {
|
|
t.Fatalf("expected nil, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveProvider_DBError_DoesNotRemoveFromMemory(t *testing.T) {
|
|
initTestLogger()
|
|
mockStore := &mockConfigStoreDeleteProvider{
|
|
MockConfigStore: *NewMockConfigStore(),
|
|
deleteProviderErr: errors.New("connection refused"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"test-provider": {Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}}},
|
|
},
|
|
ConfigStore: mockStore,
|
|
}
|
|
|
|
err := cfg.RemoveProvider(context.Background(), "test-provider")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
if _, exists := cfg.Providers["test-provider"]; !exists {
|
|
t.Fatal("provider should still be in memory when DB delete fails")
|
|
}
|
|
}
|
|
|
|
func TestRemoveProvider_NilConfigStore_RemovesFromMemoryOnly(t *testing.T) {
|
|
initTestLogger()
|
|
cfg := &Config{
|
|
Providers: map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"test-provider": {Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}}},
|
|
},
|
|
ConfigStore: nil,
|
|
}
|
|
|
|
err := cfg.RemoveProvider(context.Background(), "test-provider")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if _, exists := cfg.Providers["test-provider"]; exists {
|
|
t.Fatal("provider should be removed from memory when ConfigStore is nil")
|
|
}
|
|
}
|
|
|
|
func TestRemoveProvider_SkipDBUpdate(t *testing.T) {
|
|
initTestLogger()
|
|
// When skipDBUpdate is set, DeleteProvider should not be called on the store.
|
|
// Use a mock that would fail if called, proving it was skipped.
|
|
mockStore := &mockConfigStoreDeleteProvider{
|
|
MockConfigStore: *NewMockConfigStore(),
|
|
deleteProviderErr: errors.New("should not be called"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[schemas.ModelProvider]configstore.ProviderConfig{
|
|
"test-provider": {Keys: []schemas.Key{{Value: *schemas.NewEnvVar("test-key")}}},
|
|
},
|
|
ConfigStore: mockStore,
|
|
}
|
|
|
|
ctx := context.WithValue(context.Background(), schemas.BifrostContextKeySkipDBUpdate, true)
|
|
err := cfg.RemoveProvider(ctx, "test-provider")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if _, exists := cfg.Providers["test-provider"]; exists {
|
|
t.Fatal("provider should be removed from memory when skipDBUpdate is true")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetVirtualKeysPaginated SQLite Integration Tests
|
|
// =============================================================================
|
|
|
|
func TestSQLite_GetVirtualKeysPaginated(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := createTestSQLiteConfigStore(t, dir)
|
|
ctx := context.Background()
|
|
|
|
// ID strings for FK references
|
|
team1 := "team-1"
|
|
team2 := "team-2"
|
|
cust1 := "cust-1"
|
|
cust2 := "cust-2"
|
|
|
|
// Create referenced customers and teams first (FK constraints)
|
|
customers := []tables.TableCustomer{
|
|
{ID: cust1, Name: "Customer One"},
|
|
{ID: cust2, Name: "Customer Two"},
|
|
}
|
|
for i := range customers {
|
|
require.NoError(t, store.CreateCustomer(ctx, &customers[i]))
|
|
}
|
|
teams := []tables.TableTeam{
|
|
{ID: team1, Name: "Team One", CustomerID: &cust1},
|
|
{ID: team2, Name: "Team Two", CustomerID: &cust2},
|
|
}
|
|
for i := range teams {
|
|
require.NoError(t, store.CreateTeam(ctx, &teams[i]))
|
|
}
|
|
|
|
vks := []tables.TableVirtualKey{
|
|
{ID: "vk-1", Name: "alpha-key", Value: "val-1", IsActive: true, TeamID: &team1},
|
|
{ID: "vk-2", Name: "beta-key", Value: "val-2", IsActive: true, TeamID: &team2},
|
|
{ID: "vk-3", Name: "alpha-test", Value: "val-3", IsActive: true, CustomerID: &cust1},
|
|
{ID: "vk-4", Name: "gamma-key", Value: "val-4", IsActive: true, CustomerID: &cust2},
|
|
{ID: "vk-5", Name: "delta-key", Value: "val-5", IsActive: true, TeamID: &team1},
|
|
}
|
|
for i := range vks {
|
|
err := store.CreateVirtualKey(ctx, &vks[i])
|
|
require.NoError(t, err, "failed to seed VK %s", vks[i].ID)
|
|
}
|
|
|
|
t.Run("Pagination", func(t *testing.T) {
|
|
// First page: limit=2, offset=0
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
Limit: 2, Offset: 0,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(5), totalCount)
|
|
require.Len(t, results, 2)
|
|
|
|
// Last page: offset=4
|
|
results, totalCount, err = store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
Limit: 2, Offset: 4,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(5), totalCount)
|
|
require.Len(t, results, 1)
|
|
})
|
|
|
|
t.Run("Search", func(t *testing.T) {
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
Search: "alpha",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), totalCount)
|
|
require.Len(t, results, 2)
|
|
for _, vk := range results {
|
|
require.Contains(t, vk.Name, "alpha")
|
|
}
|
|
})
|
|
|
|
t.Run("CustomerID_filter", func(t *testing.T) {
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
CustomerID: "cust-1",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), totalCount)
|
|
require.Len(t, results, 1)
|
|
require.Equal(t, "vk-3", results[0].ID)
|
|
})
|
|
|
|
t.Run("TeamID_filter", func(t *testing.T) {
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
TeamID: "team-1",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), totalCount)
|
|
require.Len(t, results, 2)
|
|
for _, vk := range results {
|
|
require.NotNil(t, vk.TeamID)
|
|
require.Equal(t, "team-1", *vk.TeamID)
|
|
}
|
|
})
|
|
|
|
t.Run("OR_filter_customer_and_team", func(t *testing.T) {
|
|
// When both customer and team are provided, should return VKs matching either
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
CustomerID: "cust-1",
|
|
TeamID: "team-2",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), totalCount)
|
|
require.Len(t, results, 2)
|
|
ids := map[string]bool{}
|
|
for _, vk := range results {
|
|
ids[vk.ID] = true
|
|
}
|
|
require.True(t, ids["vk-2"], "should include team-2 VK")
|
|
require.True(t, ids["vk-3"], "should include cust-1 VK")
|
|
})
|
|
|
|
t.Run("Default_limit", func(t *testing.T) {
|
|
// limit=0 should default to 25
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
Limit: 0,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(5), totalCount)
|
|
require.Len(t, results, 5) // all 5, since <25
|
|
})
|
|
|
|
t.Run("Max_limit_cap", func(t *testing.T) {
|
|
// limit=200 should be capped to 100
|
|
results, totalCount, err := store.GetVirtualKeysPaginated(ctx, configstore.VirtualKeyQueryParams{
|
|
Limit: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(5), totalCount)
|
|
require.Len(t, results, 5) // all 5, since <100
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// LoadConfig Permutation Tests
|
|
// =============================================================================
|
|
// These tests cover all permutations of:
|
|
// - config.json present / absent
|
|
// - Each config section present / absent in config.json
|
|
// - DB data present / absent (first run vs subsequent runs)
|
|
//
|
|
// They exercise LoadConfig() as the public entry point and verify
|
|
// both in-memory state and DB persistence.
|
|
// =============================================================================
|
|
|
|
// makeMinimalConfigData creates a ConfigData with only config_store configured (SQLite)
|
|
func makeMinimalConfigData(tempDir string) *ConfigData {
|
|
dbPath := filepath.Join(tempDir, "config.db")
|
|
return &ConfigData{
|
|
ConfigStoreConfig: &configstore.Config{
|
|
Enabled: true,
|
|
Type: configstore.ConfigStoreTypeSQLite,
|
|
Config: &configstore.SQLiteConfig{
|
|
Path: dbPath,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// assertDefaultClientConfigValues checks that client config matches DefaultClientConfig
|
|
func assertDefaultClientConfigValues(t *testing.T, cc configstore.ClientConfig) {
|
|
t.Helper()
|
|
require.Equal(t, false, cc.DropExcessRequests, "DropExcessRequests should default to false")
|
|
require.Equal(t, schemas.DefaultInitialPoolSize, cc.InitialPoolSize, "InitialPoolSize should match default")
|
|
require.NotNil(t, cc.EnableLogging, "EnableLogging should not be nil")
|
|
require.Equal(t, true, *cc.EnableLogging, "EnableLogging should default to true")
|
|
require.Equal(t, false, cc.DisableContentLogging, "DisableContentLogging should default to false")
|
|
require.Equal(t, false, cc.EnforceAuthOnInference, "EnforceAuthOnInference should default to false")
|
|
require.Equal(t, false, cc.AllowDirectKeys, "AllowDirectKeys should default to false")
|
|
require.Equal(t, []string{"*"}, cc.AllowedOrigins, "AllowedOrigins should default to [*]")
|
|
require.Equal(t, 100, cc.MaxRequestBodySizeMB, "MaxRequestBodySizeMB should default to 100")
|
|
require.Equal(t, 10, cc.MCPAgentDepth, "MCPAgentDepth should default to 10")
|
|
require.Equal(t, 30, cc.MCPToolExecutionTimeout, "MCPToolExecutionTimeout should default to 30")
|
|
require.Equal(t, false, cc.Compat.ConvertTextToChat, "Compat.ConvertTextToChat should default to false")
|
|
require.Equal(t, false, cc.Compat.ConvertChatToResponses, "Compat.ConvertChatToResponses should default to false")
|
|
require.Equal(t, false, cc.Compat.ShouldDropParams, "Compat.ShouldDropParams should default to false")
|
|
require.Equal(t, false, cc.Compat.ShouldConvertParams, "Compat.ShouldConvertParams should default to false")
|
|
require.Equal(t, false, cc.HideDeletedVirtualKeysInFilters, "HideDeletedVirtualKeysInFilters should default to false")
|
|
}
|
|
|
|
// TestLoadConfig_NoConfigFile_FreshStart tests LoadConfig with no config.json and no existing DB
|
|
func TestLoadConfig_NoConfigFile_FreshStart(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify config store was created (default SQLite)
|
|
require.NotNil(t, config.ConfigStore, "ConfigStore should be created by default")
|
|
|
|
// Verify default client config
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
|
|
// HeaderMatcher is nil when no header filter is configured (DefaultClientConfig has nil HeaderFilterConfig)
|
|
// This is expected behavior - it's only set when HeaderFilterConfig is non-nil
|
|
|
|
// Verify providers map initialized (may be empty or auto-detected from env)
|
|
require.NotNil(t, config.Providers, "Providers map should be initialized")
|
|
|
|
// Verify governance/MCP are nil or empty (no config file)
|
|
// MCP and governance may be nil when no config and no DB data
|
|
// Plugins should be empty
|
|
require.Empty(t, config.PluginConfigs, "PluginConfigs should be empty with no config")
|
|
|
|
// Verify WebSocket defaults
|
|
require.NotNil(t, config.WebSocketConfig, "WebSocketConfig should have defaults")
|
|
|
|
// Verify KV store initialized
|
|
require.NotNil(t, config.KVStore, "KVStore should be initialized")
|
|
}
|
|
|
|
// TestLoadConfig_NoConfigFile_ExistingDB tests LoadConfig with no config.json but existing DB from previous run
|
|
func TestLoadConfig_NoConfigFile_ExistingDB(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// First run: create a config.json to populate the DB
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key-1", "sk-test-123"),
|
|
}
|
|
configData := makeConfigDataWithProvidersAndDir(providers, tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1M"},
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config1)
|
|
|
|
// Verify first load populated DB
|
|
dbProviders, err := config1.ConfigStore.GetProvidersConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, dbProviders, 1, "DB should have 1 provider after first load")
|
|
config1.Close(ctx)
|
|
|
|
// Remove config.json to simulate "no config file" on second run
|
|
require.NoError(t, os.Remove(filepath.Join(tempDir, "config.json")))
|
|
|
|
// Second run: no config.json, but DB has data
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config2)
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify DB data was loaded (provider preserved from first run)
|
|
require.Len(t, config2.Providers, 1, "Provider from DB should be preserved")
|
|
_, hasOpenAI := config2.Providers[schemas.OpenAI]
|
|
require.True(t, hasOpenAI, "OpenAI provider should be loaded from DB")
|
|
|
|
// Verify governance loaded from DB
|
|
require.NotNil(t, config2.GovernanceConfig, "GovernanceConfig should be loaded from DB")
|
|
require.Len(t, config2.GovernanceConfig.Budgets, 1, "Budget from DB should be preserved")
|
|
}
|
|
|
|
// TestLoadConfig_FullConfigFile_FreshDB tests LoadConfig with all sections in config.json
|
|
func TestLoadConfig_FullConfigFile_FreshDB(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
providers := map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key-1", "sk-openai-123"),
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "anthropic-key-1", Value: *schemas.NewEnvVar("sk-anthropic-123"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
budgetID := "budget-1"
|
|
governance := &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: budgetID, MaxLimit: 100.0, ResetDuration: "1M"},
|
|
},
|
|
VirtualKeys: []tables.TableVirtualKey{
|
|
makeVirtualKey("vk-1", "test-vk", "vk_test123"),
|
|
},
|
|
}
|
|
|
|
clientConfig := makeClientConfig(20, true)
|
|
configData := makeConfigDataFullWithDir(clientConfig, providers, governance, tempDir)
|
|
|
|
// Add plugins
|
|
pluginVersion := int16(1)
|
|
configData.Plugins = []*schemas.PluginConfig{
|
|
{Name: "test-plugin", Enabled: true, Version: &pluginVersion},
|
|
}
|
|
|
|
// Add MCP
|
|
configData.MCP = &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{ID: uuid.NewString(), Name: "mcp_client_1", ConnectionType: schemas.MCPConnectionTypeHTTP, ConnectionString: schemas.NewEnvVar("http://localhost:8080")},
|
|
},
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify all sections loaded
|
|
require.NotNil(t, config.ConfigStore, "ConfigStore should be initialized")
|
|
require.Equal(t, 20, config.ClientConfig.InitialPoolSize, "Client config InitialPoolSize from file")
|
|
require.Len(t, config.Providers, 2, "Should have 2 providers")
|
|
require.NotNil(t, config.GovernanceConfig, "GovernanceConfig should be loaded")
|
|
require.Len(t, config.GovernanceConfig.Budgets, 1, "Should have 1 budget")
|
|
require.Len(t, config.GovernanceConfig.VirtualKeys, 1, "Should have 1 virtual key")
|
|
require.NotNil(t, config.MCPConfig, "MCPConfig should be loaded")
|
|
require.Len(t, config.MCPConfig.ClientConfigs, 1, "Should have 1 MCP client")
|
|
require.Len(t, config.PluginConfigs, 1, "Should have 1 plugin")
|
|
require.Equal(t, "test-plugin", config.PluginConfigs[0].Name)
|
|
|
|
// Verify persisted to DB
|
|
dbProviders, err := config.ConfigStore.GetProvidersConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, dbProviders, 2, "DB should have 2 providers")
|
|
|
|
dbVK := verifyVirtualKeyInDB(t, config.ConfigStore, "vk-1")
|
|
require.Equal(t, "test-vk", dbVK.Name)
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_OnlyProviders tests config.json with only providers section
|
|
func TestLoadConfig_PartialConfigFile_OnlyProviders(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.Providers = map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key-1", "sk-test-123"),
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify providers loaded from file
|
|
require.Len(t, config.Providers, 1, "Should have 1 provider from file")
|
|
_, hasOpenAI := config.Providers[schemas.OpenAI]
|
|
require.True(t, hasOpenAI, "OpenAI should be loaded from file")
|
|
|
|
// Verify client config gets defaults (no client in file)
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
|
|
// Verify other sections are nil/empty
|
|
require.Empty(t, config.PluginConfigs, "Plugins should be empty")
|
|
|
|
// Verify WebSocket defaults applied
|
|
require.NotNil(t, config.WebSocketConfig, "WebSocketConfig should have defaults")
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_OnlyClient tests config.json with only client section
|
|
func TestLoadConfig_PartialConfigFile_OnlyClient(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.Client = &configstore.ClientConfig{
|
|
InitialPoolSize: 50,
|
|
EnableLogging: new(false),
|
|
MaxRequestBodySizeMB: 200,
|
|
AllowedOrigins: []string{"http://example.com"},
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify client config from file
|
|
require.Equal(t, 50, config.ClientConfig.InitialPoolSize, "InitialPoolSize from file")
|
|
require.NotNil(t, config.ClientConfig.EnableLogging, "EnableLogging should not be nil")
|
|
require.Equal(t, false, *config.ClientConfig.EnableLogging, "EnableLogging from file")
|
|
require.Equal(t, 200, config.ClientConfig.MaxRequestBodySizeMB, "MaxRequestBodySizeMB from file")
|
|
|
|
// Verify providers auto-detected (no providers in file)
|
|
// (may be empty if no env vars set, that's fine)
|
|
require.NotNil(t, config.Providers, "Providers map should be initialized")
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_OnlyGovernance tests config.json with only governance section
|
|
func TestLoadConfig_PartialConfigFile_OnlyGovernance(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.Governance = &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 500.0, ResetDuration: "1M"},
|
|
},
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify governance loaded from file
|
|
require.NotNil(t, config.GovernanceConfig, "GovernanceConfig should be loaded")
|
|
require.Len(t, config.GovernanceConfig.Budgets, 1, "Should have 1 budget")
|
|
require.Equal(t, 500.0, config.GovernanceConfig.Budgets[0].MaxLimit)
|
|
|
|
// Verify client config gets defaults
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_OnlyPlugins tests config.json with only plugins section
|
|
func TestLoadConfig_PartialConfigFile_OnlyPlugins(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
pluginVersion := int16(1)
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.Plugins = []*schemas.PluginConfig{
|
|
{Name: "my-plugin", Enabled: true, Version: &pluginVersion},
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify plugins loaded from file
|
|
require.Len(t, config.PluginConfigs, 1, "Should have 1 plugin")
|
|
require.Equal(t, "my-plugin", config.PluginConfigs[0].Name)
|
|
|
|
// Verify client gets defaults
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_OnlyMCP tests config.json with only MCP section
|
|
func TestLoadConfig_PartialConfigFile_OnlyMCP(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.MCP = &schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{ID: uuid.NewString(), Name: "mcp_test", ConnectionType: schemas.MCPConnectionTypeHTTP, ConnectionString: schemas.NewEnvVar("http://localhost:9090")},
|
|
},
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify MCP loaded from file
|
|
require.NotNil(t, config.MCPConfig, "MCPConfig should be loaded")
|
|
require.Len(t, config.MCPConfig.ClientConfigs, 1, "Should have 1 MCP client")
|
|
require.Equal(t, "mcp_test", config.MCPConfig.ClientConfigs[0].Name)
|
|
|
|
// Verify client gets defaults
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_ClientAndProviders tests the most common minimal config
|
|
func TestLoadConfig_PartialConfigFile_ClientAndProviders(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
configData.Client = &configstore.ClientConfig{
|
|
InitialPoolSize: 100,
|
|
EnableLogging: new(true),
|
|
MaxRequestBodySizeMB: 50,
|
|
AllowedOrigins: []string{"*"},
|
|
}
|
|
configData.Providers = map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key", "sk-openai-abc"),
|
|
"anthropic": makeProviderConfig("anthropic-key", "sk-anthropic-def"),
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify both sections loaded
|
|
require.Equal(t, 100, config.ClientConfig.InitialPoolSize)
|
|
require.Len(t, config.Providers, 2, "Should have 2 providers")
|
|
|
|
// Verify other sections empty/nil
|
|
require.Empty(t, config.PluginConfigs, "Plugins should be empty")
|
|
}
|
|
|
|
// TestLoadConfig_ConfigFile_NoConfigStoreSection tests config.json without config_store section
|
|
func TestLoadConfig_ConfigFile_NoConfigStoreSection(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Create config.json without config_store section
|
|
configData := &ConfigData{
|
|
Providers: map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key", "sk-test-123"),
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// ConfigStore should be created as default SQLite when config_store section is absent
|
|
require.NotNil(t, config.ConfigStore, "ConfigStore should be created as default SQLite when section is absent")
|
|
|
|
// Verify providers are loaded into memory
|
|
require.Len(t, config.Providers, 1, "Provider should be loaded into memory")
|
|
_, hasOpenAI := config.Providers[schemas.OpenAI]
|
|
require.True(t, hasOpenAI, "OpenAI should be loaded")
|
|
}
|
|
|
|
// TestLoadConfig_ConfigFile_ConfigStoreDisabled tests config.json with config_store explicitly disabled
|
|
func TestLoadConfig_ConfigFile_ConfigStoreDisabled(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := &ConfigData{
|
|
ConfigStoreConfig: &configstore.Config{
|
|
Enabled: false,
|
|
},
|
|
Providers: map[string]configstore.ProviderConfig{
|
|
"openai": makeProviderConfig("openai-key", "sk-test-456"),
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// ConfigStore should be nil when explicitly disabled
|
|
require.Nil(t, config.ConfigStore, "ConfigStore should be nil when disabled")
|
|
|
|
// Providers should still be loaded into memory
|
|
require.Len(t, config.Providers, 1, "Provider should be loaded into memory")
|
|
}
|
|
|
|
// TestLoadConfig_NoConfigFile_SecondRun tests that DB data persists across runs without config.json
|
|
func TestLoadConfig_NoConfigFile_SecondRun(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// Clear auto-detect environment variables to ensure deterministic test behavior
|
|
autoDetectEnvVars := []string{"OPENAI_API_KEY", "OPENAI_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_KEY", "MISTRAL_API_KEY", "MISTRAL_KEY"}
|
|
for _, envVar := range autoDetectEnvVars {
|
|
if orig := os.Getenv(envVar); orig != "" {
|
|
t.Setenv(envVar, "")
|
|
}
|
|
}
|
|
|
|
// First run: no config.json -> auto-detect and create defaults
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config1)
|
|
|
|
// Manually add a provider to DB to simulate dashboard addition
|
|
testProvider := configstore.ProviderConfig{
|
|
Keys: []schemas.Key{
|
|
{ID: uuid.NewString(), Name: "manual-key", Value: *schemas.NewEnvVar("sk-manual-123"), Weight: 1},
|
|
},
|
|
}
|
|
err = config1.ConfigStore.AddProvider(ctx, schemas.OpenAI, testProvider)
|
|
require.NoError(t, err)
|
|
config1.Close(ctx)
|
|
|
|
// Second run: still no config.json -> should load from DB
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config2)
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify the manually added provider is preserved from DB
|
|
dbProviders, err := config2.ConfigStore.GetProvidersConfig(ctx)
|
|
require.NoError(t, err)
|
|
_, hasOpenAI := dbProviders[schemas.OpenAI]
|
|
require.True(t, hasOpenAI, "Provider added via dashboard should be preserved in DB")
|
|
}
|
|
|
|
// TestLoadConfig_PartialConfigFile_WithExistingDB tests partial config.json update with existing DB
|
|
func TestLoadConfig_PartialConfigFile_WithExistingDB(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// First run: full config.json
|
|
providers1 := map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: "openai-key-1", Name: "openai-key", Value: *schemas.NewEnvVar("test-openai-key"), Weight: 1},
|
|
},
|
|
},
|
|
"anthropic": {
|
|
Keys: []schemas.Key{
|
|
{ID: "anthropic-key-1", Name: "anthropic-key", Value: *schemas.NewEnvVar("test-anthropic-key"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
governance1 := &configstore.GovernanceConfig{
|
|
Budgets: []tables.TableBudget{
|
|
{ID: "budget-1", MaxLimit: 100.0, ResetDuration: "1M"},
|
|
},
|
|
}
|
|
configData1 := makeConfigDataFullWithDir(nil, providers1, governance1, tempDir)
|
|
createConfigFile(t, tempDir, configData1)
|
|
|
|
config1, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, config1.Providers, 2, "Should have 2 providers from first load")
|
|
require.NotNil(t, config1.GovernanceConfig)
|
|
config1.Close(ctx)
|
|
|
|
// Second run: config.json with only changed providers (no governance section)
|
|
configData2 := makeMinimalConfigData(tempDir)
|
|
configData2.Providers = map[string]configstore.ProviderConfig{
|
|
"openai": {
|
|
Keys: []schemas.Key{
|
|
{ID: "openai-key-1", Name: "openai-key", Value: *schemas.NewEnvVar("test-openai-key-updated"), Weight: 1},
|
|
},
|
|
},
|
|
}
|
|
// Note: no governance section in this config.json
|
|
createConfigFile(t, tempDir, configData2)
|
|
|
|
config2, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config2)
|
|
defer config2.Close(ctx)
|
|
|
|
// Verify providers updated (only openai now from file)
|
|
require.Contains(t, config2.Providers, schemas.OpenAI, "OpenAI should be present")
|
|
|
|
// Verify governance preserved from DB (not wiped by missing section in file)
|
|
require.NotNil(t, config2.GovernanceConfig, "Governance should be preserved from DB")
|
|
require.Len(t, config2.GovernanceConfig.Budgets, 1, "Budget should be preserved from DB")
|
|
|
|
_, hasAnthropic := config2.Providers[schemas.Anthropic]
|
|
require.True(t, hasAnthropic, "Anthropic should be preserved from DB")
|
|
}
|
|
|
|
// TestLoadConfig_WebSocket_Defaults tests WebSocket default handling
|
|
func TestLoadConfig_WebSocket_Defaults(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
t.Run("no websocket section gets defaults", func(t *testing.T) {
|
|
configData := makeMinimalConfigData(tempDir)
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
require.NotNil(t, config.WebSocketConfig, "WebSocketConfig should be set with defaults")
|
|
})
|
|
}
|
|
|
|
// TestLoadConfig_DefaultClientConfig_Values tests that all DefaultClientConfig values are correct
|
|
func TestLoadConfig_DefaultClientConfig_Values(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
// No config.json -> defaults applied
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
assertDefaultClientConfigValues(t, *config.ClientConfig)
|
|
}
|
|
|
|
// TestLoadConfig_PartialClientConfig_DefaultsFillGaps tests that missing client fields get defaults
|
|
func TestLoadConfig_PartialClientConfig_DefaultsFillGaps(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
configData := makeMinimalConfigData(tempDir)
|
|
// Only set InitialPoolSize, leave MaxRequestBodySizeMB as 0 (should get default)
|
|
configData.Client = &configstore.ClientConfig{
|
|
InitialPoolSize: 50,
|
|
EnableLogging: new(true),
|
|
AllowedOrigins: []string{"http://myapp.com"},
|
|
// MaxRequestBodySizeMB is 0 -> should get default 100
|
|
}
|
|
|
|
createConfigFile(t, tempDir, configData)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// Verify explicit values from file
|
|
require.Equal(t, 50, config.ClientConfig.InitialPoolSize, "InitialPoolSize from file")
|
|
require.NotNil(t, config.ClientConfig.EnableLogging, "EnableLogging should not be nil")
|
|
require.Equal(t, true, *config.ClientConfig.EnableLogging, "EnableLogging from file")
|
|
|
|
// Verify zero-value fields get defaults
|
|
require.Equal(t, DefaultClientConfig.MaxRequestBodySizeMB, config.ClientConfig.MaxRequestBodySizeMB,
|
|
"MaxRequestBodySizeMB should get default when zero in file")
|
|
}
|
|
|
|
// =============================================================================
|
|
// applyV1Compat unit tests
|
|
// =============================================================================
|
|
|
|
// makeV1ProviderKey is a helper that builds a schemas.Key for compat tests.
|
|
func makeV1ProviderKey(name string, models schemas.WhiteList) schemas.Key {
|
|
return schemas.Key{
|
|
Name: name,
|
|
Value: *schemas.NewEnvVar("env.SOME_API_KEY"),
|
|
Models: models,
|
|
Weight: 1.0,
|
|
}
|
|
}
|
|
|
|
// makeV1ProviderConfig builds a minimal configstore.ProviderConfig with the given keys.
|
|
func makeV1ProviderConfig(keys ...schemas.Key) configstore.ProviderConfig {
|
|
return configstore.ProviderConfig{Keys: keys}
|
|
}
|
|
|
|
// makeV1ConfigData is a convenience constructor for compat tests.
|
|
func makeV1ConfigData(
|
|
providers map[string]configstore.ProviderConfig,
|
|
mcp *schemas.MCPConfig,
|
|
vks []tables.TableVirtualKey,
|
|
) *ConfigData {
|
|
cd := &ConfigData{
|
|
Version: 1,
|
|
Providers: providers,
|
|
MCP: mcp,
|
|
}
|
|
if len(vks) > 0 {
|
|
cd.Governance = &configstore.GovernanceConfig{VirtualKeys: vks}
|
|
}
|
|
return cd
|
|
}
|
|
|
|
// TestApplyV1Compat_ProviderKey_EmptyModels verifies that nil and [] models are
|
|
// both normalised to ["*"].
|
|
func TestApplyV1Compat_ProviderKey_EmptyModels(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(
|
|
makeV1ProviderKey("nil-models", nil),
|
|
makeV1ProviderKey("empty-models", schemas.WhiteList{}),
|
|
),
|
|
},
|
|
nil, nil,
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
for _, key := range cd.Providers["openai"].Keys {
|
|
require.Equal(t, schemas.WhiteList{"*"}, key.Models,
|
|
"key %q: expected models to be normalized to [\"*\"]", key.Name)
|
|
}
|
|
}
|
|
|
|
// TestApplyV1Compat_ProviderKey_WildcardUnchanged checks that a key already using
|
|
// ["*"] is left untouched.
|
|
func TestApplyV1Compat_ProviderKey_WildcardUnchanged(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("wildcard", schemas.WhiteList{"*"})),
|
|
},
|
|
nil, nil,
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
require.Equal(t, schemas.WhiteList{"*"}, cd.Providers["openai"].Keys[0].Models)
|
|
}
|
|
|
|
// TestApplyV1Compat_ProviderKey_ExplicitUnchanged ensures that a specific model
|
|
// list is not altered.
|
|
func TestApplyV1Compat_ProviderKey_ExplicitUnchanged(t *testing.T) {
|
|
models := schemas.WhiteList{"gpt-4o", "gpt-4o-mini"}
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("specific", models)),
|
|
},
|
|
nil, nil,
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
require.Equal(t, models, cd.Providers["openai"].Keys[0].Models)
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_EmptyProviderConfigs verifies that a VK with no
|
|
// provider_configs gets one entry per configured provider, each with
|
|
// AllowedModels: ["*"] and AllowAllKeys: true.
|
|
func TestApplyV1Compat_VK_EmptyProviderConfigs(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil)),
|
|
"anthropic": makeV1ProviderConfig(makeV1ProviderKey("k2", nil)),
|
|
},
|
|
nil,
|
|
[]tables.TableVirtualKey{
|
|
{ID: "vk-1", Name: "All Access", ProviderConfigs: []tables.TableVirtualKeyProviderConfig{}},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
vk := cd.Governance.VirtualKeys[0]
|
|
require.Len(t, vk.ProviderConfigs, 2, "expected one entry per configured provider")
|
|
|
|
for _, pc := range vk.ProviderConfigs {
|
|
require.Equal(t, schemas.WhiteList{"*"}, pc.AllowedModels,
|
|
"provider %q: AllowedModels should be [\"*\"]", pc.Provider)
|
|
require.True(t, pc.AllowAllKeys,
|
|
"provider %q: AllowAllKeys should be true", pc.Provider)
|
|
}
|
|
|
|
// Providers present in the backfill must match the configured providers.
|
|
backfilledProviders := make(map[string]bool)
|
|
for _, pc := range vk.ProviderConfigs {
|
|
backfilledProviders[pc.Provider] = true
|
|
}
|
|
require.True(t, backfilledProviders["openai"])
|
|
require.True(t, backfilledProviders["anthropic"])
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_ProviderConfig_EmptyAllowedModels checks that an existing
|
|
// provider config entry with allowed_models: [] gets normalised to ["*"].
|
|
func TestApplyV1Compat_VK_ProviderConfig_EmptyAllowedModels(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
nil,
|
|
[]tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "Restricted",
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{Provider: "openai", AllowedModels: schemas.WhiteList{}, AllowAllKeys: true},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
pc := cd.Governance.VirtualKeys[0].ProviderConfigs[0]
|
|
require.Equal(t, schemas.WhiteList{"*"}, pc.AllowedModels)
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_ProviderConfig_EmptyKeyIDs verifies that a provider config
|
|
// with no keys and AllowAllKeys=false gets AllowAllKeys set to true.
|
|
func TestApplyV1Compat_VK_ProviderConfig_EmptyKeyIDs(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
nil,
|
|
[]tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "No Keys",
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{Provider: "openai", AllowedModels: schemas.WhiteList{"*"}, AllowAllKeys: false, Keys: nil},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
pc := cd.Governance.VirtualKeys[0].ProviderConfigs[0]
|
|
require.True(t, pc.AllowAllKeys, "AllowAllKeys should be set to true when Keys is empty")
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_ProviderConfig_AlreadyAllowAll ensures a provider config
|
|
// that already has AllowAllKeys=true is left unchanged.
|
|
func TestApplyV1Compat_VK_ProviderConfig_AlreadyAllowAll(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
nil,
|
|
[]tables.TableVirtualKey{
|
|
{
|
|
ID: "vk-1",
|
|
Name: "Already OK",
|
|
ProviderConfigs: []tables.TableVirtualKeyProviderConfig{
|
|
{Provider: "openai", AllowedModels: schemas.WhiteList{"*"}, AllowAllKeys: true},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
pc := cd.Governance.VirtualKeys[0].ProviderConfigs[0]
|
|
require.True(t, pc.AllowAllKeys)
|
|
require.Equal(t, schemas.WhiteList{"*"}, pc.AllowedModels)
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_EmptyMCPConfigs verifies that a VK with no mcp_configs
|
|
// gets one entry per configured MCP client, each with ToolsToExecute: ["*"].
|
|
func TestApplyV1Compat_VK_EmptyMCPConfigs(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
&schemas.MCPConfig{
|
|
ClientConfigs: []*schemas.MCPClientConfig{
|
|
{Name: "tools-a"},
|
|
{Name: "tools-b"},
|
|
},
|
|
},
|
|
[]tables.TableVirtualKey{
|
|
{ID: "vk-1", Name: "No MCP", MCPConfigs: []tables.TableVirtualKeyMCPConfig{}},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
vk := cd.Governance.VirtualKeys[0]
|
|
require.Len(t, vk.MCPConfigs, 2, "expected one entry per configured MCP client")
|
|
|
|
for _, mc := range vk.MCPConfigs {
|
|
require.Equal(t, schemas.WhiteList{"*"}, mc.ToolsToExecute,
|
|
"MCP client %q: ToolsToExecute should be [\"*\"]", mc.MCPClientName)
|
|
}
|
|
|
|
names := make(map[string]bool)
|
|
for _, mc := range vk.MCPConfigs {
|
|
names[mc.MCPClientName] = true
|
|
}
|
|
require.True(t, names["tools-a"])
|
|
require.True(t, names["tools-b"])
|
|
}
|
|
|
|
// TestApplyV1Compat_VK_NonEmptyMCPConfigs confirms that a VK with an existing
|
|
// mcp_configs list is not modified.
|
|
func TestApplyV1Compat_VK_NonEmptyMCPConfigs(t *testing.T) {
|
|
existing := []tables.TableVirtualKeyMCPConfig{
|
|
{MCPClientName: "tools-a", ToolsToExecute: schemas.WhiteList{"tool1"}},
|
|
}
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
&schemas.MCPConfig{ClientConfigs: []*schemas.MCPClientConfig{{Name: "tools-a"}, {Name: "tools-b"}}},
|
|
[]tables.TableVirtualKey{
|
|
{ID: "vk-1", Name: "Has MCP", MCPConfigs: existing},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
// Non-empty mcp_configs must be left alone — no backfill.
|
|
require.Len(t, cd.Governance.VirtualKeys[0].MCPConfigs, 1)
|
|
require.Equal(t, schemas.WhiteList{"tool1"}, cd.Governance.VirtualKeys[0].MCPConfigs[0].ToolsToExecute)
|
|
}
|
|
|
|
// TestApplyV1Compat_NoGovernance verifies the function does not panic when the
|
|
// governance section is absent.
|
|
func TestApplyV1Compat_NoGovernance(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil)),
|
|
},
|
|
nil, nil,
|
|
)
|
|
|
|
require.NotPanics(t, func() { applyV1Compat(cd) })
|
|
require.Equal(t, schemas.WhiteList{"*"}, cd.Providers["openai"].Keys[0].Models)
|
|
}
|
|
|
|
// TestApplyV1Compat_NoMCP verifies that an empty mcp_configs on a VK is NOT
|
|
// backfilled when the top-level mcp section is absent.
|
|
func TestApplyV1Compat_NoMCP(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", nil))},
|
|
nil, // no MCP config
|
|
[]tables.TableVirtualKey{
|
|
{ID: "vk-1", Name: "No MCP Section", MCPConfigs: []tables.TableVirtualKeyMCPConfig{}},
|
|
},
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
require.Empty(t, cd.Governance.VirtualKeys[0].MCPConfigs,
|
|
"MCPConfigs should remain empty when no MCP clients are configured")
|
|
}
|
|
|
|
// TestApplyV1Compat_MultipleProviders ensures all providers are normalised in a
|
|
// single pass, even when some already have wildcard models.
|
|
func TestApplyV1Compat_MultipleProviders(t *testing.T) {
|
|
cd := makeV1ConfigData(
|
|
map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(
|
|
makeV1ProviderKey("empty", schemas.WhiteList{}),
|
|
makeV1ProviderKey("nil", nil),
|
|
),
|
|
"anthropic": makeV1ProviderConfig(
|
|
makeV1ProviderKey("wildcard", schemas.WhiteList{"*"}),
|
|
makeV1ProviderKey("specific", schemas.WhiteList{"claude-3-5-sonnet-20241022"}),
|
|
),
|
|
},
|
|
nil, nil,
|
|
)
|
|
|
|
applyV1Compat(cd)
|
|
|
|
for _, k := range cd.Providers["openai"].Keys {
|
|
require.Equal(t, schemas.WhiteList{"*"}, k.Models, "openai key %q should be [*]", k.Name)
|
|
}
|
|
require.Equal(t, schemas.WhiteList{"*"}, cd.Providers["anthropic"].Keys[0].Models, "wildcard unchanged")
|
|
require.Equal(t, schemas.WhiteList{"claude-3-5-sonnet-20241022"}, cd.Providers["anthropic"].Keys[1].Models, "specific unchanged")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Version field JSON parsing + integration with LoadConfig
|
|
// =============================================================================
|
|
|
|
// TestVersionField_ParsedFromJSON verifies that the version field is correctly
|
|
// read from a config.json file.
|
|
func TestVersionField_ParsedFromJSON(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
json string
|
|
wantVer int
|
|
}{
|
|
{`{"version": 1, "providers": {}}`, 1},
|
|
{`{"version": 2, "providers": {}}`, 2},
|
|
{`{"providers": {}}`, 0}, // omitted → zero value
|
|
} {
|
|
var cd ConfigData
|
|
require.NoError(t, json.Unmarshal([]byte(tc.json), &cd))
|
|
require.Equal(t, tc.wantVer, cd.Version, "input: %s", tc.json)
|
|
}
|
|
}
|
|
|
|
// TestVersionField_DefaultBehavior verifies that when version is omitted (or 2),
|
|
// provider key models are NOT normalised — empty stays empty.
|
|
func TestVersionField_DefaultBehavior(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
cd := &ConfigData{
|
|
// Version intentionally omitted — defaults to 0, treated as v2
|
|
Providers: map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", schemas.WhiteList{})),
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, cd)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
// v2 semantics: empty models stays empty (deny all) — must NOT be promoted to ["*"]
|
|
openaiCfg, ok := config.Providers[schemas.OpenAI]
|
|
require.True(t, ok, "openai provider should be present")
|
|
require.Len(t, openaiCfg.Keys, 1)
|
|
require.Empty(t, openaiCfg.Keys[0].Models,
|
|
"v2 semantics: empty models must NOT be normalised to [\"*\"]")
|
|
}
|
|
|
|
// TestVersionField_Version1_AppliesCompat verifies that version: 1 in config.json
|
|
// causes empty provider key models to be promoted to ["*"] before the config is
|
|
// ingested into the store.
|
|
func TestVersionField_Version1_AppliesCompat(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
cd := &ConfigData{
|
|
Version: 1,
|
|
Providers: map[string]configstore.ProviderConfig{
|
|
"openai": makeV1ProviderConfig(makeV1ProviderKey("k1", schemas.WhiteList{})),
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, cd)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
openaiCfg, ok := config.Providers[schemas.OpenAI]
|
|
require.True(t, ok, "openai provider should be present")
|
|
require.Len(t, openaiCfg.Keys, 1)
|
|
require.Equal(t, schemas.WhiteList{"*"}, openaiCfg.Keys[0].Models,
|
|
"v1 semantics: empty models must be normalised to [\"*\"]")
|
|
}
|
|
|
|
// TestVersionField_Version2_NoCompat verifies that an explicit version: 2 also
|
|
// skips normalisation (same as omitting the field).
|
|
func TestVersionField_Version2_NoCompat(t *testing.T) {
|
|
initTestLogger()
|
|
tempDir := createTempDir(t)
|
|
ctx := context.Background()
|
|
|
|
cd := &ConfigData{
|
|
Version: 2,
|
|
Providers: map[string]configstore.ProviderConfig{
|
|
"anthropic": makeV1ProviderConfig(makeV1ProviderKey("k1", schemas.WhiteList{})),
|
|
},
|
|
}
|
|
createConfigFile(t, tempDir, cd)
|
|
|
|
config, err := LoadConfig(ctx, tempDir)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
defer config.Close(ctx)
|
|
|
|
anthropicCfg, ok := config.Providers[schemas.Anthropic]
|
|
require.True(t, ok, "anthropic provider should be present")
|
|
require.Len(t, anthropicCfg.Keys, 1)
|
|
require.Empty(t, anthropicCfg.Keys[0].Models,
|
|
"v2 semantics: empty models must NOT be normalised")
|
|
}
|