This commit is contained in:
2026-03-01 22:52:08 +07:00
commit 1244f3e2d5
63 changed files with 6075 additions and 0 deletions

138
internal/config/config.go Normal file
View File

@@ -0,0 +1,138 @@
package config
import (
"fmt"
"log/slog"
"os"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
"go.uber.org/fx"
// spooky action at a distance!
// this populates the environment
// from a ./.env file automatically
// for development configuration.
// .env contents should be things like
// `DBURL=postgres://user:pass@.../`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
)
const (
// EnvironmentDev represents development environment
EnvironmentDev = "dev"
// EnvironmentProd represents production environment
EnvironmentProd = "prod"
// DevSessionKey is an insecure default session key for development
// This is "webhooker-dev-session-key-insecure!" base64 encoded
DevSessionKey = "d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE="
)
// nolint:revive // ConfigParams is a standard fx naming convention
type ConfigParams struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
type Config struct {
DBURL string
Debug bool
MaintenanceMode bool
DevelopmentMode bool
DevAdminUsername string
DevAdminPassword string
Environment string
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
SessionKey string
params *ConfigParams
log *slog.Logger
}
// IsDev returns true if running in development environment
func (c *Config) IsDev() bool {
return c.Environment == EnvironmentDev
}
// IsProd returns true if running in production environment
func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd
}
// nolint:revive // lc parameter is required by fx even if unused
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get()
// Determine environment from WEBHOOKER_ENVIRONMENT env var, default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
// Validate environment
if environment != EnvironmentDev && environment != EnvironmentProd {
return nil, fmt.Errorf("WEBHOOKER_ENVIRONMENT must be either '%s' or '%s', got '%s'",
EnvironmentDev, EnvironmentProd, environment)
}
// Set the environment in the config package
pkgconfig.SetEnvironment(environment)
// Load configuration values
s := &Config{
DBURL: pkgconfig.GetString("dburl"),
Debug: pkgconfig.GetBool("debug"),
MaintenanceMode: pkgconfig.GetBool("maintenanceMode"),
DevelopmentMode: pkgconfig.GetBool("developmentMode"),
DevAdminUsername: pkgconfig.GetString("devAdminUsername"),
DevAdminPassword: pkgconfig.GetString("devAdminPassword"),
Environment: pkgconfig.GetString("environment", environment),
MetricsUsername: pkgconfig.GetString("metricsUsername"),
MetricsPassword: pkgconfig.GetString("metricsPassword"),
Port: pkgconfig.GetInt("port", 8080),
SentryDSN: pkgconfig.GetSecretString("sentryDSN"),
SessionKey: pkgconfig.GetSecretString("sessionKey"),
log: log,
params: &params,
}
// Validate database URL
if s.DBURL == "" {
return nil, fmt.Errorf("database URL (dburl) is required")
}
// In production, require session key
if s.IsProd() && s.SessionKey == "" {
return nil, fmt.Errorf("SESSION_KEY is required in production environment")
}
// In development mode, warn if using default session key
if s.IsDev() && s.SessionKey == DevSessionKey {
log.Warn("Using insecure default session key for development mode")
}
if s.Debug {
params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
log.Debug("Debug mode enabled")
}
// Log configuration summary (without secrets)
log.Info("Configuration loaded",
"environment", s.Environment,
"port", s.Port,
"debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"developmentMode", s.DevelopmentMode,
"hasSessionKey", s.SessionKey != "",
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
)
return s, nil
}

View File

