|
| 1 | +package config |
| 2 | + |
| 3 | +import ( |
| 4 | + "strings" |
| 5 | + "testing" |
| 6 | +) |
| 7 | + |
| 8 | +// clearEnv unsets every env var Load reads so each test starts from a known |
| 9 | +// blank slate. t.Setenv restores values on cleanup, but it does not unset |
| 10 | +// pre-existing process env, so we explicitly clear first. |
| 11 | +func clearEnv(t *testing.T) { |
| 12 | + t.Helper() |
| 13 | + for _, k := range []string{ |
| 14 | + "DATABASE_URL", "REDIS_URL", "PROVISIONER_ADDR", "PROVISIONER_SECRET", |
| 15 | + "EMAIL_PROVIDER", "BREVO_API_KEY", "BREVO_TEMPLATE_IDS", "BREVO_SENDER_EMAIL", |
| 16 | + "BREVO_SENDER_NAME", "SES_AWS_REGION", "SES_AWS_ACCESS_KEY_ID", |
| 17 | + "SES_AWS_SECRET_ACCESS_KEY", "SES_FROM_EMAIL", "SES_TEMPLATE_NAMES", |
| 18 | + "ENVIRONMENT", "MAXMIND_LICENSE_KEY", "GEOLITE2_DB_PATH", "PLANS_PATH", |
| 19 | + "OBJECT_STORE_ENDPOINT", "OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", |
| 20 | + "OBJECT_STORE_BUCKET", "OBJECT_STORE_REGION", "OBJECT_STORE_SECURE", |
| 21 | + "MINIO_ENDPOINT", "MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "MINIO_BUCKET_NAME", |
| 22 | + "KUBE_NAMESPACE_APPS", "INSTANT_API_INTERNAL_URL", "WORKER_INTERNAL_JWT_SECRET", |
| 23 | + "AES_KEY", "OBJECT_STORE_BACKEND", "BACKUP_S3_BUCKET", "BACKUP_S3_PATH_PREFIX", |
| 24 | + "PLATFORM_BACKUP_S3_PREFIX", "CUSTOMER_DATABASE_URL", "MONGO_ADMIN_URI", |
| 25 | + "CUSTOMER_REDIS_URL", |
| 26 | + } { |
| 27 | + // Setenv("") then unset semantics: t.Setenv records the original and |
| 28 | + // restores it; setting to "" is enough since Load treats "" as unset. |
| 29 | + t.Setenv(k, "") |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +func TestErrMissingConfig_Error(t *testing.T) { |
| 34 | + err := &ErrMissingConfig{Key: "DATABASE_URL"} |
| 35 | + got := err.Error() |
| 36 | + if !strings.Contains(got, "DATABASE_URL") { |
| 37 | + t.Fatalf("error message missing key: %q", got) |
| 38 | + } |
| 39 | + if !strings.Contains(got, "not set") { |
| 40 | + t.Fatalf("unexpected error message: %q", got) |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +func TestGetenv(t *testing.T) { |
| 45 | + t.Setenv("CFG_TEST_KEY", "value") |
| 46 | + if got := getenv("CFG_TEST_KEY", "fb"); got != "value" { |
| 47 | + t.Fatalf("getenv set: got %q want value", got) |
| 48 | + } |
| 49 | + t.Setenv("CFG_TEST_KEY", "") |
| 50 | + if got := getenv("CFG_TEST_KEY", "fallback"); got != "fallback" { |
| 51 | + t.Fatalf("getenv empty: got %q want fallback", got) |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +func TestRequire_Panics(t *testing.T) { |
| 56 | + t.Setenv("CFG_REQ_KEY", "") |
| 57 | + defer func() { |
| 58 | + r := recover() |
| 59 | + if r == nil { |
| 60 | + t.Fatal("require did not panic on missing key") |
| 61 | + } |
| 62 | + e, ok := r.(*ErrMissingConfig) |
| 63 | + if !ok { |
| 64 | + t.Fatalf("panic value type = %T, want *ErrMissingConfig", r) |
| 65 | + } |
| 66 | + if e.Key != "CFG_REQ_KEY" { |
| 67 | + t.Fatalf("panic key = %q", e.Key) |
| 68 | + } |
| 69 | + }() |
| 70 | + require("CFG_REQ_KEY") |
| 71 | +} |
| 72 | + |
| 73 | +func TestRequire_Present(t *testing.T) { |
| 74 | + t.Setenv("CFG_REQ_KEY", "x") |
| 75 | + if got := require("CFG_REQ_KEY"); got != "x" { |
| 76 | + t.Fatalf("require present: got %q", got) |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +func TestLoad_Defaults(t *testing.T) { |
| 81 | + clearEnv(t) |
| 82 | + t.Setenv("DATABASE_URL", "postgres://localhost/db") |
| 83 | + |
| 84 | + cfg := Load() |
| 85 | + |
| 86 | + if cfg.DatabaseURL != "postgres://localhost/db" { |
| 87 | + t.Errorf("DatabaseURL = %q", cfg.DatabaseURL) |
| 88 | + } |
| 89 | + if cfg.RedisURL != "redis://localhost:6379" { |
| 90 | + t.Errorf("RedisURL default = %q", cfg.RedisURL) |
| 91 | + } |
| 92 | + if cfg.Environment != "development" { |
| 93 | + t.Errorf("Environment default = %q", cfg.Environment) |
| 94 | + } |
| 95 | + if cfg.GeoLite2DBPath != "./GeoLite2-City.mmdb" { |
| 96 | + t.Errorf("GeoLite2DBPath default = %q", cfg.GeoLite2DBPath) |
| 97 | + } |
| 98 | + if cfg.ObjectStoreBucket != "instant-shared" { |
| 99 | + t.Errorf("ObjectStoreBucket default = %q", cfg.ObjectStoreBucket) |
| 100 | + } |
| 101 | + if cfg.KubeNamespaceApps != "instant-apps" { |
| 102 | + t.Errorf("KubeNamespaceApps default = %q", cfg.KubeNamespaceApps) |
| 103 | + } |
| 104 | + if cfg.ObjectStoreBackend != "minio" { |
| 105 | + t.Errorf("ObjectStoreBackend default = %q", cfg.ObjectStoreBackend) |
| 106 | + } |
| 107 | + if cfg.BackupS3PathPrefix != "backups/" { |
| 108 | + t.Errorf("BackupS3PathPrefix default = %q", cfg.BackupS3PathPrefix) |
| 109 | + } |
| 110 | + if cfg.PlatformBackupS3Prefix != "platform-backups/" { |
| 111 | + t.Errorf("PlatformBackupS3Prefix default = %q", cfg.PlatformBackupS3Prefix) |
| 112 | + } |
| 113 | + // BackupS3Bucket falls back to ObjectStoreBucket. |
| 114 | + if cfg.BackupS3Bucket != "instant-shared" { |
| 115 | + t.Errorf("BackupS3Bucket fallback = %q", cfg.BackupS3Bucket) |
| 116 | + } |
| 117 | + if cfg.ObjectStoreSecure { |
| 118 | + t.Error("ObjectStoreSecure should default false") |
| 119 | + } |
| 120 | + // Empty maps, not nil. |
| 121 | + if cfg.BrevoTemplateIDs == nil || len(cfg.BrevoTemplateIDs) != 0 { |
| 122 | + t.Errorf("BrevoTemplateIDs = %v", cfg.BrevoTemplateIDs) |
| 123 | + } |
| 124 | + if cfg.SESTemplateNames == nil || len(cfg.SESTemplateNames) != 0 { |
| 125 | + t.Errorf("SESTemplateNames = %v", cfg.SESTemplateNames) |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +func TestLoad_PanicsWithoutDatabaseURL(t *testing.T) { |
| 130 | + clearEnv(t) |
| 131 | + defer func() { |
| 132 | + if recover() == nil { |
| 133 | + t.Fatal("Load did not panic without DATABASE_URL") |
| 134 | + } |
| 135 | + }() |
| 136 | + Load() |
| 137 | +} |
| 138 | + |
| 139 | +func TestLoad_AllOverrides(t *testing.T) { |
| 140 | + clearEnv(t) |
| 141 | + t.Setenv("DATABASE_URL", "postgres://db") |
| 142 | + t.Setenv("REDIS_URL", "redis://r:6379") |
| 143 | + t.Setenv("PROVISIONER_ADDR", "prov:50051") |
| 144 | + t.Setenv("PROVISIONER_SECRET", "psecret") |
| 145 | + t.Setenv("EMAIL_PROVIDER", "brevo") |
| 146 | + t.Setenv("BREVO_API_KEY", "bkey") |
| 147 | + t.Setenv("BREVO_TEMPLATE_IDS", `{"a.kind":12,"b.kind":7}`) |
| 148 | + t.Setenv("BREVO_SENDER_EMAIL", "no@x.dev") |
| 149 | + t.Setenv("BREVO_SENDER_NAME", "X") |
| 150 | + t.Setenv("SES_AWS_REGION", "us-east-1") |
| 151 | + t.Setenv("SES_AWS_ACCESS_KEY_ID", "AK") |
| 152 | + t.Setenv("SES_AWS_SECRET_ACCESS_KEY", "SK") |
| 153 | + t.Setenv("SES_FROM_EMAIL", "from@x.dev") |
| 154 | + t.Setenv("SES_TEMPLATE_NAMES", `{"a.kind":"tmpl-v1"}`) |
| 155 | + t.Setenv("ENVIRONMENT", "production") |
| 156 | + t.Setenv("MAXMIND_LICENSE_KEY", "mm") |
| 157 | + t.Setenv("GEOLITE2_DB_PATH", "/data/geo.mmdb") |
| 158 | + t.Setenv("PLANS_PATH", "/etc/plans.yaml") |
| 159 | + t.Setenv("OBJECT_STORE_ENDPOINT", "nyc3.do.com") |
| 160 | + t.Setenv("OBJECT_STORE_ACCESS_KEY", "oak") |
| 161 | + t.Setenv("OBJECT_STORE_SECRET_KEY", "osk") |
| 162 | + t.Setenv("OBJECT_STORE_BUCKET", "mybucket") |
| 163 | + t.Setenv("OBJECT_STORE_REGION", "nyc3") |
| 164 | + t.Setenv("OBJECT_STORE_SECURE", "true") |
| 165 | + t.Setenv("KUBE_NAMESPACE_APPS", "myapps") |
| 166 | + t.Setenv("INSTANT_API_INTERNAL_URL", "http://api") |
| 167 | + t.Setenv("WORKER_INTERNAL_JWT_SECRET", "wjwt") |
| 168 | + t.Setenv("AES_KEY", "deadbeef") |
| 169 | + t.Setenv("OBJECT_STORE_BACKEND", "do-spaces") |
| 170 | + t.Setenv("BACKUP_S3_BUCKET", "backups-bkt") |
| 171 | + t.Setenv("BACKUP_S3_PATH_PREFIX", "bk/") |
| 172 | + t.Setenv("PLATFORM_BACKUP_S3_PREFIX", "plat/") |
| 173 | + t.Setenv("CUSTOMER_DATABASE_URL", "postgres://cust") |
| 174 | + t.Setenv("MONGO_ADMIN_URI", "mongodb://admin") |
| 175 | + t.Setenv("CUSTOMER_REDIS_URL", "redis://cust") |
| 176 | + |
| 177 | + cfg := Load() |
| 178 | + |
| 179 | + if cfg.RedisURL != "redis://r:6379" { |
| 180 | + t.Errorf("RedisURL = %q", cfg.RedisURL) |
| 181 | + } |
| 182 | + if cfg.ProvisionerAddr != "prov:50051" || cfg.ProvisionerSecret != "psecret" { |
| 183 | + t.Errorf("provisioner = %q / %q", cfg.ProvisionerAddr, cfg.ProvisionerSecret) |
| 184 | + } |
| 185 | + if cfg.EmailProvider != "brevo" || cfg.BrevoAPIKey != "bkey" { |
| 186 | + t.Errorf("brevo = %q / %q", cfg.EmailProvider, cfg.BrevoAPIKey) |
| 187 | + } |
| 188 | + if cfg.BrevoTemplateIDs["a.kind"] != 12 || cfg.BrevoTemplateIDs["b.kind"] != 7 { |
| 189 | + t.Errorf("BrevoTemplateIDs = %v", cfg.BrevoTemplateIDs) |
| 190 | + } |
| 191 | + if cfg.SESTemplateNames["a.kind"] != "tmpl-v1" { |
| 192 | + t.Errorf("SESTemplateNames = %v", cfg.SESTemplateNames) |
| 193 | + } |
| 194 | + if cfg.Environment != "production" { |
| 195 | + t.Errorf("Environment = %q", cfg.Environment) |
| 196 | + } |
| 197 | + if cfg.GeoLite2DBPath != "/data/geo.mmdb" || cfg.PlansPath != "/etc/plans.yaml" { |
| 198 | + t.Errorf("geo/plans = %q / %q", cfg.GeoLite2DBPath, cfg.PlansPath) |
| 199 | + } |
| 200 | + if !cfg.ObjectStoreSecure { |
| 201 | + t.Error("ObjectStoreSecure should be true") |
| 202 | + } |
| 203 | + if cfg.ObjectStoreBucket != "mybucket" { |
| 204 | + t.Errorf("ObjectStoreBucket = %q", cfg.ObjectStoreBucket) |
| 205 | + } |
| 206 | + if cfg.ObjectStoreBackend != "do-spaces" { |
| 207 | + t.Errorf("ObjectStoreBackend = %q", cfg.ObjectStoreBackend) |
| 208 | + } |
| 209 | + if cfg.BackupS3Bucket != "backups-bkt" { |
| 210 | + t.Errorf("BackupS3Bucket = %q", cfg.BackupS3Bucket) |
| 211 | + } |
| 212 | + if cfg.BackupS3PathPrefix != "bk/" || cfg.PlatformBackupS3Prefix != "plat/" { |
| 213 | + t.Errorf("backup prefixes = %q / %q", cfg.BackupS3PathPrefix, cfg.PlatformBackupS3Prefix) |
| 214 | + } |
| 215 | + if cfg.AESKey != "deadbeef" { |
| 216 | + t.Errorf("AESKey = %q", cfg.AESKey) |
| 217 | + } |
| 218 | + if cfg.CustomerDatabaseURL != "postgres://cust" || |
| 219 | + cfg.MongoAdminURI != "mongodb://admin" || |
| 220 | + cfg.CustomerRedisURL != "redis://cust" { |
| 221 | + t.Errorf("customer infra = %q / %q / %q", |
| 222 | + cfg.CustomerDatabaseURL, cfg.MongoAdminURI, cfg.CustomerRedisURL) |
| 223 | + } |
| 224 | + if cfg.InstantAPIInternalURL != "http://api" || cfg.WorkerInternalJWTSecret != "wjwt" { |
| 225 | + t.Errorf("internal api = %q / %q", cfg.InstantAPIInternalURL, cfg.WorkerInternalJWTSecret) |
| 226 | + } |
| 227 | +} |
| 228 | + |
| 229 | +// TestLoad_LegacyMinioFallback exercises every MINIO_* fallback branch when |
| 230 | +// the OBJECT_STORE_* equivalents are unset. |
| 231 | +func TestLoad_LegacyMinioFallback(t *testing.T) { |
| 232 | + clearEnv(t) |
| 233 | + t.Setenv("DATABASE_URL", "postgres://db") |
| 234 | + t.Setenv("MINIO_ENDPOINT", "minio:9000") |
| 235 | + t.Setenv("MINIO_ROOT_USER", "minioadmin") |
| 236 | + t.Setenv("MINIO_ROOT_PASSWORD", "miniopass") |
| 237 | + t.Setenv("MINIO_BUCKET_NAME", "legacy-bucket") |
| 238 | + |
| 239 | + cfg := Load() |
| 240 | + |
| 241 | + if cfg.ObjectStoreEndpoint != "minio:9000" { |
| 242 | + t.Errorf("endpoint fallback = %q", cfg.ObjectStoreEndpoint) |
| 243 | + } |
| 244 | + if cfg.ObjectStoreAccessKey != "minioadmin" { |
| 245 | + t.Errorf("access key fallback = %q", cfg.ObjectStoreAccessKey) |
| 246 | + } |
| 247 | + if cfg.ObjectStoreSecretKey != "miniopass" { |
| 248 | + t.Errorf("secret key fallback = %q", cfg.ObjectStoreSecretKey) |
| 249 | + } |
| 250 | + // ObjectStoreBucket defaults to "instant-shared" then MINIO_BUCKET_NAME wins. |
| 251 | + if cfg.ObjectStoreBucket != "legacy-bucket" { |
| 252 | + t.Errorf("bucket fallback = %q", cfg.ObjectStoreBucket) |
| 253 | + } |
| 254 | +} |
| 255 | + |
| 256 | +// TestLoad_ObjectStoreWinsOverMinio confirms the OBJECT_STORE_* values are NOT |
| 257 | +// overwritten by MINIO_* when both present (the fallback branches are skipped). |
| 258 | +func TestLoad_ObjectStoreWinsOverMinio(t *testing.T) { |
| 259 | + clearEnv(t) |
| 260 | + t.Setenv("DATABASE_URL", "postgres://db") |
| 261 | + t.Setenv("OBJECT_STORE_ENDPOINT", "primary:9000") |
| 262 | + t.Setenv("OBJECT_STORE_ACCESS_KEY", "pak") |
| 263 | + t.Setenv("OBJECT_STORE_SECRET_KEY", "psk") |
| 264 | + t.Setenv("OBJECT_STORE_BUCKET", "primary-bucket") |
| 265 | + t.Setenv("MINIO_ENDPOINT", "minio:9000") |
| 266 | + t.Setenv("MINIO_ROOT_USER", "minioadmin") |
| 267 | + t.Setenv("MINIO_ROOT_PASSWORD", "miniopass") |
| 268 | + t.Setenv("MINIO_BUCKET_NAME", "legacy-bucket") |
| 269 | + |
| 270 | + cfg := Load() |
| 271 | + |
| 272 | + if cfg.ObjectStoreEndpoint != "primary:9000" { |
| 273 | + t.Errorf("endpoint = %q", cfg.ObjectStoreEndpoint) |
| 274 | + } |
| 275 | + if cfg.ObjectStoreAccessKey != "pak" || cfg.ObjectStoreSecretKey != "psk" { |
| 276 | + t.Errorf("keys = %q / %q", cfg.ObjectStoreAccessKey, cfg.ObjectStoreSecretKey) |
| 277 | + } |
| 278 | + // Bucket != "instant-shared" so the MINIO_BUCKET_NAME branch is skipped. |
| 279 | + if cfg.ObjectStoreBucket != "primary-bucket" { |
| 280 | + t.Errorf("bucket = %q", cfg.ObjectStoreBucket) |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +func TestParseBrevoTemplateIDs(t *testing.T) { |
| 285 | + if m := parseBrevoTemplateIDs(""); len(m) != 0 || m == nil { |
| 286 | + t.Errorf("empty = %v", m) |
| 287 | + } |
| 288 | + m := parseBrevoTemplateIDs(`{"x":1,"y":2}`) |
| 289 | + if m["x"] != 1 || m["y"] != 2 { |
| 290 | + t.Errorf("valid = %v", m) |
| 291 | + } |
| 292 | + if bad := parseBrevoTemplateIDs(`{not json`); len(bad) != 0 || bad == nil { |
| 293 | + t.Errorf("malformed should be empty map: %v", bad) |
| 294 | + } |
| 295 | +} |
| 296 | + |
| 297 | +func TestParseSESTemplateNames(t *testing.T) { |
| 298 | + if m := parseSESTemplateNames(""); len(m) != 0 || m == nil { |
| 299 | + t.Errorf("empty = %v", m) |
| 300 | + } |
| 301 | + m := parseSESTemplateNames(`{"x":"tmpl"}`) |
| 302 | + if m["x"] != "tmpl" { |
| 303 | + t.Errorf("valid = %v", m) |
| 304 | + } |
| 305 | + if bad := parseSESTemplateNames(`[]invalid`); len(bad) != 0 || bad == nil { |
| 306 | + t.Errorf("malformed should be empty map: %v", bad) |
| 307 | + } |
| 308 | +} |
0 commit comments