Files
bifrost/transports/bifrost-http/lib/config_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

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")
}