Files
bifrost/core/schemas/envvar_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

610 lines
18 KiB
Go

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