fix(config): replace sync.Once reset-from-Do with mutex
Load()'s validation-failure path reassigned cfgOnce = sync.Once{} from
inside Do(). When Do() returned and tried to unlock the original mutex,
the Once struct had already been replaced with a fresh one whose mutex
was unlocked, panicking with "sync: unlock of unlocked mutex" on every
boot where any required env var was missing or invalid.
Replaced the Once with a plain sync.Mutex around a nil-check on the
package-level cfg, building the candidate into a local first and only
assigning to cfg after validate() succeeds. Same caching semantics, no
race, and a failed Load() leaves cfg nil so the next caller retries
cleanly.
Also documented AppleAuthConfig.TeamID as currently dead — it's loaded
from APPLE_TEAM_ID but no service reads it. Wire-up point noted for
when Sign in with Apple revocation/refresh is added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+162
-158
@@ -89,8 +89,12 @@ type PushConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppleAuthConfig struct {
|
type AppleAuthConfig struct {
|
||||||
ClientID string // Bundle ID (e.g., com.tt.honeyDue.honeyDueDev)
|
ClientID string // Bundle ID, used as the `aud` claim in Sign in with Apple identity tokens
|
||||||
TeamID string // Apple Developer Team ID
|
// TeamID is currently unused — services/apple_auth.go validates identity tokens
|
||||||
|
// against ClientID + Apple's JWKS only, with no server-to-server REST calls.
|
||||||
|
// Wire this in if/when token revocation or refresh-token exchange is added,
|
||||||
|
// since both require signing a client_secret JWT with team_id + key_id.
|
||||||
|
TeamID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GoogleAuthConfig struct {
|
type GoogleAuthConfig struct {
|
||||||
@@ -178,8 +182,8 @@ type FeatureFlags struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg *Config
|
cfg *Config
|
||||||
cfgOnce sync.Once
|
cfgMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// knownWeakSecretKeys contains well-known default or placeholder secret keys
|
// knownWeakSecretKeys contains well-known default or placeholder secret keys
|
||||||
@@ -192,163 +196,163 @@ var knownWeakSecretKeys = map[string]bool{
|
|||||||
"change-me-in-production-secret-key-12345": true,
|
"change-me-in-production-secret-key-12345": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables
|
// Load reads configuration from environment variables.
|
||||||
|
//
|
||||||
|
// Caches the result so repeated calls are cheap. On validation failure, the
|
||||||
|
// cache stays nil so a subsequent call (after env is corrected) can retry. The
|
||||||
|
// previous implementation used sync.Once with an in-Do reset of the Once
|
||||||
|
// itself, which races and panics with "sync: unlock of unlocked mutex".
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
var loadErr error
|
cfgMu.Lock()
|
||||||
|
defer cfgMu.Unlock()
|
||||||
cfgOnce.Do(func() {
|
if cfg != nil {
|
||||||
viper.SetEnvPrefix("")
|
return cfg, nil
|
||||||
viper.AutomaticEnv()
|
|
||||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
||||||
|
|
||||||
// Set defaults
|
|
||||||
setDefaults()
|
|
||||||
|
|
||||||
// Parse DATABASE_URL if set (Dokku-style)
|
|
||||||
dbConfig := DatabaseConfig{
|
|
||||||
Host: viper.GetString("DB_HOST"),
|
|
||||||
Port: viper.GetInt("DB_PORT"),
|
|
||||||
User: viper.GetString("POSTGRES_USER"),
|
|
||||||
Password: viper.GetString("POSTGRES_PASSWORD"),
|
|
||||||
Database: viper.GetString("POSTGRES_DB"),
|
|
||||||
SSLMode: viper.GetString("DB_SSLMODE"),
|
|
||||||
MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"),
|
|
||||||
MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"),
|
|
||||||
MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"),
|
|
||||||
MaxIdleTime: viper.GetDuration("DB_MAX_IDLE_TIME"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override with DATABASE_URL if present (F-16: log warning on parse failure)
|
|
||||||
if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" {
|
|
||||||
parsed, err := parseDatabaseURL(databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
maskedURL := MaskURLCredentials(databaseURL)
|
|
||||||
fmt.Printf("WARNING: Failed to parse DATABASE_URL (%s): %v — falling back to individual DB_* env vars\n", maskedURL, err)
|
|
||||||
} else {
|
|
||||||
dbConfig.Host = parsed.Host
|
|
||||||
dbConfig.Port = parsed.Port
|
|
||||||
dbConfig.User = parsed.User
|
|
||||||
dbConfig.Password = parsed.Password
|
|
||||||
dbConfig.Database = parsed.Database
|
|
||||||
if parsed.SSLMode != "" {
|
|
||||||
dbConfig.SSLMode = parsed.SSLMode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg = &Config{
|
|
||||||
Server: ServerConfig{
|
|
||||||
Port: viper.GetInt("PORT"),
|
|
||||||
Debug: viper.GetBool("DEBUG"),
|
|
||||||
DebugFixedCodes: viper.GetBool("DEBUG_FIXED_CODES"),
|
|
||||||
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
|
||||||
CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
|
||||||
Timezone: viper.GetString("TIMEZONE"),
|
|
||||||
StaticDir: viper.GetString("STATIC_DIR"),
|
|
||||||
BaseURL: viper.GetString("BASE_URL"),
|
|
||||||
},
|
|
||||||
Database: dbConfig,
|
|
||||||
Redis: RedisConfig{
|
|
||||||
URL: viper.GetString("REDIS_URL"),
|
|
||||||
Password: viper.GetString("REDIS_PASSWORD"),
|
|
||||||
DB: viper.GetInt("REDIS_DB"),
|
|
||||||
},
|
|
||||||
Email: EmailConfig{
|
|
||||||
Host: viper.GetString("EMAIL_HOST"),
|
|
||||||
Port: viper.GetInt("EMAIL_PORT"),
|
|
||||||
User: viper.GetString("EMAIL_HOST_USER"),
|
|
||||||
Password: viper.GetString("EMAIL_HOST_PASSWORD"),
|
|
||||||
From: viper.GetString("DEFAULT_FROM_EMAIL"),
|
|
||||||
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
|
||||||
},
|
|
||||||
Push: PushConfig{
|
|
||||||
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
|
||||||
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
|
||||||
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
|
||||||
APNSTopic: viper.GetString("APNS_TOPIC"),
|
|
||||||
APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"),
|
|
||||||
APNSProduction: viper.GetBool("APNS_PRODUCTION"),
|
|
||||||
FCMProjectID: viper.GetString("FCM_PROJECT_ID"),
|
|
||||||
FCMServiceAccountPath: viper.GetString("FCM_SERVICE_ACCOUNT_PATH"),
|
|
||||||
FCMServiceAccountJSON: viper.GetString("FCM_SERVICE_ACCOUNT_JSON"),
|
|
||||||
FCMServerKey: viper.GetString("FCM_SERVER_KEY"),
|
|
||||||
},
|
|
||||||
Worker: WorkerConfig{
|
|
||||||
TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"),
|
|
||||||
OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"),
|
|
||||||
DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"),
|
|
||||||
},
|
|
||||||
Security: SecurityConfig{
|
|
||||||
SecretKey: viper.GetString("SECRET_KEY"),
|
|
||||||
TokenCacheTTL: 5 * time.Minute,
|
|
||||||
PasswordResetExpiry: 15 * time.Minute,
|
|
||||||
ConfirmationExpiry: 24 * time.Hour,
|
|
||||||
MaxPasswordResetRate: 3,
|
|
||||||
TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"),
|
|
||||||
TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"),
|
|
||||||
},
|
|
||||||
Storage: StorageConfig{
|
|
||||||
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
|
|
||||||
BaseURL: viper.GetString("STORAGE_BASE_URL"),
|
|
||||||
S3Endpoint: viper.GetString("B2_ENDPOINT"),
|
|
||||||
S3KeyID: viper.GetString("B2_KEY_ID"),
|
|
||||||
S3AppKey: viper.GetString("B2_APP_KEY"),
|
|
||||||
S3Bucket: viper.GetString("B2_BUCKET_NAME"),
|
|
||||||
S3UseSSL: viper.GetString("STORAGE_USE_SSL") == "" || viper.GetBool("STORAGE_USE_SSL"),
|
|
||||||
S3Region: viper.GetString("B2_REGION"),
|
|
||||||
MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"),
|
|
||||||
AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"),
|
|
||||||
EncryptionKey: viper.GetString("STORAGE_ENCRYPTION_KEY"),
|
|
||||||
},
|
|
||||||
AppleAuth: AppleAuthConfig{
|
|
||||||
ClientID: viper.GetString("APPLE_CLIENT_ID"),
|
|
||||||
TeamID: viper.GetString("APPLE_TEAM_ID"),
|
|
||||||
},
|
|
||||||
GoogleAuth: GoogleAuthConfig{
|
|
||||||
ClientID: viper.GetString("GOOGLE_CLIENT_ID"),
|
|
||||||
AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"),
|
|
||||||
IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"),
|
|
||||||
},
|
|
||||||
AppleIAP: AppleIAPConfig{
|
|
||||||
KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"),
|
|
||||||
KeyID: viper.GetString("APPLE_IAP_KEY_ID"),
|
|
||||||
IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"),
|
|
||||||
BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"),
|
|
||||||
Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"),
|
|
||||||
},
|
|
||||||
GoogleIAP: GoogleIAPConfig{
|
|
||||||
ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"),
|
|
||||||
PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"),
|
|
||||||
},
|
|
||||||
Stripe: StripeConfig{
|
|
||||||
SecretKey: viper.GetString("STRIPE_SECRET_KEY"),
|
|
||||||
WebhookSecret: viper.GetString("STRIPE_WEBHOOK_SECRET"),
|
|
||||||
PriceMonthly: viper.GetString("STRIPE_PRICE_MONTHLY"),
|
|
||||||
PriceYearly: viper.GetString("STRIPE_PRICE_YEARLY"),
|
|
||||||
},
|
|
||||||
Features: FeatureFlags{
|
|
||||||
PushEnabled: viper.GetBool("FEATURE_PUSH_ENABLED"),
|
|
||||||
EmailEnabled: viper.GetBool("FEATURE_EMAIL_ENABLED"),
|
|
||||||
WebhooksEnabled: viper.GetBool("FEATURE_WEBHOOKS_ENABLED"),
|
|
||||||
OnboardingEmailsEnabled: viper.GetBool("FEATURE_ONBOARDING_EMAILS_ENABLED"),
|
|
||||||
PDFReportsEnabled: viper.GetBool("FEATURE_PDF_REPORTS_ENABLED"),
|
|
||||||
WorkerEnabled: viper.GetBool("FEATURE_WORKER_ENABLED"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if err := validate(cfg); err != nil {
|
|
||||||
loadErr = err
|
|
||||||
// Reset so a subsequent call can retry after env is fixed
|
|
||||||
cfg = nil
|
|
||||||
cfgOnce = sync.Once{}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if loadErr != nil {
|
|
||||||
return nil, loadErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viper.SetEnvPrefix("")
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
setDefaults()
|
||||||
|
|
||||||
|
// Parse DATABASE_URL if set (Dokku-style)
|
||||||
|
dbConfig := DatabaseConfig{
|
||||||
|
Host: viper.GetString("DB_HOST"),
|
||||||
|
Port: viper.GetInt("DB_PORT"),
|
||||||
|
User: viper.GetString("POSTGRES_USER"),
|
||||||
|
Password: viper.GetString("POSTGRES_PASSWORD"),
|
||||||
|
Database: viper.GetString("POSTGRES_DB"),
|
||||||
|
SSLMode: viper.GetString("DB_SSLMODE"),
|
||||||
|
MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"),
|
||||||
|
MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"),
|
||||||
|
MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"),
|
||||||
|
MaxIdleTime: viper.GetDuration("DB_MAX_IDLE_TIME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with DATABASE_URL if present (F-16: log warning on parse failure)
|
||||||
|
if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" {
|
||||||
|
parsed, err := parseDatabaseURL(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
maskedURL := MaskURLCredentials(databaseURL)
|
||||||
|
fmt.Printf("WARNING: Failed to parse DATABASE_URL (%s): %v — falling back to individual DB_* env vars\n", maskedURL, err)
|
||||||
|
} else {
|
||||||
|
dbConfig.Host = parsed.Host
|
||||||
|
dbConfig.Port = parsed.Port
|
||||||
|
dbConfig.User = parsed.User
|
||||||
|
dbConfig.Password = parsed.Password
|
||||||
|
dbConfig.Database = parsed.Database
|
||||||
|
if parsed.SSLMode != "" {
|
||||||
|
dbConfig.SSLMode = parsed.SSLMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Port: viper.GetInt("PORT"),
|
||||||
|
Debug: viper.GetBool("DEBUG"),
|
||||||
|
DebugFixedCodes: viper.GetBool("DEBUG_FIXED_CODES"),
|
||||||
|
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
||||||
|
CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
||||||
|
Timezone: viper.GetString("TIMEZONE"),
|
||||||
|
StaticDir: viper.GetString("STATIC_DIR"),
|
||||||
|
BaseURL: viper.GetString("BASE_URL"),
|
||||||
|
},
|
||||||
|
Database: dbConfig,
|
||||||
|
Redis: RedisConfig{
|
||||||
|
URL: viper.GetString("REDIS_URL"),
|
||||||
|
Password: viper.GetString("REDIS_PASSWORD"),
|
||||||
|
DB: viper.GetInt("REDIS_DB"),
|
||||||
|
},
|
||||||
|
Email: EmailConfig{
|
||||||
|
Host: viper.GetString("EMAIL_HOST"),
|
||||||
|
Port: viper.GetInt("EMAIL_PORT"),
|
||||||
|
User: viper.GetString("EMAIL_HOST_USER"),
|
||||||
|
Password: viper.GetString("EMAIL_HOST_PASSWORD"),
|
||||||
|
From: viper.GetString("DEFAULT_FROM_EMAIL"),
|
||||||
|
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
||||||
|
},
|
||||||
|
Push: PushConfig{
|
||||||
|
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
||||||
|
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
||||||
|
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
||||||
|
APNSTopic: viper.GetString("APNS_TOPIC"),
|
||||||
|
APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"),
|
||||||
|
APNSProduction: viper.GetBool("APNS_PRODUCTION"),
|
||||||
|
FCMProjectID: viper.GetString("FCM_PROJECT_ID"),
|
||||||
|
FCMServiceAccountPath: viper.GetString("FCM_SERVICE_ACCOUNT_PATH"),
|
||||||
|
FCMServiceAccountJSON: viper.GetString("FCM_SERVICE_ACCOUNT_JSON"),
|
||||||
|
FCMServerKey: viper.GetString("FCM_SERVER_KEY"),
|
||||||
|
},
|
||||||
|
Worker: WorkerConfig{
|
||||||
|
TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"),
|
||||||
|
OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"),
|
||||||
|
DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"),
|
||||||
|
},
|
||||||
|
Security: SecurityConfig{
|
||||||
|
SecretKey: viper.GetString("SECRET_KEY"),
|
||||||
|
TokenCacheTTL: 5 * time.Minute,
|
||||||
|
PasswordResetExpiry: 15 * time.Minute,
|
||||||
|
ConfirmationExpiry: 24 * time.Hour,
|
||||||
|
MaxPasswordResetRate: 3,
|
||||||
|
TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"),
|
||||||
|
TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"),
|
||||||
|
},
|
||||||
|
Storage: StorageConfig{
|
||||||
|
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
|
||||||
|
BaseURL: viper.GetString("STORAGE_BASE_URL"),
|
||||||
|
S3Endpoint: viper.GetString("B2_ENDPOINT"),
|
||||||
|
S3KeyID: viper.GetString("B2_KEY_ID"),
|
||||||
|
S3AppKey: viper.GetString("B2_APP_KEY"),
|
||||||
|
S3Bucket: viper.GetString("B2_BUCKET_NAME"),
|
||||||
|
S3UseSSL: viper.GetString("STORAGE_USE_SSL") == "" || viper.GetBool("STORAGE_USE_SSL"),
|
||||||
|
S3Region: viper.GetString("B2_REGION"),
|
||||||
|
MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"),
|
||||||
|
AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"),
|
||||||
|
EncryptionKey: viper.GetString("STORAGE_ENCRYPTION_KEY"),
|
||||||
|
},
|
||||||
|
AppleAuth: AppleAuthConfig{
|
||||||
|
ClientID: viper.GetString("APPLE_CLIENT_ID"),
|
||||||
|
TeamID: viper.GetString("APPLE_TEAM_ID"),
|
||||||
|
},
|
||||||
|
GoogleAuth: GoogleAuthConfig{
|
||||||
|
ClientID: viper.GetString("GOOGLE_CLIENT_ID"),
|
||||||
|
AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"),
|
||||||
|
IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"),
|
||||||
|
},
|
||||||
|
AppleIAP: AppleIAPConfig{
|
||||||
|
KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"),
|
||||||
|
KeyID: viper.GetString("APPLE_IAP_KEY_ID"),
|
||||||
|
IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"),
|
||||||
|
BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"),
|
||||||
|
Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"),
|
||||||
|
},
|
||||||
|
GoogleIAP: GoogleIAPConfig{
|
||||||
|
ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"),
|
||||||
|
PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"),
|
||||||
|
},
|
||||||
|
Stripe: StripeConfig{
|
||||||
|
SecretKey: viper.GetString("STRIPE_SECRET_KEY"),
|
||||||
|
WebhookSecret: viper.GetString("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
PriceMonthly: viper.GetString("STRIPE_PRICE_MONTHLY"),
|
||||||
|
PriceYearly: viper.GetString("STRIPE_PRICE_YEARLY"),
|
||||||
|
},
|
||||||
|
Features: FeatureFlags{
|
||||||
|
PushEnabled: viper.GetBool("FEATURE_PUSH_ENABLED"),
|
||||||
|
EmailEnabled: viper.GetBool("FEATURE_EMAIL_ENABLED"),
|
||||||
|
WebhooksEnabled: viper.GetBool("FEATURE_WEBHOOKS_ENABLED"),
|
||||||
|
OnboardingEmailsEnabled: viper.GetBool("FEATURE_ONBOARDING_EMAILS_ENABLED"),
|
||||||
|
PDFReportsEnabled: viper.GetBool("FEATURE_PDF_REPORTS_ENABLED"),
|
||||||
|
WorkerEnabled: viper.GetBool("FEATURE_WORKER_ENABLED"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validate(c); err != nil {
|
||||||
|
// Leave cfg nil so the next Load() retries after env is corrected.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg = c
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -11,8 +10,9 @@ import (
|
|||||||
|
|
||||||
// resetConfigState resets the package-level singleton so each test starts fresh.
|
// resetConfigState resets the package-level singleton so each test starts fresh.
|
||||||
func resetConfigState() {
|
func resetConfigState() {
|
||||||
|
cfgMu.Lock()
|
||||||
cfg = nil
|
cfg = nil
|
||||||
cfgOnce = sync.Once{}
|
cfgMu.Unlock()
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user