// Copyright (c) 2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package bootstrap import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/probodconfig" ) func mockEnv(env map[string]string) EnvGetter { return func(key string) string { return env[key] } } func requiredEnv() map[string]string { return map[string]string{ "PROBOD_ENCRYPTION_KEY": "test-encryption-key-32-bytes-long", "AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!", "AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo", "OAUTH2_SERVER_SIGNING_KEY": "test-oauth2-signing-key", } } func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) { tests := []struct { name string env map[string]string wantMissing []string }{ { name: "all missing", env: map[string]string{}, wantMissing: []string{"PROBOD_ENCRYPTION_KEY", "AUTH_COOKIE_SECRET", "AUTH_PASSWORD_PEPPER", "OAUTH2_SERVER_SIGNING_KEY"}, }, { name: "missing oauth2 signing key", env: map[string]string{ "PROBOD_ENCRYPTION_KEY": "key", "AUTH_COOKIE_SECRET": "secret", "AUTH_PASSWORD_PEPPER": "pepper", }, wantMissing: []string{"OAUTH2_SERVER_SIGNING_KEY"}, }, { name: "missing encryption key", env: map[string]string{ "AUTH_COOKIE_SECRET": "secret", "AUTH_PASSWORD_PEPPER": "pepper", }, wantMissing: []string{"PROBOD_ENCRYPTION_KEY"}, }, { name: "missing cookie secret", env: map[string]string{ "PROBOD_ENCRYPTION_KEY": "key", "AUTH_PASSWORD_PEPPER": "pepper", }, wantMissing: []string{"AUTH_COOKIE_SECRET"}, }, { name: "slack connector missing required fields", env: map[string]string{ "PROBOD_ENCRYPTION_KEY": "key", "AUTH_COOKIE_SECRET": "secret", "AUTH_PASSWORD_PEPPER": "pepper", "CONNECTOR_SLACK_CLIENT_ID": "client-id", }, wantMissing: []string{"CONNECTOR_SLACK_CLIENT_SECRET", "CONNECTOR_SLACK_SIGNING_SECRET"}, }, { name: "google workspace connector missing required fields", env: map[string]string{ "PROBOD_ENCRYPTION_KEY": "key", "AUTH_COOKIE_SECRET": "secret", "AUTH_PASSWORD_PEPPER": "pepper", "CONNECTOR_GOOGLE_WORKSPACE_CLIENT_ID": "client-id", }, wantMissing: []string{"CONNECTOR_GOOGLE_WORKSPACE_CLIENT_SECRET"}, }, { name: "microsoft 365 connector missing required fields", env: map[string]string{ "PROBOD_ENCRYPTION_KEY": "key", "AUTH_COOKIE_SECRET": "secret", "AUTH_PASSWORD_PEPPER": "pepper", "CONNECTOR_MICROSOFT_365_CLIENT_ID": "client-id", }, wantMissing: []string{"CONNECTOR_MICROSOFT_365_CLIENT_SECRET"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b := NewBuilder(mockEnv(tt.env)) _, err := b.Build() require.Error(t, err) for _, missing := range tt.wantMissing { assert.Contains(t, err.Error(), missing) } }) } } func TestBuilder_Build_Defaults(t *testing.T) { b := NewBuilder(mockEnv(requiredEnv())) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) // Unit config assert.Equal(t, "localhost:8081", cfg.Unit.Metrics.Addr) assert.Equal(t, "localhost:4318", cfg.Unit.Tracing.Addr) assert.Equal(t, 512, cfg.Unit.Tracing.MaxBatchSize) assert.Equal(t, 5, cfg.Unit.Tracing.BatchTimeout) assert.Equal(t, 30, cfg.Unit.Tracing.ExportTimeout) assert.Equal(t, 2048, cfg.Unit.Tracing.MaxQueueSize) // Probod base config assert.Equal(t, "http://localhost:8080", cfg.Probod.BaseURL) assert.Equal(t, "localhost:9222", cfg.Probod.ChromeDPAddr) // API config assert.Equal(t, ":8080", cfg.Probod.Api.Addr) assert.Nil(t, cfg.Probod.Api.ProxyProtocol.TrustedProxies) assert.Equal(t, []string{"http://localhost:8080"}, cfg.Probod.Api.Cors.AllowedOrigins) // PG config assert.Equal(t, "localhost:5432", cfg.Probod.Pg.Addr) assert.Equal(t, "probod", cfg.Probod.Pg.Username) assert.Equal(t, "probod", cfg.Probod.Pg.Password) assert.Equal(t, "probod", cfg.Probod.Pg.Database) assert.Equal(t, int32(100), cfg.Probod.Pg.PoolSize) assert.Equal(t, int32(10), cfg.Probod.Pg.MinPoolSize) assert.Equal(t, 1800, cfg.Probod.Pg.MaxConnIdleTimeSeconds) assert.Equal(t, 3600, cfg.Probod.Pg.MaxConnLifetimeSeconds) assert.Equal(t, 300, cfg.Probod.Pg.MaxConnLifetimeJitterSeconds) assert.Equal(t, 60, cfg.Probod.Pg.HealthCheckPeriodSeconds) assert.False(t, cfg.Probod.Pg.Debug) // Auth config assert.False(t, cfg.Probod.Auth.DisableSignup) assert.Equal(t, 3600, cfg.Probod.Auth.InvitationConfirmationTokenValidity) assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity) assert.Equal(t, 900, cfg.Probod.Auth.MagicLinkTokenValidity) assert.Equal(t, "SSID", cfg.Probod.Auth.Cookie.Name) assert.Equal(t, "localhost", cfg.Probod.Auth.Cookie.Domain) assert.Equal(t, 24, cfg.Probod.Auth.Cookie.Duration) assert.True(t, cfg.Probod.Auth.Cookie.Secure) assert.Equal(t, 1000000, cfg.Probod.Auth.Password.Iterations) // SAML config assert.Equal(t, 604800, cfg.Probod.Auth.SAML.SessionDuration) assert.Equal(t, 0, cfg.Probod.Auth.SAML.CleanupIntervalSeconds) assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds) assert.Equal(t, "8.8.8.8:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr) // Trust center config assert.Equal(t, ":80", cfg.Probod.TrustCenter.HTTPAddr) assert.Equal(t, ":443", cfg.Probod.TrustCenter.HTTPSAddr) assert.Nil(t, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies) // AWS config assert.Equal(t, "us-east-1", cfg.Probod.AWS.Region) assert.Equal(t, "probod", cfg.Probod.AWS.Bucket) assert.False(t, cfg.Probod.AWS.UsePathStyle) // Notifications config assert.Equal(t, "Probo", cfg.Probod.Notifications.Mailer.SenderName) assert.Equal(t, "no-reply@notification.getprobo.com", cfg.Probod.Notifications.Mailer.SenderEmail) assert.Equal(t, "localhost:1025", cfg.Probod.Notifications.Mailer.SMTP.Addr) assert.False(t, cfg.Probod.Notifications.Mailer.SMTP.TLSRequired) assert.Empty(t, cfg.Probod.Notifications.Mailer.SMTP.HelloName) assert.Equal(t, 60, cfg.Probod.Notifications.Mailer.MailerInterval) assert.Equal(t, 60, cfg.Probod.Notifications.Slack.SenderInterval) assert.Empty(t, cfg.Probod.Notifications.Slack.SigningSecret) assert.Equal(t, 5, cfg.Probod.Notifications.Webhook.SenderInterval) assert.Equal(t, 86400, cfg.Probod.Notifications.Webhook.CacheTTL) // Agents tools — Firecrawl empty by default assert.Empty(t, cfg.Probod.Agents.Tools.FirecrawlAPIKey) // Agents config — default assert.Equal(t, "openai", cfg.Probod.Agents.Default.Provider) assert.Equal(t, "gpt-4o", cfg.Probod.Agents.Default.ModelName) assert.Equal(t, new(0.1), cfg.Probod.Agents.Default.Temperature) assert.Equal(t, new(4096), cfg.Probod.Agents.Default.MaxTokens) // Agents config — per-agent overrides are empty (inherit from default) assert.Empty(t, cfg.Probod.Agents.Probo.Provider) assert.Empty(t, cfg.Probod.Agents.Probo.ModelName) assert.Nil(t, cfg.Probod.Agents.Probo.Temperature) assert.Nil(t, cfg.Probod.Agents.Probo.MaxTokens) assert.Empty(t, cfg.Probod.Agents.EvidenceDescriber.Provider) assert.Empty(t, cfg.Probod.Agents.EvidenceDescriber.ModelName) assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.Temperature) assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.MaxTokens) assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.Provider) assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.ModelName) assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.Temperature) assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.Provider) assert.Empty(t, cfg.Probod.Agents.TrackerMapping.ModelName) assert.Nil(t, cfg.Probod.Agents.TrackerMapping.Temperature) assert.Equal(t, new(4096), cfg.Probod.Agents.TrackerMapping.MaxTokens) // Tracker worker tuning — defaults assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 3, cfg.Probod.TrackerMappingWorker.MaxConcurrency) assert.Equal(t, 600, cfg.Probod.TrackerMappingWorker.StaleAfter) assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 2, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency) assert.Equal(t, 600, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 45, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout) assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns) assert.Equal(t, 10, cfg.Probod.ThirdPartyVetting.Interval) assert.Equal(t, 1500, cfg.Probod.ThirdPartyVetting.StaleAfter) assert.Equal(t, 1, cfg.Probod.ThirdPartyVetting.MaxConcurrency) // Custom domains config assert.Equal(t, 3600, cfg.Probod.CustomDomains.RenewalInterval) assert.Equal(t, 30, cfg.Probod.CustomDomains.ProvisionInterval) assert.Equal(t, "custom.getprobo.com", cfg.Probod.CustomDomains.CnameTarget) assert.Equal(t, "8.8.8.8:53", cfg.Probod.CustomDomains.ResolverAddr) assert.Equal(t, "https://acme-v02.api.letsencrypt.org/directory", cfg.Probod.CustomDomains.ACME.Directory) assert.Equal(t, "admin@getprobo.com", cfg.Probod.CustomDomains.ACME.Email) assert.Equal(t, "EC256", cfg.Probod.CustomDomains.ACME.KeyType) // SCIM bridge config assert.Equal(t, 900, cfg.Probod.SCIMBridge.SyncInterval) assert.Equal(t, 30, cfg.Probod.SCIMBridge.PollInterval) // ESign config assert.Equal(t, "http://timestamp.digicert.com", cfg.Probod.ESign.TSAURL) // Branding assert.True(t, cfg.Probod.Branding) // No connectors by default assert.Empty(t, cfg.Probod.Connectors) } func TestBuilder_Build_CustomValues(t *testing.T) { env := requiredEnv() // Unit env["METRICS_ADDR"] = "0.0.0.0:9090" env["TRACING_ADDR"] = "jaeger:4317" env["TRACING_MAX_BATCH_SIZE"] = "1024" // Probod env["PROBOD_BASE_URL"] = "https://app.example.com" env["CHROME_DP_ADDR"] = "chrome:9222" // API env["API_ADDR"] = "0.0.0.0:8080" env["API_CORS_ALLOWED_ORIGINS"] = "https://app.example.com,https://admin.example.com" env["API_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.0.1,10.0.0.2" // PG env["PG_ADDR"] = "postgres.example.com:5432" env["PG_USERNAME"] = "probo" env["PG_PASSWORD"] = "secret123" env["PG_DATABASE"] = "probo_prod" env["PG_POOL_SIZE"] = "200" env["PG_MIN_POOL_SIZE"] = "25" env["PG_MAX_CONN_IDLE_TIME_SECONDS"] = "900" env["PG_MAX_CONN_LIFETIME_SECONDS"] = "7200" env["PG_MAX_CONN_LIFETIME_JITTER_SECONDS"] = "600" env["PG_HEALTH_CHECK_PERIOD_SECONDS"] = "30" env["PG_DEBUG"] = "true" // Auth env["AUTH_DISABLE_SIGNUP"] = "true" env["AUTH_INVITATION_TOKEN_VALIDITY"] = "7200" env["AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800" env["AUTH_MAGIC_LINK_TOKEN_VALIDITY"] = "600" env["AUTH_COOKIE_DOMAIN"] = ".example.com" env["AUTH_COOKIE_DURATION"] = "48" // SAML env["SAML_DOMAIN_VERIFICATION_INTERVAL_SECONDS"] = "120" env["SAML_DOMAIN_VERIFICATION_RESOLVER_ADDR"] = "1.1.1.1:53" // Trust center env["TRUST_CENTER_HTTP_ADDR"] = ":8080" env["TRUST_CENTER_HTTPS_ADDR"] = ":8443" env["TRUST_CENTER_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.1.1,10.0.1.2" // AWS env["AWS_REGION"] = "eu-west-1" env["AWS_BUCKET"] = "probo-files" env["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" env["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" env["AWS_ENDPOINT"] = "https://s3.example.com" env["AWS_USE_PATH_STYLE"] = "true" // Notifications env["WEBHOOK_SENDER_INTERVAL"] = "10" env["WEBHOOK_CACHE_TTL"] = "3600" env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" // Firecrawl env["FIRECRAWL_API_KEY"] = "fc-test-key" // Agents — providers env["OPENAI_API_KEY"] = "sk-test-key" env["ANTHROPIC_API_KEY"] = "sk-ant-test-key" // Agents — default env["AGENT_DEFAULT_PROVIDER"] = "openai" env["AGENT_DEFAULT_MODEL_NAME"] = "gpt-4-turbo" env["AGENT_DEFAULT_TEMPERATURE"] = "0.5" env["AGENT_DEFAULT_MAX_TOKENS"] = "8192" // Agents — evidence-describer override env["AGENT_EVIDENCE_DESCRIBER_PROVIDER"] = "anthropic" env["AGENT_EVIDENCE_DESCRIBER_MODEL_NAME"] = "claude-sonnet-4-20250514" env["AGENT_EVIDENCE_DESCRIBER_TEMPERATURE"] = "0.2" env["AGENT_EVIDENCE_DESCRIBER_MAX_TOKENS"] = "4096" // Agents — third-party-vetter override env["AGENT_THIRD_PARTY_VETTER_PROVIDER"] = "openai" env["AGENT_THIRD_PARTY_VETTER_MODEL_NAME"] = "gpt-4o" env["AGENT_THIRD_PARTY_VETTER_TEMPERATURE"] = "0.3" env["AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"] = "8192" // Agents — tracker-mapping override env["AGENT_TRACKER_MAPPING_PROVIDER"] = "openai" env["AGENT_TRACKER_MAPPING_MODEL_NAME"] = "gpt-4o-mini" env["AGENT_TRACKER_MAPPING_TEMPERATURE"] = "0.1" env["AGENT_TRACKER_MAPPING_MAX_TOKENS"] = "1024" // Tracker worker tuning override env["TRACKER_MAPPING_INTERVAL"] = "20" env["TRACKER_MAPPING_MAX_CONCURRENCY"] = "5" env["TRACKER_MAPPING_STALE_AFTER"] = "1200" env["TRACKER_MAPPING_AGENT_TIMEOUT"] = "30" env["TRACKER_MAPPING_AGENT_MAX_TURNS"] = "6" env["COMMON_PATTERN_ENRICHMENT_INTERVAL"] = "15" env["COMMON_PATTERN_ENRICHMENT_MAX_CONCURRENCY"] = "4" env["COMMON_PATTERN_ENRICHMENT_STALE_AFTER"] = "900" env["COMMON_PATTERN_ENRICHMENT_AGENT_TIMEOUT"] = "50" env["COMMON_PATTERN_ENRICHMENT_AGENT_MAX_TURNS"] = "5" env["THIRD_PARTY_VETTING_INTERVAL"] = "15" env["THIRD_PARTY_VETTING_STALE_AFTER"] = "1800" env["THIRD_PARTY_VETTING_MAX_CONCURRENCY"] = "2" // Custom domains env["CUSTOM_DOMAINS_RESOLVER_ADDR"] = "1.1.1.1:53" env["ACME_ACCOUNT_KEY"] = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----" // SCIM bridge env["SCIM_BRIDGE_SYNC_INTERVAL"] = "1800" env["SCIM_BRIDGE_POLL_INTERVAL"] = "60" // ESign env["ESIGN_TSA_URL"] = "http://custom.tsa.example.com" // Branding env["BRANDING"] = "false" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) // Unit assert.Equal(t, "0.0.0.0:9090", cfg.Unit.Metrics.Addr) assert.Equal(t, "jaeger:4317", cfg.Unit.Tracing.Addr) assert.Equal(t, 1024, cfg.Unit.Tracing.MaxBatchSize) // Probod assert.Equal(t, "https://app.example.com", cfg.Probod.BaseURL) assert.Equal(t, "chrome:9222", cfg.Probod.ChromeDPAddr) // API assert.Equal(t, "0.0.0.0:8080", cfg.Probod.Api.Addr) assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, cfg.Probod.Api.ProxyProtocol.TrustedProxies) assert.Equal(t, []string{"https://app.example.com", "https://admin.example.com"}, cfg.Probod.Api.Cors.AllowedOrigins) // PG assert.Equal(t, "postgres.example.com:5432", cfg.Probod.Pg.Addr) assert.Equal(t, "probo", cfg.Probod.Pg.Username) assert.Equal(t, "secret123", cfg.Probod.Pg.Password) assert.Equal(t, "probo_prod", cfg.Probod.Pg.Database) assert.Equal(t, int32(200), cfg.Probod.Pg.PoolSize) assert.Equal(t, int32(25), cfg.Probod.Pg.MinPoolSize) assert.Equal(t, 900, cfg.Probod.Pg.MaxConnIdleTimeSeconds) assert.Equal(t, 7200, cfg.Probod.Pg.MaxConnLifetimeSeconds) assert.Equal(t, 600, cfg.Probod.Pg.MaxConnLifetimeJitterSeconds) assert.Equal(t, 30, cfg.Probod.Pg.HealthCheckPeriodSeconds) assert.True(t, cfg.Probod.Pg.Debug) // Auth assert.True(t, cfg.Probod.Auth.DisableSignup) assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity) assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity) assert.Equal(t, 600, cfg.Probod.Auth.MagicLinkTokenValidity) assert.Equal(t, ".example.com", cfg.Probod.Auth.Cookie.Domain) assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration) // SAML assert.Equal(t, 120, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds) assert.Equal(t, "1.1.1.1:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr) // Trust center assert.Equal(t, ":8080", cfg.Probod.TrustCenter.HTTPAddr) assert.Equal(t, ":8443", cfg.Probod.TrustCenter.HTTPSAddr) assert.Equal(t, []string{"10.0.1.1", "10.0.1.2"}, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies) // AWS assert.Equal(t, "eu-west-1", cfg.Probod.AWS.Region) assert.Equal(t, "probo-files", cfg.Probod.AWS.Bucket) assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", cfg.Probod.AWS.AccessKeyID) assert.Equal(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", cfg.Probod.AWS.SecretAccessKey) assert.Equal(t, "https://s3.example.com", cfg.Probod.AWS.Endpoint) assert.True(t, cfg.Probod.AWS.UsePathStyle) // Notifications assert.Equal(t, "slack-signing-secret", cfg.Probod.Notifications.Slack.SigningSecret) assert.Equal(t, 10, cfg.Probod.Notifications.Webhook.SenderInterval) assert.Equal(t, 3600, cfg.Probod.Notifications.Webhook.CacheTTL) // Agents tools — Firecrawl assert.Equal(t, "fc-test-key", cfg.Probod.Agents.Tools.FirecrawlAPIKey) // Agents — providers assert.Equal(t, "openai", cfg.Probod.Agents.Providers["openai"].Type) assert.Equal(t, "sk-test-key", cfg.Probod.Agents.Providers["openai"].APIKey) assert.Equal(t, "anthropic", cfg.Probod.Agents.Providers["anthropic"].Type) assert.Equal(t, "sk-ant-test-key", cfg.Probod.Agents.Providers["anthropic"].APIKey) // Agents — default assert.Equal(t, "openai", cfg.Probod.Agents.Default.Provider) assert.Equal(t, "gpt-4-turbo", cfg.Probod.Agents.Default.ModelName) assert.Equal(t, new(0.5), cfg.Probod.Agents.Default.Temperature) assert.Equal(t, new(8192), cfg.Probod.Agents.Default.MaxTokens) // Agents — probo inherits default (no overrides set) assert.Empty(t, cfg.Probod.Agents.Probo.Provider) assert.Empty(t, cfg.Probod.Agents.Probo.ModelName) // Agents — evidence-describer overrides assert.Equal(t, "anthropic", cfg.Probod.Agents.EvidenceDescriber.Provider) assert.Equal(t, "claude-sonnet-4-20250514", cfg.Probod.Agents.EvidenceDescriber.ModelName) assert.Equal(t, new(0.2), cfg.Probod.Agents.EvidenceDescriber.Temperature) assert.Equal(t, new(4096), cfg.Probod.Agents.EvidenceDescriber.MaxTokens) // Agents — third-party-vetter overrides assert.Equal(t, "openai", cfg.Probod.Agents.ThirdPartyVetter.Provider) assert.Equal(t, "gpt-4o", cfg.Probod.Agents.ThirdPartyVetter.ModelName) assert.Equal(t, new(0.3), cfg.Probod.Agents.ThirdPartyVetter.Temperature) assert.Equal(t, new(8192), cfg.Probod.Agents.ThirdPartyVetter.MaxTokens) // Agents — tracker-mapping overrides assert.Equal(t, "openai", cfg.Probod.Agents.TrackerMapping.Provider) assert.Equal(t, "gpt-4o-mini", cfg.Probod.Agents.TrackerMapping.ModelName) assert.Equal(t, new(0.1), cfg.Probod.Agents.TrackerMapping.Temperature) assert.Equal(t, new(1024), cfg.Probod.Agents.TrackerMapping.MaxTokens) // Tracker worker tuning — overrides assert.Equal(t, 20, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 5, cfg.Probod.TrackerMappingWorker.MaxConcurrency) assert.Equal(t, 1200, cfg.Probod.TrackerMappingWorker.StaleAfter) assert.Equal(t, 30, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 6, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 15, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 4, cfg.Probod.CommonPatternEnrichmentWorker.MaxConcurrency) assert.Equal(t, 900, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter) assert.Equal(t, 50, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout) assert.Equal(t, 5, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns) assert.Equal(t, 15, cfg.Probod.ThirdPartyVetting.Interval) assert.Equal(t, 1800, cfg.Probod.ThirdPartyVetting.StaleAfter) assert.Equal(t, 2, cfg.Probod.ThirdPartyVetting.MaxConcurrency) // Custom domains assert.Equal(t, "1.1.1.1:53", cfg.Probod.CustomDomains.ResolverAddr) assert.Equal(t, "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----", cfg.Probod.CustomDomains.ACME.AccountKey) // SCIM bridge assert.Equal(t, 1800, cfg.Probod.SCIMBridge.SyncInterval) assert.Equal(t, 60, cfg.Probod.SCIMBridge.PollInterval) // ESign assert.Equal(t, "http://custom.tsa.example.com", cfg.Probod.ESign.TSAURL) // Branding assert.False(t, cfg.Probod.Branding) } func TestBuilder_Build_GoogleWorkspaceConnector(t *testing.T) { env := requiredEnv() env["CONNECTOR_GOOGLE_WORKSPACE_CLIENT_ID"] = "gw-client-id" env["CONNECTOR_GOOGLE_WORKSPACE_CLIENT_SECRET"] = "gw-client-secret" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Connectors, 1) connector := cfg.Probod.Connectors[0] assert.Equal(t, "GOOGLE_WORKSPACE", connector.Provider) assert.Equal(t, "oauth2", string(connector.Protocol)) rawConfig := connector.RawConfig.(probodconfig.ConnectorConfigOAuth2) assert.Equal(t, "gw-client-id", rawConfig.ClientID) assert.Equal(t, "gw-client-secret", rawConfig.ClientSecret) } func TestBuilder_Build_Microsoft365Connector(t *testing.T) { env := requiredEnv() env["CONNECTOR_MICROSOFT_365_CLIENT_ID"] = "ms365-client-id" env["CONNECTOR_MICROSOFT_365_CLIENT_SECRET"] = "ms365-client-secret" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Connectors, 1) connector := cfg.Probod.Connectors[0] assert.Equal(t, "MICROSOFT_365", connector.Provider) assert.Equal(t, "oauth2", string(connector.Protocol)) rawConfig := connector.RawConfig.(probodconfig.ConnectorConfigOAuth2) assert.Equal(t, "ms365-client-id", rawConfig.ClientID) assert.Equal(t, "ms365-client-secret", rawConfig.ClientSecret) } func TestBuilder_Build_AccessReviewConnectors(t *testing.T) { // All non-Vercel access-review providers added by this PR. Vercel // has its own dedicated test because it carries an additional // CONNECTOR_VERCEL_INTEGRATION_SLUG env var. providers := []string{ "GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY", "ASANA", "NETLIFY", "CLICKUP", "MONDAY", "DATADOG", "ZENDESK", } env := requiredEnv() for _, provider := range providers { env["CONNECTOR_"+provider+"_CLIENT_ID"] = strings.ToLower(provider) + "-id" env["CONNECTOR_"+provider+"_CLIENT_SECRET"] = strings.ToLower(provider) + "-secret" } b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Connectors, len(providers)) byProvider := make(map[string]probodconfig.ConnectorConfig, len(cfg.Probod.Connectors)) for _, c := range cfg.Probod.Connectors { byProvider[c.Provider] = c } for _, provider := range providers { c, ok := byProvider[provider] require.True(t, ok, "missing %s connector", provider) assert.Equal(t, "oauth2", string(c.Protocol)) raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2) assert.NotEmpty(t, raw.ClientID, "%s client-id", provider) assert.NotEmpty(t, raw.ClientSecret, "%s client-secret", provider) assert.Empty(t, raw.IntegrationSlug, "%s should not carry integration-slug", provider) } } func TestBuilder_Build_VercelConnector(t *testing.T) { env := requiredEnv() env["CONNECTOR_VERCEL_CLIENT_ID"] = "vercel-id" env["CONNECTOR_VERCEL_CLIENT_SECRET"] = "vercel-secret" env["CONNECTOR_VERCEL_INTEGRATION_SLUG"] = "probo-app" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Connectors, 1) c := cfg.Probod.Connectors[0] assert.Equal(t, "VERCEL", c.Provider) assert.Equal(t, "oauth2", string(c.Protocol)) raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2) assert.Equal(t, "vercel-id", raw.ClientID) assert.Equal(t, "vercel-secret", raw.ClientSecret) assert.Equal(t, "probo-app", raw.IntegrationSlug) } func TestBuilder_Build_SlackConnector(t *testing.T) { env := requiredEnv() env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id" env["CONNECTOR_SLACK_CLIENT_SECRET"] = "slack-client-secret" env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Connectors, 1) connector := cfg.Probod.Connectors[0] assert.Equal(t, "SLACK", connector.Provider) assert.Equal(t, "oauth2", string(connector.Protocol)) rawConfig := connector.RawConfig.(probodconfig.ConnectorConfigOAuth2) assert.Equal(t, "slack-client-id", rawConfig.ClientID) assert.Equal(t, "slack-client-secret", rawConfig.ClientSecret) rawSettings := connector.RawSettings.(map[string]any) assert.Equal(t, "slack-signing-secret", rawSettings["signing-secret"]) } func TestBuilder_Build_SAMLAutoGeneration(t *testing.T) { b := NewBuilder(mockEnv(requiredEnv())) cfg, err := b.Build() require.NoError(t, err) assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----BEGIN CERTIFICATE-----") assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----END CERTIFICATE-----") assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----") assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----END RSA PRIVATE KEY-----") } func TestBuilder_Build_SAMLFromEnv(t *testing.T) { env := requiredEnv() env["SAML_CERTIFICATE"] = "env-cert" env["SAML_PRIVATE_KEY"] = "env-key" b := NewBuilder(mockEnv(env)) cfg, err := b.Build() require.NoError(t, err) assert.Equal(t, "env-cert", cfg.Probod.Auth.SAML.Certificate) assert.Equal(t, "env-key", cfg.Probod.Auth.SAML.PrivateKey) } func TestBuilder_Build_SAMLPreset(t *testing.T) { b := NewBuilder(mockEnv(requiredEnv())) b.samlCertificate = "preset-cert" b.samlPrivateKey = "preset-key" cfg, err := b.Build() require.NoError(t, err) assert.Equal(t, "preset-cert", cfg.Probod.Auth.SAML.Certificate) assert.Equal(t, "preset-key", cfg.Probod.Auth.SAML.PrivateKey) } func TestBuilder_Build_OAuth2Defaults(t *testing.T) { b := NewBuilder(mockEnv(requiredEnv())) cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) sk := cfg.Probod.Auth.OAuth2Server.SigningKeys[0] assert.Equal(t, "test-oauth2-signing-key", sk.PrivateKey) assert.Equal(t, "default", sk.KID) assert.True(t, sk.Active) assert.Equal(t, 3600, cfg.Probod.Auth.OAuth2Server.AccessTokenDuration) assert.Equal(t, 2592000, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) } func TestBuilder_Build_OAuth2FromEnv(t *testing.T) { env := requiredEnv() env["OAUTH2_SERVER_SIGNING_KEY"] = "env-signing-key" env["OAUTH2_SERVER_SIGNING_KEY_KID"] = "env-kid" env["OAUTH2_SERVER_ACCESS_TOKEN_DURATION"] = "10" env["OAUTH2_SERVER_REFRESH_TOKEN_DURATION"] = "20" env["OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION"] = "30" env["OAUTH2_SERVER_DEVICE_CODE_DURATION"] = "40" b := NewBuilder(mockEnv(env)) cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) sk := cfg.Probod.Auth.OAuth2Server.SigningKeys[0] assert.Equal(t, "env-signing-key", sk.PrivateKey) assert.Equal(t, "env-kid", sk.KID) assert.True(t, sk.Active) assert.Equal(t, 10, cfg.Probod.Auth.OAuth2Server.AccessTokenDuration) assert.Equal(t, 20, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) assert.Equal(t, 30, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) assert.Equal(t, 40, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) } func TestBuilder_Build_OAuth2Preset(t *testing.T) { env := requiredEnv() delete(env, "OAUTH2_SERVER_SIGNING_KEY") b := NewBuilder(mockEnv(env)) b.oauth2SigningKey = "preset-signing-key" cfg, err := b.Build() require.NoError(t, err) require.Len(t, cfg.Probod.Auth.OAuth2Server.SigningKeys, 1) assert.Equal(t, "preset-signing-key", cfg.Probod.Auth.OAuth2Server.SigningKeys[0].PrivateKey) } func TestBuilder_Build_PgCABundleFromEnv(t *testing.T) { env := requiredEnv() env["PG_CA_BUNDLE"] = "test-ca-bundle-content" b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) assert.Equal(t, "test-ca-bundle-content", cfg.Probod.Pg.CACertBundle) } func TestBuilder_Build_PgCABundleFromFile(t *testing.T) { tmpDir := t.TempDir() caFile := filepath.Join(tmpDir, "ca-bundle.pem") err := os.WriteFile(caFile, []byte("ca-bundle-from-file"), 0644) require.NoError(t, err) env := requiredEnv() env["PG_CA_BUNDLE_PATH"] = caFile b := NewBuilder(mockEnv(env)) b.samlCertificate = "test-cert" b.samlPrivateKey = "test-key" cfg, err := b.Build() require.NoError(t, err) assert.Equal(t, "ca-bundle-from-file", cfg.Probod.Pg.CACertBundle) } func TestBuilder_parseOriginsList(t *testing.T) { tests := []struct { name string input string want []string }{ { name: "single origin", input: "http://localhost:8080", want: []string{"http://localhost:8080"}, }, { name: "multiple origins", input: "http://localhost:8080,https://example.com", want: []string{"http://localhost:8080", "https://example.com"}, }, { name: "quoted origins", input: `"http://localhost:8080","https://example.com"`, want: []string{"http://localhost:8080", "https://example.com"}, }, { name: "with spaces", input: "http://localhost:8080 , https://example.com", want: []string{"http://localhost:8080", "https://example.com"}, }, { name: "empty", input: "", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b := NewBuilder(nil) got := b.parseOriginsList(tt.input) assert.Equal(t, tt.want, got) }) } }