|
| 1 | +package config |
| 2 | + |
| 3 | +import ( |
| 4 | + "reflect" |
| 5 | + "testing" |
| 6 | + |
| 7 | + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" |
| 8 | + "gopkg.in/yaml.v3" |
| 9 | +) |
| 10 | + |
| 11 | +func TestCloneForRuntimeNil(t *testing.T) { |
| 12 | + var cfg *Config |
| 13 | + if got := cfg.CloneForRuntime(); got != nil { |
| 14 | + t.Fatalf("CloneForRuntime() = %#v, want nil", got) |
| 15 | + } |
| 16 | +} |
| 17 | + |
| 18 | +func TestCloneForRuntimeDeepCopiesConfig(t *testing.T) { |
| 19 | + cfg := sampleCloneRuntimeConfig() |
| 20 | + |
| 21 | + clone := cfg.CloneForRuntime() |
| 22 | + if clone == nil { |
| 23 | + t.Fatal("CloneForRuntime() = nil") |
| 24 | + } |
| 25 | + if clone == cfg { |
| 26 | + t.Fatal("CloneForRuntime() returned original pointer") |
| 27 | + } |
| 28 | + |
| 29 | + mutateOriginalConfig(cfg) |
| 30 | + |
| 31 | + if clone.Home.Host != "home.local" { |
| 32 | + t.Fatalf("clone.Home.Host = %q, want home.local", clone.Home.Host) |
| 33 | + } |
| 34 | + if clone.APIKeys[0] != "client-key" { |
| 35 | + t.Fatalf("clone.APIKeys[0] = %q, want client-key", clone.APIKeys[0]) |
| 36 | + } |
| 37 | + if clone.OAuthExcludedModels["codex"][0] != "hidden-model" { |
| 38 | + t.Fatalf("clone.OAuthExcludedModels[codex][0] = %q, want hidden-model", clone.OAuthExcludedModels["codex"][0]) |
| 39 | + } |
| 40 | + if clone.OAuthModelAlias["codex"][0].Alias != "client-model" { |
| 41 | + t.Fatalf("clone.OAuthModelAlias[codex][0].Alias = %q, want client-model", clone.OAuthModelAlias["codex"][0].Alias) |
| 42 | + } |
| 43 | + if got := pluginRawScalar(t, clone.Plugins.Configs["sample"].Raw, "mode"); got != "first" { |
| 44 | + t.Fatalf("clone plugin raw mode = %q, want first", got) |
| 45 | + } |
| 46 | + if clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "low" { |
| 47 | + t.Fatalf("clone thinking level = %q, want low", clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0]) |
| 48 | + } |
| 49 | + if got := clone.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "value" { |
| 50 | + t.Fatalf("clone payload object key = %#v, want value", got) |
| 51 | + } |
| 52 | + |
| 53 | + clone.APIKeys[0] = "clone-client-key" |
| 54 | + clone.OAuthExcludedModels["codex"][0] = "clone-hidden-model" |
| 55 | + clone.OAuthModelAlias["codex"][0].Alias = "clone-client-model" |
| 56 | + clone.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "clone-low" |
| 57 | + clone.Payload.Default[0].Params["object"].(map[string]any)["key"] = "clone-value" |
| 58 | + plugin := clone.Plugins.Configs["sample"] |
| 59 | + setPluginRawScalar(t, &plugin.Raw, "mode", "third") |
| 60 | + clone.Plugins.Configs["sample"] = plugin |
| 61 | + |
| 62 | + if cfg.APIKeys[0] != "mutated-client-key" { |
| 63 | + t.Fatalf("cfg.APIKeys[0] = %q, want mutated-client-key", cfg.APIKeys[0]) |
| 64 | + } |
| 65 | + if cfg.OAuthExcludedModels["codex"][0] != "mutated-hidden-model" { |
| 66 | + t.Fatalf("cfg.OAuthExcludedModels[codex][0] = %q, want mutated-hidden-model", cfg.OAuthExcludedModels["codex"][0]) |
| 67 | + } |
| 68 | + if cfg.OAuthModelAlias["codex"][0].Alias != "mutated-client-model" { |
| 69 | + t.Fatalf("cfg.OAuthModelAlias[codex][0].Alias = %q, want mutated-client-model", cfg.OAuthModelAlias["codex"][0].Alias) |
| 70 | + } |
| 71 | + if got := pluginRawScalar(t, cfg.Plugins.Configs["sample"].Raw, "mode"); got != "second" { |
| 72 | + t.Fatalf("cfg plugin raw mode = %q, want second", got) |
| 73 | + } |
| 74 | + if cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] != "mutated-low" { |
| 75 | + t.Fatalf("cfg thinking level = %q, want mutated-low", cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0]) |
| 76 | + } |
| 77 | + if got := cfg.Payload.Default[0].Params["object"].(map[string]any)["key"]; got != "mutated-value" { |
| 78 | + t.Fatalf("cfg payload object key = %#v, want mutated-value", got) |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +func TestCloneForRuntimeDoesNotShareReferenceFields(t *testing.T) { |
| 83 | + cfg := sampleCloneRuntimeConfig() |
| 84 | + clone := cfg.CloneForRuntime() |
| 85 | + |
| 86 | + assertNoSharedRuntimeReferences(t, reflect.ValueOf(cfg), reflect.ValueOf(clone), "Config") |
| 87 | +} |
| 88 | + |
| 89 | +func sampleCloneRuntimeConfig() *Config { |
| 90 | + cacheStrict := true |
| 91 | + bypassStrict := false |
| 92 | + pluginEnabled := false |
| 93 | + cacheUserID := true |
| 94 | + |
| 95 | + return &Config{ |
| 96 | + SDKConfig: SDKConfig{ |
| 97 | + APIKeys: []string{"client-key"}, |
| 98 | + Streaming: StreamingConfig{ |
| 99 | + KeepAliveSeconds: 3, |
| 100 | + BootstrapRetries: 2, |
| 101 | + }, |
| 102 | + }, |
| 103 | + Home: HomeConfig{ |
| 104 | + Enabled: true, |
| 105 | + Host: "home.local", |
| 106 | + Port: 8081, |
| 107 | + TLS: HomeTLSConfig{ |
| 108 | + Enable: true, |
| 109 | + ServerName: "home.local", |
| 110 | + CACert: "ca", |
| 111 | + ClientCert: "cert", |
| 112 | + ClientKey: "key", |
| 113 | + UseTargetServerName: true, |
| 114 | + }, |
| 115 | + }, |
| 116 | + Plugins: PluginsConfig{ |
| 117 | + Enabled: true, |
| 118 | + Dir: "plugins", |
| 119 | + StoreSources: []string{"https://plugins.example/store.json"}, |
| 120 | + Configs: map[string]PluginInstanceConfig{ |
| 121 | + "sample": { |
| 122 | + Enabled: &pluginEnabled, |
| 123 | + Priority: 10, |
| 124 | + Raw: samplePluginRawNode("first"), |
| 125 | + }, |
| 126 | + }, |
| 127 | + }, |
| 128 | + AntigravitySignatureCacheEnabled: &cacheStrict, |
| 129 | + AntigravitySignatureBypassStrict: &bypassStrict, |
| 130 | + GeminiKey: []GeminiKey{{ |
| 131 | + APIKey: "gemini-key", |
| 132 | + Models: []GeminiModel{{Name: "gemini-upstream", Alias: "gemini-client"}}, |
| 133 | + Headers: map[string]string{"X-Gemini": "one"}, |
| 134 | + ExcludedModels: []string{"gemini-hidden"}, |
| 135 | + }}, |
| 136 | + CodexKey: []CodexKey{{ |
| 137 | + APIKey: "codex-key", |
| 138 | + Models: []CodexModel{{Name: "codex-upstream", Alias: "codex-client"}}, |
| 139 | + Headers: map[string]string{"X-Codex": "one"}, |
| 140 | + ExcludedModels: []string{"codex-hidden-key"}, |
| 141 | + }}, |
| 142 | + ClaudeKey: []ClaudeKey{{ |
| 143 | + APIKey: "claude-key", |
| 144 | + Models: []ClaudeModel{{Name: "claude-upstream", Alias: "claude-client"}}, |
| 145 | + Headers: map[string]string{"X-Claude": "one"}, |
| 146 | + ExcludedModels: []string{"claude-hidden"}, |
| 147 | + Cloak: &CloakConfig{ |
| 148 | + SensitiveWords: []string{"secret"}, |
| 149 | + CacheUserID: &cacheUserID, |
| 150 | + }, |
| 151 | + }}, |
| 152 | + OpenAICompatibility: []OpenAICompatibility{{ |
| 153 | + Name: "compat", |
| 154 | + APIKeyEntries: []OpenAICompatibilityAPIKey{{APIKey: "compat-key", ProxyURL: "http://proxy.local"}}, |
| 155 | + Models: []OpenAICompatibilityModel{{ |
| 156 | + Name: "compat-upstream", |
| 157 | + Alias: "compat-client", |
| 158 | + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "high"}}, |
| 159 | + }}, |
| 160 | + Headers: map[string]string{"X-Compat": "one"}, |
| 161 | + }}, |
| 162 | + VertexCompatAPIKey: []VertexCompatKey{{ |
| 163 | + APIKey: "vertex-key", |
| 164 | + Headers: map[string]string{"X-Vertex": "one"}, |
| 165 | + Models: []VertexCompatModel{{Name: "vertex-upstream", Alias: "vertex-client"}}, |
| 166 | + ExcludedModels: []string{"vertex-hidden"}, |
| 167 | + }}, |
| 168 | + OAuthExcludedModels: map[string][]string{ |
| 169 | + "codex": {"hidden-model"}, |
| 170 | + }, |
| 171 | + OAuthModelAlias: map[string][]OAuthModelAlias{ |
| 172 | + "codex": {{Name: "upstream-model", Alias: "client-model", Fork: true}}, |
| 173 | + }, |
| 174 | + Payload: PayloadConfig{ |
| 175 | + Default: []PayloadRule{{ |
| 176 | + Models: []PayloadModelRule{{ |
| 177 | + Name: "model-*", |
| 178 | + Headers: map[string]string{"X-Tier": "gold"}, |
| 179 | + Match: []map[string]any{{"tier": "gold"}}, |
| 180 | + Exist: []string{"$.messages"}, |
| 181 | + }}, |
| 182 | + Params: map[string]any{ |
| 183 | + "object": map[string]any{"key": "value"}, |
| 184 | + "array": []any{"first", map[string]any{"nested": "value"}}, |
| 185 | + }, |
| 186 | + }}, |
| 187 | + Filter: []PayloadFilterRule{{ |
| 188 | + Models: []PayloadModelRule{{Name: "model-*"}}, |
| 189 | + Params: []string{"$.secret"}, |
| 190 | + }}, |
| 191 | + }, |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +func mutateOriginalConfig(cfg *Config) { |
| 196 | + cfg.Home.Host = "mutated-home.local" |
| 197 | + cfg.APIKeys[0] = "mutated-client-key" |
| 198 | + cfg.OAuthExcludedModels["codex"][0] = "mutated-hidden-model" |
| 199 | + cfg.OAuthModelAlias["codex"][0].Alias = "mutated-client-model" |
| 200 | + cfg.OpenAICompatibility[0].Models[0].Thinking.Levels[0] = "mutated-low" |
| 201 | + cfg.Payload.Default[0].Params["object"].(map[string]any)["key"] = "mutated-value" |
| 202 | + plugin := cfg.Plugins.Configs["sample"] |
| 203 | + setPluginRawScalar(nil, &plugin.Raw, "mode", "second") |
| 204 | + cfg.Plugins.Configs["sample"] = plugin |
| 205 | +} |
| 206 | + |
| 207 | +func samplePluginRawNode(mode string) yaml.Node { |
| 208 | + modeValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: mode, Anchor: "modeAnchor"} |
| 209 | + return yaml.Node{ |
| 210 | + Kind: yaml.MappingNode, |
| 211 | + Tag: "!!map", |
| 212 | + Content: []*yaml.Node{ |
| 213 | + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, |
| 214 | + {Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, |
| 215 | + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode"}, |
| 216 | + modeValue, |
| 217 | + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "mode-alias"}, |
| 218 | + {Kind: yaml.AliasNode, Alias: modeValue}, |
| 219 | + }, |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +func pluginRawScalar(t *testing.T, node yaml.Node, key string) string { |
| 224 | + t.Helper() |
| 225 | + for i := 0; i+1 < len(node.Content); i += 2 { |
| 226 | + if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil { |
| 227 | + return node.Content[i+1].Value |
| 228 | + } |
| 229 | + } |
| 230 | + t.Fatalf("raw plugin node missing key %q", key) |
| 231 | + return "" |
| 232 | +} |
| 233 | + |
| 234 | +func setPluginRawScalar(t *testing.T, node *yaml.Node, key, value string) { |
| 235 | + if t != nil { |
| 236 | + t.Helper() |
| 237 | + } |
| 238 | + for i := 0; i+1 < len(node.Content); i += 2 { |
| 239 | + if node.Content[i] != nil && node.Content[i].Value == key && node.Content[i+1] != nil { |
| 240 | + node.Content[i+1].Value = value |
| 241 | + return |
| 242 | + } |
| 243 | + } |
| 244 | + if t != nil { |
| 245 | + t.Fatalf("raw plugin node missing key %q", key) |
| 246 | + } |
| 247 | +} |
| 248 | + |
| 249 | +func assertNoSharedRuntimeReferences(t *testing.T, original, clone reflect.Value, path string) { |
| 250 | + t.Helper() |
| 251 | + if !original.IsValid() || !clone.IsValid() { |
| 252 | + return |
| 253 | + } |
| 254 | + if original.Kind() == reflect.Interface { |
| 255 | + if original.IsNil() || clone.IsNil() { |
| 256 | + return |
| 257 | + } |
| 258 | + assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path) |
| 259 | + return |
| 260 | + } |
| 261 | + if original.Kind() != clone.Kind() { |
| 262 | + t.Fatalf("%s kind mismatch: %s != %s", path, original.Kind(), clone.Kind()) |
| 263 | + } |
| 264 | + |
| 265 | + switch original.Kind() { |
| 266 | + case reflect.Pointer: |
| 267 | + if original.IsNil() || clone.IsNil() { |
| 268 | + return |
| 269 | + } |
| 270 | + if original.Pointer() == clone.Pointer() { |
| 271 | + t.Fatalf("%s shares pointer %x", path, original.Pointer()) |
| 272 | + } |
| 273 | + assertNoSharedRuntimeReferences(t, original.Elem(), clone.Elem(), path+"->"+original.Type().Elem().String()) |
| 274 | + case reflect.Map: |
| 275 | + if original.IsNil() || clone.IsNil() { |
| 276 | + return |
| 277 | + } |
| 278 | + if original.Pointer() == clone.Pointer() { |
| 279 | + t.Fatalf("%s shares map pointer %x", path, original.Pointer()) |
| 280 | + } |
| 281 | + iter := original.MapRange() |
| 282 | + for iter.Next() { |
| 283 | + key := iter.Key() |
| 284 | + assertNoSharedRuntimeReferences(t, iter.Value(), clone.MapIndex(key), path+"["+keyForPath(key)+"]") |
| 285 | + } |
| 286 | + case reflect.Slice: |
| 287 | + if original.IsNil() || clone.IsNil() { |
| 288 | + return |
| 289 | + } |
| 290 | + if original.Pointer() == clone.Pointer() { |
| 291 | + t.Fatalf("%s shares slice pointer %x", path, original.Pointer()) |
| 292 | + } |
| 293 | + for i := 0; i < original.Len(); i++ { |
| 294 | + assertNoSharedRuntimeReferences(t, original.Index(i), clone.Index(i), path+"[]") |
| 295 | + } |
| 296 | + case reflect.Struct: |
| 297 | + for i := 0; i < original.NumField(); i++ { |
| 298 | + field := original.Type().Field(i) |
| 299 | + assertNoSharedRuntimeReferences(t, original.Field(i), clone.Field(i), path+"."+field.Name) |
| 300 | + } |
| 301 | + } |
| 302 | +} |
| 303 | + |
| 304 | +func keyForPath(key reflect.Value) string { |
| 305 | + if key.Kind() == reflect.String { |
| 306 | + return key.String() |
| 307 | + } |
| 308 | + return key.Type().String() |
| 309 | +} |
0 commit comments