Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests) - Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests) - Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests) - Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests) - Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests) - Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
324
internal/config/config_test.go
Normal file
324
internal/config/config_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// resetConfigState resets the package-level singleton so each test starts fresh.
|
||||
func resetConfigState() {
|
||||
cfg = nil
|
||||
cfgOnce = sync.Once{}
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
func TestLoad_DefaultValues(t *testing.T) {
|
||||
resetConfigState()
|
||||
// Provide required SECRET_KEY so validation passes
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Server defaults
|
||||
assert.Equal(t, 8000, c.Server.Port)
|
||||
assert.False(t, c.Server.Debug)
|
||||
assert.False(t, c.Server.DebugFixedCodes)
|
||||
assert.Equal(t, "UTC", c.Server.Timezone)
|
||||
assert.Equal(t, "/app/static", c.Server.StaticDir)
|
||||
assert.Equal(t, "https://api.myhoneydue.com", c.Server.BaseURL)
|
||||
|
||||
// Database defaults
|
||||
assert.Equal(t, "localhost", c.Database.Host)
|
||||
assert.Equal(t, 5432, c.Database.Port)
|
||||
assert.Equal(t, "postgres", c.Database.User)
|
||||
assert.Equal(t, "honeydue", c.Database.Database)
|
||||
assert.Equal(t, "disable", c.Database.SSLMode)
|
||||
assert.Equal(t, 25, c.Database.MaxOpenConns)
|
||||
assert.Equal(t, 10, c.Database.MaxIdleConns)
|
||||
|
||||
// Redis defaults
|
||||
assert.Equal(t, "redis://localhost:6379/0", c.Redis.URL)
|
||||
assert.Equal(t, 0, c.Redis.DB)
|
||||
|
||||
// Worker defaults
|
||||
assert.Equal(t, 14, c.Worker.TaskReminderHour)
|
||||
assert.Equal(t, 15, c.Worker.OverdueReminderHour)
|
||||
assert.Equal(t, 3, c.Worker.DailyNotifHour)
|
||||
|
||||
// Token expiry defaults
|
||||
assert.Equal(t, 90, c.Security.TokenExpiryDays)
|
||||
assert.Equal(t, 60, c.Security.TokenRefreshDays)
|
||||
|
||||
// Feature flags default to true
|
||||
assert.True(t, c.Features.PushEnabled)
|
||||
assert.True(t, c.Features.EmailEnabled)
|
||||
assert.True(t, c.Features.WebhooksEnabled)
|
||||
assert.True(t, c.Features.OnboardingEmailsEnabled)
|
||||
assert.True(t, c.Features.PDFReportsEnabled)
|
||||
assert.True(t, c.Features.WorkerEnabled)
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverrides(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
t.Setenv("PORT", "9090")
|
||||
t.Setenv("DEBUG", "true")
|
||||
t.Setenv("DB_HOST", "db.example.com")
|
||||
t.Setenv("DB_PORT", "5433")
|
||||
t.Setenv("TOKEN_EXPIRY_DAYS", "180")
|
||||
t.Setenv("TOKEN_REFRESH_DAYS", "120")
|
||||
t.Setenv("FEATURE_PUSH_ENABLED", "false")
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 9090, c.Server.Port)
|
||||
assert.True(t, c.Server.Debug)
|
||||
assert.Equal(t, "db.example.com", c.Database.Host)
|
||||
assert.Equal(t, 5433, c.Database.Port)
|
||||
assert.Equal(t, 180, c.Security.TokenExpiryDays)
|
||||
assert.Equal(t, 120, c.Security.TokenRefreshDays)
|
||||
assert.False(t, c.Features.PushEnabled)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_MissingSecretKey_Production(t *testing.T) {
|
||||
// Test validate() directly to avoid the sync.Once mutex issue
|
||||
// that occurs when Load() resets cfgOnce inside cfgOnce.Do()
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: false},
|
||||
Security: SecurityConfig{SecretKey: ""},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SECRET_KEY")
|
||||
}
|
||||
|
||||
func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "")
|
||||
t.Setenv("DEBUG", "true")
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
// In debug mode, a default key is assigned
|
||||
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) {
|
||||
// Test validate() directly to avoid the sync.Once mutex issue
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: false},
|
||||
Security: SecurityConfig{SecretKey: "password"},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "well-known weak value")
|
||||
}
|
||||
|
||||
func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "secret")
|
||||
t.Setenv("DEBUG", "true")
|
||||
|
||||
// In debug mode, weak keys produce a warning but no error
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret", c.Security.SecretKey)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
// Valid 64-char hex key (32 bytes)
|
||||
t.Setenv("STORAGE_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", c.Storage.EncryptionKey)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_EncryptionKey_WrongLength(t *testing.T) {
|
||||
// Test validate() directly to avoid the sync.Once mutex issue
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: false},
|
||||
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
|
||||
Storage: StorageConfig{EncryptionKey: "tooshort"},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "STORAGE_ENCRYPTION_KEY must be exactly 64 hex characters")
|
||||
}
|
||||
|
||||
func TestLoad_Validation_EncryptionKey_InvalidHex(t *testing.T) {
|
||||
// Test validate() directly to avoid the sync.Once mutex issue
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: false},
|
||||
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
|
||||
Storage: StorageConfig{EncryptionKey: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid hex")
|
||||
}
|
||||
|
||||
func TestLoad_DatabaseURL_Override(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
t.Setenv("DATABASE_URL", "postgres://myuser:mypass@dbhost:5433/mydb?sslmode=require")
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "dbhost", c.Database.Host)
|
||||
assert.Equal(t, 5433, c.Database.Port)
|
||||
assert.Equal(t, "myuser", c.Database.User)
|
||||
assert.Equal(t, "mypass", c.Database.Password)
|
||||
assert.Equal(t, "mydb", c.Database.Database)
|
||||
assert.Equal(t, "require", c.Database.SSLMode)
|
||||
}
|
||||
|
||||
func TestDSN(t *testing.T) {
|
||||
d := DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "testuser",
|
||||
Password: "Password123",
|
||||
Database: "testdb",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
|
||||
dsn := d.DSN()
|
||||
assert.Contains(t, dsn, "host=localhost")
|
||||
assert.Contains(t, dsn, "port=5432")
|
||||
assert.Contains(t, dsn, "user=testuser")
|
||||
assert.Contains(t, dsn, "password=Password123")
|
||||
assert.Contains(t, dsn, "dbname=testdb")
|
||||
assert.Contains(t, dsn, "sslmode=disable")
|
||||
}
|
||||
|
||||
func TestMaskURLCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "URL with password",
|
||||
input: "postgres://user:secret@host:5432/db",
|
||||
expected: "postgres://user:xxxxx@host:5432/db",
|
||||
},
|
||||
{
|
||||
name: "URL without password",
|
||||
input: "postgres://user@host:5432/db",
|
||||
expected: "postgres://user@host:5432/db",
|
||||
},
|
||||
{
|
||||
name: "URL without user info",
|
||||
input: "postgres://host:5432/db",
|
||||
expected: "postgres://host:5432/db",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := MaskURLCredentials(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCorsOrigins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"empty string", "", nil},
|
||||
{"single origin", "https://example.com", []string{"https://example.com"}},
|
||||
{"multiple origins", "https://a.com, https://b.com", []string{"https://a.com", "https://b.com"}},
|
||||
{"whitespace trimmed", " https://a.com , https://b.com ", []string{"https://a.com", "https://b.com"}},
|
||||
{"empty parts skipped", "https://a.com,,https://b.com", []string{"https://a.com", "https://b.com"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := parseCorsOrigins(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDatabaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantUser string
|
||||
wantPass string
|
||||
wantDB string
|
||||
wantSSL string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "full URL",
|
||||
url: "postgres://user:Password123@host:5433/mydb?sslmode=require",
|
||||
wantHost: "host",
|
||||
wantPort: 5433,
|
||||
wantUser: "user",
|
||||
wantPass: "Password123",
|
||||
wantDB: "mydb",
|
||||
wantSSL: "require",
|
||||
},
|
||||
{
|
||||
name: "default port",
|
||||
url: "postgres://user:pass@host/mydb",
|
||||
wantHost: "host",
|
||||
wantPort: 5432,
|
||||
wantUser: "user",
|
||||
wantPass: "pass",
|
||||
wantDB: "mydb",
|
||||
wantSSL: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := parseDatabaseURL(tc.url)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantHost, result.Host)
|
||||
assert.Equal(t, tc.wantPort, result.Port)
|
||||
assert.Equal(t, tc.wantUser, result.User)
|
||||
assert.Equal(t, tc.wantPass, result.Password)
|
||||
assert.Equal(t, tc.wantDB, result.Database)
|
||||
assert.Equal(t, tc.wantSSL, result.SSLMode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeakSecretKey(t *testing.T) {
|
||||
assert.True(t, isWeakSecretKey("secret"))
|
||||
assert.True(t, isWeakSecretKey("Secret")) // case-insensitive
|
||||
assert.True(t, isWeakSecretKey(" changeme ")) // whitespace trimmed
|
||||
assert.True(t, isWeakSecretKey("password"))
|
||||
assert.True(t, isWeakSecretKey("change-me"))
|
||||
assert.False(t, isWeakSecretKey("a-strong-unique-production-key"))
|
||||
}
|
||||
|
||||
func TestGet_ReturnsNilBeforeLoad(t *testing.T) {
|
||||
resetConfigState()
|
||||
assert.Nil(t, Get())
|
||||
}
|
||||
Reference in New Issue
Block a user