To gitea and beyond, let's go(-yco)
This commit is contained in:
318
internal/config/config.go
Normal file
318
internal/config/config.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
Server ServerConfig
|
||||
JWT JWTConfig
|
||||
SMTP SMTPConfig
|
||||
App AppConfig
|
||||
RateLimit RateLimitConfig
|
||||
LogDir string
|
||||
PIDDir string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Host string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
EnableTLS bool
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
Expiration int
|
||||
RefreshExpiration int
|
||||
Issuer string
|
||||
Audience string
|
||||
KeyRotation KeyRotationConfig
|
||||
}
|
||||
|
||||
type KeyRotationConfig struct {
|
||||
Enabled bool
|
||||
CurrentKey string
|
||||
PreviousKey string
|
||||
KeyID string
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
BaseURL string
|
||||
Debug bool
|
||||
AdminEmail string
|
||||
BcryptCost int
|
||||
Title string
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
AuthLimit int
|
||||
GeneralLimit int
|
||||
HealthLimit int
|
||||
MetricsLimit int
|
||||
TrustProxyHeaders bool
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
config := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
User: getEnv("DB_USER", "postgres"),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
Name: getEnv("DB_NAME", "goyco"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||
ReadTimeout: time.Duration(getEnvAsInt("SERVER_READ_TIMEOUT", 30)) * time.Second,
|
||||
WriteTimeout: time.Duration(getEnvAsInt("SERVER_WRITE_TIMEOUT", 30)) * time.Second,
|
||||
IdleTimeout: time.Duration(getEnvAsInt("SERVER_IDLE_TIMEOUT", 120)) * time.Second,
|
||||
MaxHeaderBytes: getEnvAsInt("SERVER_MAX_HEADER_BYTES", 1<<20),
|
||||
EnableTLS: getEnvAsBool("SERVER_ENABLE_TLS", false),
|
||||
TLSCertFile: getEnv("SERVER_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getEnv("SERVER_TLS_KEY_FILE", ""),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
Expiration: getEnvAsInt("JWT_EXPIRATION", 1),
|
||||
RefreshExpiration: getEnvAsInt("JWT_REFRESH_EXPIRATION", 168),
|
||||
Issuer: getEnv("JWT_ISSUER", "goyco"),
|
||||
Audience: getEnv("JWT_AUDIENCE", "goyco-users"),
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: getEnvAsBool("JWT_KEY_ROTATION_ENABLED", false),
|
||||
CurrentKey: getEnv("JWT_CURRENT_KEY", ""),
|
||||
PreviousKey: getEnv("JWT_PREVIOUS_KEY", ""),
|
||||
KeyID: getEnv("JWT_KEY_ID", "default"),
|
||||
},
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: getEnv("SMTP_HOST", ""),
|
||||
Port: getEnvAsInt("SMTP_PORT", 587),
|
||||
Username: getEnv("SMTP_USERNAME", ""),
|
||||
Password: getEnv("SMTP_PASSWORD", ""),
|
||||
From: getEnv("SMTP_FROM", ""),
|
||||
Timeout: time.Duration(getEnvAsInt("SMTP_TIMEOUT", 30)) * time.Second,
|
||||
},
|
||||
App: AppConfig{
|
||||
BaseURL: getEnv("APP_BASE_URL", ""),
|
||||
Debug: getEnvAsBool("DEBUG", false),
|
||||
AdminEmail: getEnv("ADMIN_EMAIL", ""),
|
||||
BcryptCost: getEnvAsInt("BCRYPT_COST", 10),
|
||||
Title: getEnv("TITLE", "Goyco"),
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
AuthLimit: getEnvAsInt("RATE_LIMIT_AUTH", 5),
|
||||
GeneralLimit: getEnvAsInt("RATE_LIMIT_GENERAL", 100),
|
||||
HealthLimit: getEnvAsInt("RATE_LIMIT_HEALTH", 60),
|
||||
MetricsLimit: getEnvAsInt("RATE_LIMIT_METRICS", 10),
|
||||
TrustProxyHeaders: getEnvAsBool("RATE_LIMIT_TRUST_PROXY", false),
|
||||
},
|
||||
LogDir: getEnv("LOG_DIR", "/var/log/"),
|
||||
PIDDir: getEnv("PID_DIR", "/run"),
|
||||
}
|
||||
|
||||
if config.App.BaseURL == "" {
|
||||
config.App.BaseURL = fmt.Sprintf("http://%s:%s", config.Server.Host, config.Server.Port)
|
||||
}
|
||||
|
||||
if config.Database.Password == "" {
|
||||
return nil, fmt.Errorf("DB_PASSWORD is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.SMTP.Host) == "" {
|
||||
return nil, fmt.Errorf("SMTP_HOST is required")
|
||||
}
|
||||
|
||||
if config.SMTP.Port <= 0 {
|
||||
return nil, fmt.Errorf("SMTP_PORT must be greater than 0")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.SMTP.From) == "" {
|
||||
return nil, fmt.Errorf("SMTP_FROM is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.App.AdminEmail) == "" {
|
||||
return nil, fmt.Errorf("ADMIN_EMAIL is required")
|
||||
}
|
||||
|
||||
if config.Server.EnableTLS {
|
||||
if strings.TrimSpace(config.Server.TLSCertFile) == "" {
|
||||
return nil, fmt.Errorf("SERVER_TLS_CERT_FILE is required when SERVER_ENABLE_TLS is true")
|
||||
}
|
||||
if strings.TrimSpace(config.Server.TLSKeyFile) == "" {
|
||||
return nil, fmt.Errorf("SERVER_TLS_KEY_FILE is required when SERVER_ENABLE_TLS is true")
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateJWTConfig(&config.JWT); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAppConfig(&config.App); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (c *Config) GetConnectionString() string {
|
||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s client_encoding=UTF8",
|
||||
c.Database.Host,
|
||||
c.Database.Port,
|
||||
c.Database.User,
|
||||
c.Database.Password,
|
||||
c.Database.Name,
|
||||
c.Database.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if boolValue, err := strconv.ParseBool(value); err == nil {
|
||||
return boolValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func validateJWTConfig(jwt *JWTConfig) error {
|
||||
if err := validateJWTSecret(jwt.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.Issuer) == "" {
|
||||
return fmt.Errorf("JWT_ISSUER is required and cannot be empty")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.Audience) == "" {
|
||||
return fmt.Errorf("JWT_AUDIENCE is required and cannot be empty")
|
||||
}
|
||||
|
||||
if jwt.Expiration <= 0 {
|
||||
return fmt.Errorf("JWT_EXPIRATION must be greater than 0")
|
||||
}
|
||||
|
||||
if jwt.RefreshExpiration <= 0 {
|
||||
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than 0")
|
||||
}
|
||||
|
||||
if jwt.RefreshExpiration <= jwt.Expiration {
|
||||
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION")
|
||||
}
|
||||
|
||||
if jwt.KeyRotation.Enabled {
|
||||
if strings.TrimSpace(jwt.KeyRotation.CurrentKey) == "" {
|
||||
return fmt.Errorf("JWT_CURRENT_KEY is required when key rotation is enabled")
|
||||
}
|
||||
|
||||
if err := validateJWTSecret(jwt.KeyRotation.CurrentKey); err != nil {
|
||||
return fmt.Errorf("JWT_CURRENT_KEY validation failed: %w", err)
|
||||
}
|
||||
|
||||
if jwt.KeyRotation.PreviousKey != "" {
|
||||
if err := validateJWTSecret(jwt.KeyRotation.PreviousKey); err != nil {
|
||||
return fmt.Errorf("JWT_PREVIOUS_KEY validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.KeyRotation.KeyID) == "" {
|
||||
return fmt.Errorf("JWT_KEY_ID is required when key rotation is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJWTSecret(secret string) error {
|
||||
trimmed := strings.TrimSpace(secret)
|
||||
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("JWT secret is required and cannot be empty")
|
||||
}
|
||||
|
||||
invalidSecrets := []string{
|
||||
"your-secret-key",
|
||||
"secret",
|
||||
"jwt-secret",
|
||||
"my-secret",
|
||||
"change-me",
|
||||
"default-secret",
|
||||
"123456",
|
||||
"password",
|
||||
"admin",
|
||||
"test",
|
||||
"development",
|
||||
"production",
|
||||
"staging",
|
||||
}
|
||||
|
||||
for _, invalid := range invalidSecrets {
|
||||
if strings.EqualFold(trimmed, invalid) {
|
||||
return fmt.Errorf("JWT secret cannot be a placeholder value like %q - please set a secure, random secret", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmed) < 32 {
|
||||
return fmt.Errorf("JWT secret must be at least 32 characters long for security (current length: %d)", len(trimmed))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAppConfig(app *AppConfig) error {
|
||||
|
||||
if app.BcryptCost < 10 {
|
||||
return fmt.Errorf("BCRYPT_COST must be at least 10 for security (current: %d)", app.BcryptCost)
|
||||
}
|
||||
if app.BcryptCost > 14 {
|
||||
return fmt.Errorf("BCRYPT_COST must be at most 14 to avoid performance issues (current: %d)", app.BcryptCost)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
997
internal/config/config_test.go
Normal file
997
internal/config/config_test.go
Normal file
@@ -0,0 +1,997 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSuccess(t *testing.T) {
|
||||
t.Setenv("DB_HOST", "db.example.com")
|
||||
t.Setenv("DB_PORT", "5439")
|
||||
t.Setenv("DB_USER", "goyco")
|
||||
t.Setenv("DB_PASSWORD", "super-secret")
|
||||
t.Setenv("DB_NAME", "goycodb")
|
||||
t.Setenv("DB_SSLMODE", "require")
|
||||
t.Setenv("SERVER_PORT", "9090")
|
||||
t.Setenv("SERVER_HOST", "127.0.0.1")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
t.Setenv("JWT_EXPIRATION", "12")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_USERNAME", "mailer")
|
||||
t.Setenv("SMTP_PASSWORD", "mail-secret")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("APP_BASE_URL", "https://goyco.example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("TITLE", "My Custom Site")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Database.Host != "db.example.com" || cfg.Database.Port != "5439" || cfg.Database.User != "goyco" {
|
||||
t.Fatalf("unexpected database config: %+v", cfg.Database)
|
||||
}
|
||||
|
||||
if cfg.Database.Password != "super-secret" || cfg.Database.Name != "goycodb" || cfg.Database.SSLMode != "require" {
|
||||
t.Fatalf("unexpected database credentials: %+v", cfg.Database)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != "9090" || cfg.Server.Host != "127.0.0.1" {
|
||||
t.Fatalf("unexpected server config: %+v", cfg.Server)
|
||||
}
|
||||
|
||||
if cfg.JWT.Secret != "this-is-a-very-secure-jwt-secret-key-that-is-long-enough" {
|
||||
t.Fatalf("unexpected jwt secret: %q", cfg.JWT.Secret)
|
||||
}
|
||||
|
||||
if cfg.JWT.Expiration != 12 {
|
||||
t.Fatalf("expected JWT expiration 12, got %d", cfg.JWT.Expiration)
|
||||
}
|
||||
|
||||
if cfg.SMTP.Host != "smtp.example.com" || cfg.SMTP.Port != 2525 {
|
||||
t.Fatalf("unexpected smtp host/port: %+v", cfg.SMTP)
|
||||
}
|
||||
|
||||
if cfg.SMTP.Username != "mailer" || cfg.SMTP.Password != "mail-secret" || cfg.SMTP.From != "no-reply@example.com" {
|
||||
t.Fatalf("unexpected smtp credentials: %+v", cfg.SMTP)
|
||||
}
|
||||
|
||||
if cfg.App.BaseURL != "https://goyco.example.com" {
|
||||
t.Fatalf("expected base url to be overridden, got %q", cfg.App.BaseURL)
|
||||
}
|
||||
|
||||
if cfg.App.Title != "My Custom Site" {
|
||||
t.Fatalf("expected title to be 'My Custom Site', got %q", cfg.App.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingPassword(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("expected error when DB_PASSWORD is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultBaseURL(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "pw")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected load to succeed, got %v", err)
|
||||
}
|
||||
|
||||
if cfg.App.BaseURL != "http://0.0.0.0:8080" {
|
||||
t.Fatalf("expected default base url http://0.0.0.0:8080, got %q", cfg.App.BaseURL)
|
||||
}
|
||||
|
||||
if cfg.App.Title != "Goyco" {
|
||||
t.Fatalf("expected default title to be 'Goyco', got %q", cfg.App.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetConnectionString(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Host: "db",
|
||||
Port: "5432",
|
||||
User: "user",
|
||||
Password: "pass",
|
||||
Name: "dbname",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
}
|
||||
|
||||
got := cfg.GetConnectionString()
|
||||
expected := "host=db port=5432 user=user password=pass dbname=dbname sslmode=disable client_encoding=UTF8"
|
||||
|
||||
if got != expected {
|
||||
t.Fatalf("expected connection string %q, got %q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
const key = "CONFIG_TEST_ENV"
|
||||
|
||||
t.Setenv(key, "value")
|
||||
if got := getEnv(key, "default"); got != "value" {
|
||||
t.Fatalf("expected %q, got %q", "value", got)
|
||||
}
|
||||
|
||||
if got := getEnv(key+"_MISSING", "fallback"); got != "fallback" {
|
||||
t.Fatalf("expected fallback value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvAsInt(t *testing.T) {
|
||||
const key = "CONFIG_TEST_INT"
|
||||
|
||||
t.Setenv(key, "42")
|
||||
if got := getEnvAsInt(key, 1); got != 42 {
|
||||
t.Fatalf("expected 42, got %d", got)
|
||||
}
|
||||
|
||||
t.Setenv(key, "not-a-number")
|
||||
if got := getEnvAsInt(key, 5); got != 5 {
|
||||
t.Fatalf("expected default 5 when invalid int, got %d", got)
|
||||
}
|
||||
|
||||
t.Setenv(key, "")
|
||||
if got := getEnvAsInt(key, 7); got != 7 {
|
||||
t.Fatalf("expected default 7 when env empty, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid long secret",
|
||||
secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid secret with special chars",
|
||||
secret: "MyV3ry$ecure&JWT!Secret#Key@2024-With-Special-Chars",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
secret: "",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only secret",
|
||||
secret: " ",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "too short secret",
|
||||
secret: "short",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret must be at least 32 characters long for security",
|
||||
},
|
||||
{
|
||||
name: "default placeholder secret",
|
||||
secret: "your-secret-key",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"your-secret-key\"",
|
||||
},
|
||||
{
|
||||
name: "common placeholder secret",
|
||||
secret: "secret",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"secret\"",
|
||||
},
|
||||
{
|
||||
name: "test placeholder secret",
|
||||
secret: "test",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"test\"",
|
||||
},
|
||||
{
|
||||
name: "development placeholder secret",
|
||||
secret: "development",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"development\"",
|
||||
},
|
||||
{
|
||||
name: "case insensitive placeholder",
|
||||
secret: "SECRET",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"secret\"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWTSecret(tt.secret)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for secret %q, got nil", tt.secret)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for secret %q: %v", tt.secret, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidJWTSecret(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "password")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
|
||||
t.Setenv("JWT_SECRET", "your-secret-key")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when JWT_SECRET is placeholder value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "your-secret-key") {
|
||||
t.Fatalf("expected error message to mention placeholder value, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config JWTConfig
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty issuer",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only issuer",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: " ",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty audience",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only audience",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: " ",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "zero expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 0,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "negative expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: -1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "zero refresh expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 0,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "negative refresh expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: -1,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration not greater than access expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration less than access expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 12,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no current key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "",
|
||||
PreviousKey: "",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY is required when key rotation is enabled",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no key ID",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "",
|
||||
KeyID: "",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_KEY_ID is required when key rotation is enabled",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled with invalid current key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "short",
|
||||
PreviousKey: "",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY validation failed",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled with invalid previous key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "short",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_PREVIOUS_KEY validation failed",
|
||||
},
|
||||
{
|
||||
name: "valid key rotation config",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "this-is-another-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWTConfig(&tt.config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for config %+v, got nil", tt.config)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for config %+v: %v", tt.config, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidJWTConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVars map[string]string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "whitespace only issuer",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": " ",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only audience",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": " ",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "zero expiration",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "0",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration not greater than access expiration",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "24",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no current key",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
"JWT_KEY_ROTATION_ENABLED": "true",
|
||||
"JWT_CURRENT_KEY": "",
|
||||
"JWT_KEY_ID": "test-key",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY is required when key rotation is enabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
envVars := []string{
|
||||
"JWT_SECRET", "JWT_ISSUER", "JWT_AUDIENCE", "JWT_EXPIRATION", "JWT_REFRESH_EXPIRATION",
|
||||
"JWT_KEY_ROTATION_ENABLED", "JWT_CURRENT_KEY", "JWT_PREVIOUS_KEY", "JWT_KEY_ID",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
t.Setenv(envVar, "")
|
||||
}
|
||||
|
||||
for key, value := range tt.envVars {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigDefaults(t *testing.T) {
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 30*time.Second {
|
||||
t.Errorf("Expected ReadTimeout to be 30s, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 30*time.Second {
|
||||
t.Errorf("Expected WriteTimeout to be 30s, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 120*time.Second {
|
||||
t.Errorf("Expected IdleTimeout to be 120s, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 1<<20 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 1MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
if config.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be false by default")
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigCustomValues(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_READ_TIMEOUT", "60")
|
||||
os.Setenv("SERVER_WRITE_TIMEOUT", "45")
|
||||
os.Setenv("SERVER_IDLE_TIMEOUT", "180")
|
||||
os.Setenv("SERVER_MAX_HEADER_BYTES", "2097152")
|
||||
os.Setenv("SERVER_ENABLE_TLS", "true")
|
||||
os.Setenv("SERVER_TLS_CERT_FILE", "/path/to/cert.pem")
|
||||
os.Setenv("SERVER_TLS_KEY_FILE", "/path/to/key.pem")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 60*time.Second {
|
||||
t.Errorf("Expected ReadTimeout to be 60s, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 45*time.Second {
|
||||
t.Errorf("Expected WriteTimeout to be 45s, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 180*time.Second {
|
||||
t.Errorf("Expected IdleTimeout to be 180s, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 2<<20 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 2MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
if !config.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be true")
|
||||
}
|
||||
|
||||
if config.Server.TLSCertFile != "/path/to/cert.pem" {
|
||||
t.Errorf("Expected TLSCertFile to be /path/to/cert.pem, got %s", config.Server.TLSCertFile)
|
||||
}
|
||||
|
||||
if config.Server.TLSKeyFile != "/path/to/key.pem" {
|
||||
t.Errorf("Expected TLSKeyFile to be /path/to/key.pem, got %s", config.Server.TLSKeyFile)
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigEdgeCases(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_READ_TIMEOUT", "0")
|
||||
os.Setenv("SERVER_WRITE_TIMEOUT", "0")
|
||||
os.Setenv("SERVER_IDLE_TIMEOUT", "0")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 0 {
|
||||
t.Errorf("Expected ReadTimeout to be 0, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 0 {
|
||||
t.Errorf("Expected WriteTimeout to be 0, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 0 {
|
||||
t.Errorf("Expected IdleTimeout to be 0, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_MAX_HEADER_BYTES", "10485760")
|
||||
|
||||
config, err = Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 10485760 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 10MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSValidation(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_ENABLE_TLS", "true")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when TLS is enabled without cert files")
|
||||
}
|
||||
|
||||
if err.Error() != "SERVER_TLS_CERT_FILE is required when SERVER_ENABLE_TLS is true" {
|
||||
t.Errorf("Expected specific error message, got: %v", err)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_TLS_CERT_FILE", "/path/to/cert.pem")
|
||||
|
||||
_, err = Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when TLS is enabled without key file")
|
||||
}
|
||||
|
||||
if err.Error() != "SERVER_TLS_KEY_FILE is required when SERVER_ENABLE_TLS is true" {
|
||||
t.Errorf("Expected specific error message, got: %v", err)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_TLS_KEY_FILE", "/path/to/key.pem")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config with TLS: %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be true")
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBcryptCost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bcryptCost int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid cost at minimum (10)",
|
||||
bcryptCost: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid cost at maximum (14)",
|
||||
bcryptCost: 14,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid cost in middle (12)",
|
||||
bcryptCost: 12,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "cost too low (9)",
|
||||
bcryptCost: 9,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too low (5)",
|
||||
bcryptCost: 5,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too low (0)",
|
||||
bcryptCost: 0,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too high (15)",
|
||||
bcryptCost: 15,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14 to avoid performance issues",
|
||||
},
|
||||
{
|
||||
name: "cost too high (20)",
|
||||
bcryptCost: 20,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14 to avoid performance issues",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
appConfig := AppConfig{
|
||||
BcryptCost: tt.bcryptCost,
|
||||
}
|
||||
err := validateAppConfig(&appConfig)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for BCRYPT_COST %d, got nil", tt.bcryptCost)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for BCRYPT_COST %d: %v", tt.bcryptCost, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidBcryptCost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bcryptCost string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "cost too low",
|
||||
bcryptCost: "9",
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10",
|
||||
},
|
||||
{
|
||||
name: "cost too high",
|
||||
bcryptCost: "15",
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14",
|
||||
},
|
||||
{
|
||||
name: "valid cost",
|
||||
bcryptCost: "12",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "default cost",
|
||||
bcryptCost: "",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
t.Setenv("DB_PASSWORD", "password")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
|
||||
if tt.bcryptCost != "" {
|
||||
t.Setenv("BCRYPT_COST", tt.bcryptCost)
|
||||
} else {
|
||||
os.Unsetenv("BCRYPT_COST")
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expectedCost := 12
|
||||
if tt.bcryptCost == "" {
|
||||
expectedCost = 10
|
||||
} else {
|
||||
if costInt, err := strconv.Atoi(tt.bcryptCost); err == nil {
|
||||
expectedCost = costInt
|
||||
}
|
||||
}
|
||||
if cfg.App.BcryptCost != expectedCost {
|
||||
t.Fatalf("expected BCRYPT_COST %d, got %d", expectedCost, cfg.App.BcryptCost)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user