@@ -0,0 +1,300 @@
package config
import (
"os"
"testing"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
// createTestConfig creates a test configuration file in memory
func createTestConfig(fs afero.Fs) error {
configYAML := `
environments:
dev:
config:
port: 8080
debug: true
maintenanceMode: false
developmentMode: true
environment: dev
dburl: postgres://test:test@localhost:5432/test_dev?sslmode=disable
metricsUsername: testuser
metricsPassword: testpass
devAdminUsername: devadmin
devAdminPassword: devpass
secrets:
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=
sentryDSN: ""
prod:
config:
port: $ENV:PORT
debug: $ENV:DEBUG
maintenanceMode: $ENV:MAINTENANCE_MODE
developmentMode: false
environment: prod
dburl: $ENV:DBURL
metricsUsername: $ENV:METRICS_USERNAME
metricsPassword: $ENV:METRICS_PASSWORD
devAdminUsername: ""
devAdminPassword: ""
secrets:
sessionKey: $ENV:SESSION_KEY
sentryDSN: $ENV:SENTRY_DSN
configDefaults:
port: 8080
debug: false
maintenanceMode: false
developmentMode: false
environment: dev
metricsUsername: ""
metricsPassword: ""
devAdminUsername: ""
devAdminPassword: ""
`
return afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644)
}
func TestEnvironmentConfig(t *testing.T) {
tests := []struct {
name string
envValue string
envVars map[string]string
expectError bool
isDev bool
isProd bool
}{
{
name: "default is dev",
envValue: "",
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit dev",
envValue: "dev",
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit prod with session key",
envValue: "prod",
envVars: map[string]string{
"SESSION_KEY": "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
"DBURL": "postgres://prod:prod@localhost:5432/prod?sslmode=require",
},
expectError: false,
isDev: false,
isProd: true,
},
{
name: "invalid environment",
envValue: "staging",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create in-memory filesystem with test config
fs := afero.NewMemMapFs()
require.NoError(t, createTestConfig(fs))
pkgconfig.SetFs(fs)
// Set environment variable if specified
if tt.envValue != "" {
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
}
// Set additional environment variables
for k, v := range tt.envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
if tt.expectError {
// Use regular fx.New for error cases since fxtest doesn't expose errors the same way
var cfg *Config
app := fx.New(
fx.NopLogger, // Suppress fx logs in tests
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
} else {
// Use fxtest for success cases
var cfg *Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, tt.isDev, cfg.IsDev())
assert.Equal(t, tt.isProd, cfg.IsProd())
}
})
}
}
func TestSessionKeyDefaults(t *testing.T) {
tests := []struct {
name string
environment string
sessionKey string
dburl string
expectError bool
expectedKey string
}{
{
name: "dev mode with default session key",
environment: "dev",
sessionKey: "",
expectError: false,
expectedKey: DevSessionKey,
},
{
name: "dev mode with custom session key",
environment: "dev",
sessionKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
expectError: false,
expectedKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
},
{
name: "prod mode with no session key fails",
environment: "prod",
sessionKey: "",
dburl: "postgres://prod:prod@localhost:5432/prod",
expectError: true,
},
{
name: "prod mode with session key succeeds",
environment: "prod",
sessionKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
dburl: "postgres://prod:prod@localhost:5432/prod",
expectError: false,
expectedKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create in-memory filesystem with test config
fs := afero.NewMemMapFs()
// Create custom config for session key tests
configYAML := `
environments:
dev:
config:
environment: dev
developmentMode: true
dburl: postgres://test:test@localhost:5432/test_dev
secrets:`
// Only add sessionKey line if it's not empty
if tt.sessionKey != "" {
configYAML += `
sessionKey: ` + tt.sessionKey
} else if tt.environment == "dev" {
// For dev mode with no session key, use the default
configYAML += `
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=`
}
// Add prod config if testing prod
if tt.environment == "prod" {
configYAML += `
prod:
config:
environment: prod
developmentMode: false
dburl: $ENV:DBURL
secrets:
sessionKey: $ENV:SESSION_KEY`
}
require.NoError(t, afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644))
pkgconfig.SetFs(fs)
// Clean up any existing env vars
os.Unsetenv("WEBHOOKER_ENVIRONMENT")
os.Unsetenv("SESSION_KEY")
os.Unsetenv("DBURL")
// Set environment variables
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
if tt.sessionKey != "" && tt.environment == "prod" {
os.Setenv("SESSION_KEY", tt.sessionKey)
defer os.Unsetenv("SESSION_KEY")
}
if tt.dburl != "" {
os.Setenv("DBURL", tt.dburl)
defer os.Unsetenv("DBURL")
}
if tt.expectError {
// Use regular fx.New for error cases
var cfg *Config
app := fx.New(
fx.NopLogger, // Suppress fx logs in tests
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
} else {
// Use fxtest for success cases
var cfg *Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
if tt.environment == "dev" && tt.sessionKey == "" {
// Dev mode with no session key uses default
assert.Equal(t, DevSessionKey, cfg.SessionKey)
} else {
assert.Equal(t, tt.expectedKey, cfg.SessionKey)
}
}
})
}
}