package schemas import ( "encoding/json" "os" "testing" ) func TestEnvVar_UnmarshalJSON_DoubleEscapedJSON(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "service account credentials with escaped JSON", input: `"{\"type\":\"service_account\",\"project_id\":\"test-project\"}"`, expected: `{"type":"service_account","project_id":"test-project"}`, }, { name: "nested JSON object with multiple levels of escaping", input: `"{\"key\":\"value\",\"nested\":{\"inner\":\"data\"}}"`, expected: `{"key":"value","nested":{"inner":"data"}}`, }, { name: "JSON with escaped newlines in private key", input: `"{\"private_key\":\"-----BEGIN PRIVATE KEY-----\\nMIIE...\\n-----END PRIVATE KEY-----\\n\"}"`, expected: `{"private_key":"-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"}`, }, { name: "simple string value", input: `"sk-test-api-key-12345"`, expected: "sk-test-api-key-12345", }, { name: "empty string", input: `""`, expected: "", }, { name: "string with special characters", input: `"hello\"world"`, expected: `hello"world`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var envVar EnvVar err := envVar.UnmarshalJSON([]byte(tt.input)) if err != nil { t.Fatalf("UnmarshalJSON failed: %v", err) } if envVar.Val != tt.expected { t.Errorf("Expected Val=%q, got Val=%q", tt.expected, envVar.Val) } if envVar.FromEnv { t.Errorf("Expected FromEnv=false, got FromEnv=true") } }) } } func TestEnvVar_UnmarshalJSON_EnvVarReference(t *testing.T) { // Set up test environment variable os.Setenv("TEST_API_KEY", "actual-api-key-value") defer os.Unsetenv("TEST_API_KEY") tests := []struct { name string input string expectedVal string expectedEnvVar string expectedFromEnv bool }{ { name: "env var reference with value present", input: `"env.TEST_API_KEY"`, expectedVal: "actual-api-key-value", expectedEnvVar: "env.TEST_API_KEY", expectedFromEnv: true, }, { name: "env var reference with missing value", input: `"env.NONEXISTENT_VAR"`, expectedVal: "", expectedEnvVar: "env.NONEXISTENT_VAR", expectedFromEnv: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var envVar EnvVar err := envVar.UnmarshalJSON([]byte(tt.input)) if err != nil { t.Fatalf("UnmarshalJSON failed: %v", err) } if envVar.Val != tt.expectedVal { t.Errorf("Expected Val=%q, got Val=%q", tt.expectedVal, envVar.Val) } if envVar.EnvVar != tt.expectedEnvVar { t.Errorf("Expected EnvVar=%q, got EnvVar=%q", tt.expectedEnvVar, envVar.EnvVar) } if envVar.FromEnv != tt.expectedFromEnv { t.Errorf("Expected FromEnv=%v, got FromEnv=%v", tt.expectedFromEnv, envVar.FromEnv) } }) } } func TestEnvVar_UnmarshalJSON_FullStructure(t *testing.T) { // Test when the input is already an EnvVar JSON object input := `{"value":"my-api-key","env_var":"env.MY_KEY","from_env":true}` var envVar EnvVar err := envVar.UnmarshalJSON([]byte(input)) if err != nil { t.Fatalf("UnmarshalJSON failed: %v", err) } if envVar.Val != "my-api-key" { t.Errorf("Expected Val=%q, got Val=%q", "my-api-key", envVar.Val) } if envVar.EnvVar != "env.MY_KEY" { t.Errorf("Expected EnvVar=%q, got EnvVar=%q", "env.MY_KEY", envVar.EnvVar) } if !envVar.FromEnv { t.Errorf("Expected FromEnv=true, got FromEnv=false") } } func TestNewEnvVar_DoubleEscapedJSON(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "service account credentials with escaped JSON", input: `"{\"type\":\"service_account\",\"project_id\":\"test-project\"}"`, expected: `{"type":"service_account","project_id":"test-project"}`, }, { name: "JSON with escaped newlines", input: `"{\"private_key\":\"-----BEGIN-----\\nDATA\\n-----END-----\\n\"}"`, expected: `{"private_key":"-----BEGIN-----\nDATA\n-----END-----\n"}`, }, { name: "simple string without quotes", input: "sk-test-api-key", expected: "sk-test-api-key", }, { name: "simple string with outer quotes", input: `"sk-test-api-key"`, expected: "sk-test-api-key", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { envVar := NewEnvVar(tt.input) if envVar.Val != tt.expected { t.Errorf("Expected Val=%q, got Val=%q", tt.expected, envVar.Val) } }) } } func TestNewEnvVar_EnvVarReference(t *testing.T) { // Set up test environment variable os.Setenv("TEST_NEW_ENVVAR_KEY", "resolved-value") defer os.Unsetenv("TEST_NEW_ENVVAR_KEY") tests := []struct { name string input string expectedVal string expectedEnvVar string expectedFromEnv bool }{ { name: "env var reference with value present", input: "env.TEST_NEW_ENVVAR_KEY", expectedVal: "resolved-value", expectedEnvVar: "env.TEST_NEW_ENVVAR_KEY", expectedFromEnv: true, }, { name: "env var reference with quotes", input: `"env.TEST_NEW_ENVVAR_KEY"`, expectedVal: "resolved-value", expectedEnvVar: "env.TEST_NEW_ENVVAR_KEY", expectedFromEnv: true, }, { name: "env var reference missing", input: "env.MISSING_VAR", expectedVal: "", expectedEnvVar: "env.MISSING_VAR", expectedFromEnv: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { envVar := NewEnvVar(tt.input) if envVar.Val != tt.expectedVal { t.Errorf("Expected Val=%q, got Val=%q", tt.expectedVal, envVar.Val) } if envVar.EnvVar != tt.expectedEnvVar { t.Errorf("Expected EnvVar=%q, got EnvVar=%q", tt.expectedEnvVar, envVar.EnvVar) } if envVar.FromEnv != tt.expectedFromEnv { t.Errorf("Expected FromEnv=%v, got FromEnv=%v", tt.expectedFromEnv, envVar.FromEnv) } }) } } // TestEnvVar_RealWorldVertexCredentials tests the actual use case that triggered // the double-escaping bug: Vertex AI service account credentials func TestEnvVar_RealWorldVertexCredentials(t *testing.T) { // This simulates what happens when parsing config.json with embedded service account JSON type VertexKeyConfig struct { ProjectID EnvVar `json:"project_id"` Region EnvVar `json:"region"` AuthCredentials EnvVar `json:"auth_credentials"` } jsonInput := `{ "project_id": "my-project", "region": "us-central1", "auth_credentials": "{\"type\":\"service_account\",\"project_id\":\"my-project\",\"private_key_id\":\"abc123\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\nMIIE...\\n-----END PRIVATE KEY-----\\n\",\"client_email\":\"test@my-project.iam.gserviceaccount.com\"}" }` var config VertexKeyConfig err := json.Unmarshal([]byte(jsonInput), &config) if err != nil { t.Fatalf("Failed to unmarshal: %v", err) } // Verify auth_credentials is properly unescaped expectedAuthCreds := `{"type":"service_account","project_id":"my-project","private_key_id":"abc123","private_key":"-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n","client_email":"test@my-project.iam.gserviceaccount.com"}` if config.AuthCredentials.Val != expectedAuthCreds { t.Errorf("AuthCredentials not properly unescaped.\nExpected: %s\nGot: %s", expectedAuthCreds, config.AuthCredentials.Val) } // Verify simple string fields work correctly if config.ProjectID.Val != "my-project" { t.Errorf("Expected ProjectID=%q, got %q", "my-project", config.ProjectID.Val) } if config.Region.Val != "us-central1" { t.Errorf("Expected Region=%q, got %q", "us-central1", config.Region.Val) } } // TestEnvVar_MixedConfigParsing tests parsing a config with both env var references // and embedded JSON credentials func TestEnvVar_MixedConfigParsing(t *testing.T) { os.Setenv("TEST_PROJECT_ID", "env-project-id") defer os.Unsetenv("TEST_PROJECT_ID") type Config struct { ProjectID EnvVar `json:"project_id"` Credentials EnvVar `json:"credentials"` } jsonInput := `{ "project_id": "env.TEST_PROJECT_ID", "credentials": "{\"type\":\"service_account\",\"key\":\"value\"}" }` var config Config err := json.Unmarshal([]byte(jsonInput), &config) if err != nil { t.Fatalf("Failed to unmarshal: %v", err) } // Verify env var reference is resolved if config.ProjectID.Val != "env-project-id" { t.Errorf("Expected ProjectID=%q, got %q", "env-project-id", config.ProjectID.Val) } if !config.ProjectID.FromEnv { t.Errorf("Expected ProjectID.FromEnv=true") } // Verify JSON credentials are properly unescaped expectedCreds := `{"type":"service_account","key":"value"}` if config.Credentials.Val != expectedCreds { t.Errorf("Expected Credentials=%q, got %q", expectedCreds, config.Credentials.Val) } if config.Credentials.FromEnv { t.Errorf("Expected Credentials.FromEnv=false") } } func TestEnvVar_Equals(t *testing.T) { tests := []struct { name string a *EnvVar b *EnvVar expected bool }{ { name: "both nil", a: nil, b: nil, expected: true, }, { name: "first nil", a: nil, b: &EnvVar{Val: "test"}, expected: false, }, { name: "second nil", a: &EnvVar{Val: "test"}, b: nil, expected: false, }, { name: "equal values", a: &EnvVar{Val: "test", EnvVar: "env.TEST", FromEnv: true}, b: &EnvVar{Val: "test", EnvVar: "env.TEST", FromEnv: true}, expected: true, }, { name: "different values", a: &EnvVar{Val: "test1"}, b: &EnvVar{Val: "test2"}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.a.Equals(tt.b) if result != tt.expected { t.Errorf("Expected Equals=%v, got %v", tt.expected, result) } }) } } func TestEnvVar_Redacted(t *testing.T) { tests := []struct { name string input EnvVar expectedVal string }{ { name: "empty value", input: EnvVar{Val: ""}, expectedVal: "", }, { name: "short value (8 chars)", input: EnvVar{Val: "12345678"}, expectedVal: "********", }, { name: "long value", input: EnvVar{Val: "sk-1234567890abcdefghijklmnop"}, expectedVal: "sk-1************************mnop", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.input.Redacted() if result.Val != tt.expectedVal { t.Errorf("Expected Redacted Val=%q, got %q", tt.expectedVal, result.Val) } }) } } func TestEnvVar_IsRedacted(t *testing.T) { tests := []struct { name string input EnvVar expected bool }{ { name: "empty not from env", input: EnvVar{Val: "", FromEnv: false}, expected: false, }, { name: "from env", input: EnvVar{Val: "test", FromEnv: true}, expected: true, }, { name: "short all asterisks", input: EnvVar{Val: "****"}, expected: true, }, { name: "redacted pattern 32 chars", input: EnvVar{Val: "sk-1************************mnop"}, expected: true, }, { name: "normal value", input: EnvVar{Val: "sk-test-key"}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.input.IsRedacted() if result != tt.expected { t.Errorf("Expected IsRedacted=%v, got %v", tt.expected, result) } }) } } // TestEnvVar_IsSet verifies the semantic difference between GetValue() != "" and IsSet(). // IsSet() must return true when the EnvVar references an env var (regardless of whether // that env var has been resolved to a non-empty Val). This is the property that the // BeforeSave hooks rely on so env var references survive persistence. func TestEnvVar_IsSet(t *testing.T) { tests := []struct { name string input *EnvVar expected bool }{ { name: "nil envvar", input: nil, expected: false, }, { name: "completely empty", input: &EnvVar{}, expected: false, }, { name: "only Val set (plain value)", input: &EnvVar{Val: "abc"}, expected: true, }, { name: "only EnvVar reference set (env not resolved on this server)", input: &EnvVar{EnvVar: "env.MISSING", FromEnv: true}, expected: true, }, { name: "Val and EnvVar both set (env was resolved)", input: &EnvVar{Val: "resolved-secret", EnvVar: "env.X", FromEnv: true}, expected: true, }, { name: "FromEnv true but no reference and no value", input: &EnvVar{FromEnv: true}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.input.IsSet(); got != tt.expected { t.Errorf("IsSet() = %v, want %v", got, tt.expected) } }) } } // TestEnvVar_MarshalJSON_AutoRedactsEnvBackedValues verifies that any EnvVar marshaled // to JSON with FromEnv=true is automatically masked, regardless of whether the // surrounding code remembered to call Redacted() explicitly. This is the defense-in-depth // guarantee that prevents env-resolved secrets from leaking through unredacted fields. func TestEnvVar_MarshalJSON_AutoRedactsEnvBackedValues(t *testing.T) { tests := []struct { name string input EnvVar wantValue string wantEnvVar string wantFromEnv bool }{ { name: "env-backed long secret is redacted", input: EnvVar{Val: "sk-1234567890abcdefghijklmnop", EnvVar: "env.OPENAI_API_KEY", FromEnv: true}, wantValue: "sk-1************************mnop", wantEnvVar: "env.OPENAI_API_KEY", wantFromEnv: true, }, { name: "env-backed short secret is fully masked", input: EnvVar{Val: "12345678", EnvVar: "env.SHORT", FromEnv: true}, wantValue: "********", wantEnvVar: "env.SHORT", wantFromEnv: true, }, { name: "env-backed unresolved on this server keeps empty value", input: EnvVar{Val: "", EnvVar: "env.MISSING", FromEnv: true}, wantValue: "", wantEnvVar: "env.MISSING", wantFromEnv: true, }, { name: "plain value (not from env) is NOT redacted", input: EnvVar{Val: "2024-10-21", EnvVar: "", FromEnv: false}, wantValue: "2024-10-21", wantEnvVar: "", wantFromEnv: false, }, { name: "empty plain value passes through", input: EnvVar{Val: "", EnvVar: "", FromEnv: false}, wantValue: "", wantEnvVar: "", wantFromEnv: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.input) if err != nil { t.Fatalf("Marshal failed: %v", err) } var got struct { Value string `json:"value"` EnvVar string `json:"env_var"` FromEnv bool `json:"from_env"` } if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal of marshaled output failed: %v", err) } if got.Value != tt.wantValue { t.Errorf("value: got %q, want %q", got.Value, tt.wantValue) } if got.EnvVar != tt.wantEnvVar { t.Errorf("env_var: got %q, want %q", got.EnvVar, tt.wantEnvVar) } if got.FromEnv != tt.wantFromEnv { t.Errorf("from_env: got %v, want %v", got.FromEnv, tt.wantFromEnv) } }) } } // TestEnvVar_MarshalJSON_DoesNotMutateOriginal ensures the auto-redaction in MarshalJSON // does not mutate the receiver. The inference path calls GetValue() to build the actual // HTTP request to the LLM provider, so the original Val must remain intact. func TestEnvVar_MarshalJSON_DoesNotMutateOriginal(t *testing.T) { original := EnvVar{Val: "real-secret-value", EnvVar: "env.SECRET", FromEnv: true} if _, err := json.Marshal(original); err != nil { t.Fatalf("Marshal failed: %v", err) } if original.Val != "real-secret-value" { t.Errorf("MarshalJSON mutated Val: got %q, want %q", original.Val, "real-secret-value") } if original.GetValue() != "real-secret-value" { t.Errorf("GetValue() returns mutated value: got %q", original.GetValue()) } } // TestEnvVar_MarshalJSON_RoundTripIsRedacted verifies that a marshaled-then-unmarshaled // env-backed EnvVar is recognized as redacted. The merge logic in provider_keys.go relies // on this so it can detect "the UI sent back the same redacted value, don't overwrite". func TestEnvVar_MarshalJSON_RoundTripIsRedacted(t *testing.T) { original := EnvVar{Val: "sk-1234567890abcdefghijklmnop", EnvVar: "env.KEY", FromEnv: true} data, err := json.Marshal(original) if err != nil { t.Fatalf("Marshal failed: %v", err) } var roundTripped EnvVar if err := json.Unmarshal(data, &roundTripped); err != nil { t.Fatalf("Unmarshal failed: %v", err) } if !roundTripped.IsRedacted() { t.Errorf("Round-tripped env-backed value should be IsRedacted, got Val=%q", roundTripped.Val) } if roundTripped.EnvVar != "env.KEY" { t.Errorf("env_var reference lost in round-trip: got %q, want %q", roundTripped.EnvVar, "env.KEY") } } // TestEnvVar_MarshalJSON_DoesNotAffectGetValue is a critical safety net: marshaling an // EnvVar to JSON must NOT change what GetValue() returns. The inference path uses // GetValue() to build outgoing LLM requests; if marshaling were to mutate the value, // every request after a UI fetch would silently start using the redacted mask as the // API key. func TestEnvVar_MarshalJSON_DoesNotAffectGetValue(t *testing.T) { os.Setenv("MY_REAL_API_KEY", "sk-real-secret-1234567890abcdef") defer os.Unsetenv("MY_REAL_API_KEY") ev := NewEnvVar("env.MY_REAL_API_KEY") if ev.GetValue() != "sk-real-secret-1234567890abcdef" { t.Fatalf("setup: GetValue() = %q, want resolved env value", ev.GetValue()) } // Marshaling would redact in the JSON output, but must not touch the in-memory Val. if _, err := json.Marshal(ev); err != nil { t.Fatalf("Marshal failed: %v", err) } if ev.GetValue() != "sk-real-secret-1234567890abcdef" { t.Errorf("GetValue() returns mutated value after MarshalJSON: got %q", ev.GetValue()) } }