Files
honeyDueAPI/internal/config/config_test.go
T
Trey t c77ff07ce9
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:28:33 -05:00

354 lines
10 KiB
Go

package config
import (
"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() {
cfgMu.Lock()
cfg = nil
cfgMu.Unlock()
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)
// Audit M8: in debug mode an ephemeral random key is generated per boot
// (no static fallback). It must be a non-empty 64-char hex string.
assert.Len(t, c.Security.SecretKey, 64)
assert.NotEqual(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)
}
// Audit C4: DEBUG_FIXED_CODES makes confirmation codes a fixed "123456" — a
// full authentication bypass. With DEBUG=false, validate() must refuse to boot
// rather than ship that bypass to production.
func TestLoad_Validation_DebugFixedCodes_Production(t *testing.T) {
// validate() directly — avoids the sync.Once issue Load() has on failure.
cfg := &Config{
Server: ServerConfig{Debug: false, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "DEBUG_FIXED_CODES")
}
// With DEBUG=true the fixed codes are an intended local-dev convenience, so
// the same combination must NOT error.
func TestLoad_Validation_DebugFixedCodes_DebugMode(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Debug: true, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.NoError(t, err)
}
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())
}