Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
170
consent-service/internal/config/config.go
Normal file
170
consent-service/internal/config/config.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the service
|
||||
type Config struct {
|
||||
// Server
|
||||
Port string
|
||||
Environment string
|
||||
|
||||
// Database
|
||||
DatabaseURL string
|
||||
|
||||
// JWT
|
||||
JWTSecret string
|
||||
JWTRefreshSecret string
|
||||
|
||||
// CORS
|
||||
AllowedOrigins []string
|
||||
|
||||
// Rate Limiting
|
||||
RateLimitRequests int
|
||||
RateLimitWindow int // in seconds
|
||||
|
||||
// BreakPilot Integration
|
||||
BreakPilotAPIURL string
|
||||
FrontendURL string
|
||||
|
||||
// SMTP Email Configuration
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPFromName string
|
||||
SMTPFromAddr string
|
||||
|
||||
// Consent Settings
|
||||
ConsentDeadlineDays int
|
||||
ConsentReminderEnabled bool
|
||||
|
||||
// VAPID Keys for Web Push
|
||||
VAPIDPublicKey string
|
||||
VAPIDPrivateKey string
|
||||
|
||||
// Matrix (Synapse) Configuration
|
||||
MatrixHomeserverURL string
|
||||
MatrixAccessToken string
|
||||
MatrixServerName string
|
||||
MatrixEnabled bool
|
||||
|
||||
// Jitsi Configuration
|
||||
JitsiBaseURL string
|
||||
JitsiAppID string
|
||||
JitsiAppSecret string
|
||||
JitsiEnabled bool
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file if exists (for development)
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", ""),
|
||||
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
||||
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
||||
BreakPilotAPIURL: getEnv("BREAKPILOT_API_URL", "http://localhost:8000"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:8000"),
|
||||
|
||||
// SMTP Configuration
|
||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||
SMTPUsername: getEnv("SMTP_USERNAME", ""),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "BreakPilot"),
|
||||
SMTPFromAddr: getEnv("SMTP_FROM_ADDR", "noreply@breakpilot.app"),
|
||||
|
||||
// Consent Settings
|
||||
ConsentDeadlineDays: getEnvInt("CONSENT_DEADLINE_DAYS", 30),
|
||||
ConsentReminderEnabled: getEnvBool("CONSENT_REMINDER_ENABLED", true),
|
||||
|
||||
// VAPID Keys
|
||||
VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""),
|
||||
VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""),
|
||||
|
||||
// Matrix Configuration
|
||||
MatrixHomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://synapse:8008"),
|
||||
MatrixAccessToken: getEnv("MATRIX_ACCESS_TOKEN", ""),
|
||||
MatrixServerName: getEnv("MATRIX_SERVER_NAME", "breakpilot.local"),
|
||||
MatrixEnabled: getEnvBool("MATRIX_ENABLED", true),
|
||||
|
||||
// Jitsi Configuration
|
||||
JitsiBaseURL: getEnv("JITSI_BASE_URL", "http://localhost:8443"),
|
||||
JitsiAppID: getEnv("JITSI_APP_ID", "breakpilot"),
|
||||
JitsiAppSecret: getEnv("JITSI_APP_SECRET", ""),
|
||||
JitsiEnabled: getEnvBool("JITSI_ENABLED", true),
|
||||
}
|
||||
|
||||
// Parse allowed origins
|
||||
originsStr := getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000")
|
||||
cfg.AllowedOrigins = parseCommaSeparated(originsStr)
|
||||
|
||||
// Validate required fields
|
||||
if cfg.DatabaseURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
if cfg.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
var result int
|
||||
fmt.Sscanf(value, "%d", &result)
|
||||
return result
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value == "true" || value == "1" || value == "yes"
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func parseCommaSeparated(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
var result []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == ',' {
|
||||
item := s[start:i]
|
||||
// Trim whitespace
|
||||
for len(item) > 0 && item[0] == ' ' {
|
||||
item = item[1:]
|
||||
}
|
||||
for len(item) > 0 && item[len(item)-1] == ' ' {
|
||||
item = item[:len(item)-1]
|
||||
}
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
322
consent-service/internal/config/config_test.go
Normal file
322
consent-service/internal/config/config_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetEnv tests the getEnv helper function
|
||||
func TestGetEnv(t *testing.T) {
|
||||
// Test with default value when env var not set
|
||||
result := getEnv("TEST_NONEXISTENT_VAR_12345", "default")
|
||||
if result != "default" {
|
||||
t.Errorf("Expected 'default', got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with set env var
|
||||
os.Setenv("TEST_ENV_VAR", "custom_value")
|
||||
defer os.Unsetenv("TEST_ENV_VAR")
|
||||
|
||||
result = getEnv("TEST_ENV_VAR", "default")
|
||||
if result != "custom_value" {
|
||||
t.Errorf("Expected 'custom_value', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetEnvInt tests the getEnvInt helper function
|
||||
func TestGetEnvInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
defaultValue int
|
||||
expected int
|
||||
}{
|
||||
{"default when not set", "", 100, 100},
|
||||
{"parse valid int", "42", 0, 42},
|
||||
{"parse zero", "0", 100, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv("TEST_INT_VAR", tt.envValue)
|
||||
defer os.Unsetenv("TEST_INT_VAR")
|
||||
} else {
|
||||
os.Unsetenv("TEST_INT_VAR")
|
||||
}
|
||||
|
||||
result := getEnvInt("TEST_INT_VAR", tt.defaultValue)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetEnvBool tests the getEnvBool helper function
|
||||
func TestGetEnvBool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
defaultValue bool
|
||||
expected bool
|
||||
}{
|
||||
{"default when not set", "", true, true},
|
||||
{"default false when not set", "", false, false},
|
||||
{"parse true", "true", false, true},
|
||||
{"parse 1", "1", false, true},
|
||||
{"parse yes", "yes", false, true},
|
||||
{"parse false", "false", true, false},
|
||||
{"parse 0", "0", true, false},
|
||||
{"parse no", "no", true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv("TEST_BOOL_VAR", tt.envValue)
|
||||
defer os.Unsetenv("TEST_BOOL_VAR")
|
||||
} else {
|
||||
os.Unsetenv("TEST_BOOL_VAR")
|
||||
}
|
||||
|
||||
result := getEnvBool("TEST_BOOL_VAR", tt.defaultValue)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCommaSeparated tests the parseCommaSeparated helper function
|
||||
func TestParseCommaSeparated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"empty string", "", []string{}},
|
||||
{"single value", "value1", []string{"value1"}},
|
||||
{"multiple values", "value1,value2,value3", []string{"value1", "value2", "value3"}},
|
||||
{"with spaces", "value1, value2, value3", []string{"value1", "value2", "value3"}},
|
||||
{"with trailing comma", "value1,value2,", []string{"value1", "value2"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseCommaSeparated(tt.input)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("Expected length %d, got %d", len(tt.expected), len(result))
|
||||
return
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("At index %d: expected '%s', got '%s'", i, tt.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigEnvironmentDefaults tests default environment values
|
||||
func TestConfigEnvironmentDefaults(t *testing.T) {
|
||||
// Clear any existing env vars that might interfere
|
||||
varsToUnset := []string{
|
||||
"PORT", "ENVIRONMENT", "DATABASE_URL", "JWT_SECRET", "JWT_REFRESH_SECRET",
|
||||
}
|
||||
for _, v := range varsToUnset {
|
||||
os.Unsetenv(v)
|
||||
}
|
||||
|
||||
// Set required vars
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here")
|
||||
defer func() {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
}()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Test defaults
|
||||
if cfg.Port != "8080" {
|
||||
t.Errorf("Expected default port '8080', got '%s'", cfg.Port)
|
||||
}
|
||||
|
||||
if cfg.Environment != "development" {
|
||||
t.Errorf("Expected default environment 'development', got '%s'", cfg.Environment)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLoadWithEnvironment tests loading config with different environments
|
||||
func TestConfigLoadWithEnvironment(t *testing.T) {
|
||||
// Set required vars
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here")
|
||||
defer func() {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
environment string
|
||||
}{
|
||||
{"development", "development"},
|
||||
{"staging", "staging"},
|
||||
{"production", "production"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
os.Setenv("ENVIRONMENT", tt.environment)
|
||||
defer os.Unsetenv("ENVIRONMENT")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Environment != tt.environment {
|
||||
t.Errorf("Expected environment '%s', got '%s'", tt.environment, cfg.Environment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigMissingRequiredVars tests that missing required vars return errors
|
||||
func TestConfigMissingRequiredVars(t *testing.T) {
|
||||
// Clear all env vars
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when DATABASE_URL is missing")
|
||||
}
|
||||
|
||||
// Set DATABASE_URL but not JWT_SECRET
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
defer os.Unsetenv("DATABASE_URL")
|
||||
|
||||
_, err = Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when JWT_SECRET is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigAllowedOrigins tests that allowed origins are parsed correctly
|
||||
func TestConfigAllowedOrigins(t *testing.T) {
|
||||
// Set required vars
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here")
|
||||
os.Setenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000,http://localhost:8001")
|
||||
defer func() {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
os.Unsetenv("ALLOWED_ORIGINS")
|
||||
}()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
expected := []string{"http://localhost:3000", "http://localhost:8000", "http://localhost:8001"}
|
||||
if len(cfg.AllowedOrigins) != len(expected) {
|
||||
t.Errorf("Expected %d origins, got %d", len(expected), len(cfg.AllowedOrigins))
|
||||
}
|
||||
|
||||
for i, origin := range cfg.AllowedOrigins {
|
||||
if origin != expected[i] {
|
||||
t.Errorf("At index %d: expected '%s', got '%s'", i, expected[i], origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigDebugSettings tests debug-related settings for different environments
|
||||
func TestConfigDebugSettings(t *testing.T) {
|
||||
// Set required vars
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here")
|
||||
defer func() {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
}()
|
||||
|
||||
// Test development environment
|
||||
t.Run("development", func(t *testing.T) {
|
||||
os.Setenv("ENVIRONMENT", "development")
|
||||
defer os.Unsetenv("ENVIRONMENT")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Environment != "development" {
|
||||
t.Errorf("Expected 'development', got '%s'", cfg.Environment)
|
||||
}
|
||||
})
|
||||
|
||||
// Test staging environment
|
||||
t.Run("staging", func(t *testing.T) {
|
||||
os.Setenv("ENVIRONMENT", "staging")
|
||||
defer os.Unsetenv("ENVIRONMENT")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Environment != "staging" {
|
||||
t.Errorf("Expected 'staging', got '%s'", cfg.Environment)
|
||||
}
|
||||
})
|
||||
|
||||
// Test production environment
|
||||
t.Run("production", func(t *testing.T) {
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
defer os.Unsetenv("ENVIRONMENT")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Environment != "production" {
|
||||
t.Errorf("Expected 'production', got '%s'", cfg.Environment)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigStagingPorts tests that staging uses different ports
|
||||
func TestConfigStagingPorts(t *testing.T) {
|
||||
// Set required vars
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5433/breakpilot_staging")
|
||||
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here")
|
||||
os.Setenv("ENVIRONMENT", "staging")
|
||||
os.Setenv("PORT", "8081")
|
||||
defer func() {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
os.Unsetenv("ENVIRONMENT")
|
||||
os.Unsetenv("PORT")
|
||||
}()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "8081" {
|
||||
t.Errorf("Expected staging port '8081', got '%s'", cfg.Port)
|
||||
}
|
||||
|
||||
if cfg.Environment != "staging" {
|
||||
t.Errorf("Expected 'staging', got '%s'", cfg.Environment)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user