package config_test import ( "os" "path/filepath" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "github.com/travelswitch/travelhub/backend/internal/config" ) // isolate points the process at an empty directory and clears every variable // Load reads, so one test cannot inherit another's environment -- or the // developer's real .env. func isolate(t *testing.T) { t.Helper() for _, key := range []string{ "DATABASE_URL", "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME", "DB_SSLMODE", "PORT", "CORS_ORIGINS", } { t.Setenv(key, "") os.Unsetenv(key) } t.Chdir(t.TempDir()) } func TestLoadDefaults(t *testing.T) { isolate(t) t.Setenv("DB_PASSWORD", "secret") cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } if cfg.Port != "8080" { t.Errorf("Port = %q, want 8080", cfg.Port) } if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "http://localhost:4200" { t.Errorf("CORSOrigins = %v, want the Angular dev server", cfg.CORSOrigins) } if !strings.Contains(cfg.DatabaseURL, "/travelhub") { t.Errorf("DatabaseURL = %q, want the default database name", cfg.DatabaseURL) } } func TestLoadRequiresAPassword(t *testing.T) { isolate(t) _, err := config.Load() if err == nil { t.Fatal("want an error when neither DATABASE_URL nor DB_PASSWORD is set") } if !strings.Contains(err.Error(), "DB_PASSWORD") { t.Errorf("error should name the missing variable, got: %v", err) } } // The credentials in use here contain '@', which is exactly the character that // breaks naive string concatenation into a DSN. func TestLoadEscapesAwkwardPasswords(t *testing.T) { isolate(t) awkward := []string{ // Synthetic values only — never a real, in-use password (see the VAPT // report, FINDING-8). Each still exercises a reserved DSN character. "S3cr3t@x", "p@ss:word/with?reserved#chars", "has spaces and %25 percent", "trailing@@@", } for _, password := range awkward { t.Run(password, func(t *testing.T) { t.Setenv("DB_PASSWORD", password) t.Setenv("DB_USER", "postgres") t.Setenv("DB_NAME", "travelhub") cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } // The real proof is that the driver can parse it back out. parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL) if err != nil { t.Fatalf("pgx could not parse %q: %v", cfg.DatabaseURL, err) } if parsed.ConnConfig.Password != password { t.Errorf("round-tripped password = %q, want %q", parsed.ConnConfig.Password, password) } if parsed.ConnConfig.Database != "travelhub" { t.Errorf("database = %q, want travelhub", parsed.ConnConfig.Database) } }) } } func TestDatabaseURLWinsOverParts(t *testing.T) { isolate(t) const explicit = "postgres://someone:else@db.internal:6543/other?sslmode=require" t.Setenv("DATABASE_URL", explicit) t.Setenv("DB_PASSWORD", "ignored") t.Setenv("DB_NAME", "ignored") cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } if cfg.DatabaseURL != explicit { t.Errorf("DatabaseURL = %q, want the explicit value", cfg.DatabaseURL) } } func TestLoadReadsDotEnv(t *testing.T) { isolate(t) dir := t.TempDir() contents := strings.Join([]string{ "# a comment", "", "DB_PASSWORD=from-file", "DB_NAME=from-file-db", `DB_USER="quoted-user"`, "PORT=9999", "CORS_ORIGINS=http://a.test, http://b.test ,", "MALFORMED-LINE-WITHOUT-EQUALS", }, "\n") if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(contents), 0o600); err != nil { t.Fatalf("write .env: %v", err) } t.Chdir(dir) cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } if cfg.Port != "9999" { t.Errorf("Port = %q, want 9999 from .env", cfg.Port) } if got := cfg.CORSOrigins; len(got) != 2 || got[0] != "http://a.test" || got[1] != "http://b.test" { t.Errorf("CORSOrigins = %v, want two trimmed origins", got) } parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL) if err != nil { t.Fatalf("parse: %v", err) } if parsed.ConnConfig.Password != "from-file" { t.Errorf("password = %q, want from-file", parsed.ConnConfig.Password) } if parsed.ConnConfig.Database != "from-file-db" { t.Errorf("database = %q, want from-file-db", parsed.ConnConfig.Database) } // Surrounding quotes are stripped rather than taken literally. if parsed.ConnConfig.User != "quoted-user" { t.Errorf("user = %q, want quoted-user", parsed.ConnConfig.User) } } // Real environment variables must beat .env, so container and CI overrides work. func TestEnvironmentBeatsDotEnv(t *testing.T) { isolate(t) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("DB_PASSWORD=from-file\nPORT=1111\n"), 0o600); err != nil { t.Fatalf("write .env: %v", err) } t.Chdir(dir) t.Setenv("PORT", "2222") t.Setenv("DB_PASSWORD", "from-environment") cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } if cfg.Port != "2222" { t.Errorf("Port = %q, want the environment value 2222", cfg.Port) } parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL) if parsed.ConnConfig.Password != "from-environment" { t.Errorf("password = %q, want from-environment", parsed.ConnConfig.Password) } } func TestLoadFindsDotEnvInAParentDirectory(t *testing.T) { isolate(t) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".env"), []byte("DB_PASSWORD=parent-secret\n"), 0o600); err != nil { t.Fatalf("write .env: %v", err) } nested := filepath.Join(root, "cmd", "api") if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } t.Chdir(nested) cfg, err := config.Load() if err != nil { t.Fatalf("load: %v", err) } parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL) if parsed.ConnConfig.Password != "parent-secret" { t.Errorf("password = %q, want parent-secret", parsed.ConnConfig.Password) } }