summaryrefslogtreecommitdiffstats
path: root/internal/config
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/README.md440
-rw-r--r--internal/config/config.go324
-rw-r--r--internal/config/config_test.go288
3 files changed, 1052 insertions, 0 deletions
diff --git a/internal/config/README.md b/internal/config/README.md
new file mode 100644
index 0000000..87d6fa1
--- /dev/null
+++ b/internal/config/README.md
@@ -0,0 +1,440 @@
1# Configuration
2
3This package provides configuration management for the relay with support for YAML files and environment variable overrides.
4
5## Overview
6
7Configuration can be loaded from:
81. **YAML file** - Primary configuration source
92. **Environment variables** - Override file values
103. **Defaults** - Sensible defaults if not specified
11
12## Usage
13
14### Load from File
15
16```go
17import "northwest.io/muxstr/internal/config"
18
19// Load configuration
20cfg, err := config.Load("config.yaml")
21if err != nil {
22 log.Fatal(err)
23}
24
25// Use configuration
26fmt.Printf("gRPC listening on %s\n", cfg.Server.GrpcAddr)
27```
28
29### Load with Environment Overrides
30
31```bash
32# Set environment variables
33export MUXSTR_SERVER_GRPC_ADDR=":50051"
34export MUXSTR_AUTH_REQUIRED=true
35export MUXSTR_RATE_LIMIT_DEFAULT_RPS=100
36
37# Run relay
38./relay -config config.yaml
39```
40
41Environment variables use the format: `MUXSTR_<SECTION>_<KEY>`
42
43### Use Defaults
44
45```go
46// Get default configuration
47cfg := config.Default()
48```
49
50## Configuration File Format
51
52### Complete Example
53
54```yaml
55# Server configuration
56server:
57 # gRPC server address
58 grpc_addr: ":50051"
59
60 # HTTP server address (for Connect and WebSocket)
61 http_addr: ":8080"
62
63 # Public URL for reverse proxy deployments (optional)
64 # Example: "relay.example.com"
65 public_url: ""
66
67 # Read timeout for requests (optional)
68 read_timeout: "30s"
69
70 # Write timeout for responses (optional)
71 write_timeout: "30s"
72
73# Database configuration
74database:
75 # Path to SQLite database file
76 path: "relay.db"
77
78 # Maximum number of open connections
79 max_connections: 10
80
81 # Connection max lifetime
82 max_lifetime: "1h"
83
84# Authentication configuration
85auth:
86 # Enable authentication
87 enabled: false
88
89 # Require authentication for all requests
90 # If false, authentication is optional (pubkey available if provided)
91 required: false
92
93 # Timestamp window in seconds for replay protection
94 timestamp_window: 60
95
96 # Allowed pubkeys (optional, whitelist)
97 # If empty, all valid signatures are accepted
98 allowed_pubkeys: []
99
100 # Skip authentication for these methods
101 skip_methods:
102 - "/grpc.health.v1.Health/Check"
103
104# Rate limiting configuration
105rate_limit:
106 # Enable rate limiting
107 enabled: false
108
109 # Default rate limit (requests per second)
110 default_rps: 10
111
112 # Default burst size (token bucket capacity)
113 default_burst: 20
114
115 # Rate limit for unauthenticated users (per IP)
116 ip_rps: 5
117 ip_burst: 10
118
119 # Method-specific limits
120 methods:
121 "/nostr.v1.NostrRelay/PublishEvent":
122 rps: 2
123 burst: 5
124 "/nostr.v1.NostrRelay/Subscribe":
125 rps: 1
126 burst: 3
127
128 # User-specific limits (VIP/premium users)
129 users:
130 "vip-pubkey-here":
131 rps: 100
132 burst: 200
133
134 # Skip rate limiting for these methods
135 skip_methods:
136 - "/grpc.health.v1.Health/Check"
137
138 # Skip rate limiting for these pubkeys (admins)
139 skip_users: []
140
141 # Cleanup interval for idle limiters
142 cleanup_interval: "5m"
143
144 # Max idle time before limiter is removed
145 max_idle_time: "10m"
146
147# Metrics configuration
148metrics:
149 # Enable Prometheus metrics
150 enabled: true
151
152 # Metrics HTTP server address
153 addr: ":9090"
154
155 # Metrics path
156 path: "/metrics"
157
158 # Namespace for metrics
159 namespace: "muxstr"
160
161 # Subsystem for metrics
162 subsystem: "relay"
163
164# Logging configuration
165logging:
166 # Log level: debug, info, warn, error
167 level: "info"
168
169 # Log format: json, text
170 format: "json"
171
172 # Output: stdout, stderr, or file path
173 output: "stdout"
174
175# Storage configuration
176storage:
177 # Enable automatic compaction
178 auto_compact: true
179
180 # Compact interval
181 compact_interval: "24h"
182
183 # Maximum event age (0 = unlimited)
184 max_event_age: "0"
185```
186
187### Minimal Example
188
189```yaml
190server:
191 grpc_addr: ":50051"
192 http_addr: ":8080"
193
194database:
195 path: "relay.db"
196
197metrics:
198 enabled: true
199 addr: ":9090"
200```
201
202## Environment Variables
203
204All configuration values can be overridden with environment variables using the pattern:
205
206```
207MUXSTR_<SECTION>_<SUBSECTION>_<KEY>=value
208```
209
210Examples:
211
212| Config Path | Environment Variable |
213|-------------|---------------------|
214| `server.grpc_addr` | `MUXSTR_SERVER_GRPC_ADDR` |
215| `database.path` | `MUXSTR_DATABASE_PATH` |
216| `auth.required` | `MUXSTR_AUTH_REQUIRED` |
217| `rate_limit.default_rps` | `MUXSTR_RATE_LIMIT_DEFAULT_RPS` |
218| `metrics.enabled` | `MUXSTR_METRICS_ENABLED` |
219
220Complex types:
221
222```bash
223# Lists (comma-separated)
224export MUXSTR_AUTH_ALLOWED_PUBKEYS="pubkey1,pubkey2,pubkey3"
225
226# Durations
227export MUXSTR_SERVER_READ_TIMEOUT="30s"
228export MUXSTR_DATABASE_MAX_LIFETIME="1h"
229
230# Booleans
231export MUXSTR_AUTH_ENABLED=true
232export MUXSTR_METRICS_ENABLED=false
233```
234
235## Validation
236
237Configuration is validated on load:
238
239```go
240cfg, err := config.Load("config.yaml")
241if err != nil {
242 // Validation errors include detailed messages
243 log.Fatalf("Invalid configuration: %v", err)
244}
245```
246
247Validation checks:
248- Required fields are present
249- Addresses are valid (host:port format)
250- File paths are accessible
251- Numeric values are in valid ranges
252- Durations are parseable
253
254## Default Values
255
256If not specified, the following defaults are used:
257
258```go
259Server:
260 GrpcAddr: ":50051"
261 HttpAddr: ":8080"
262 ReadTimeout: 30s
263 WriteTimeout: 30s
264
265Database:
266 Path: "relay.db"
267 MaxConnections: 10
268 MaxLifetime: 1h
269
270Auth:
271 Enabled: false
272 Required: false
273 TimestampWindow: 60
274
275RateLimit:
276 Enabled: false
277 DefaultRPS: 10
278 DefaultBurst: 20
279 IPRPS: 5
280 IPBurst: 10
281 CleanupInterval: 5m
282 MaxIdleTime: 10m
283
284Metrics:
285 Enabled: true
286 Addr: ":9090"
287 Path: "/metrics"
288 Namespace: "muxstr"
289 Subsystem: "relay"
290
291Logging:
292 Level: "info"
293 Format: "json"
294 Output: "stdout"
295```
296
297## Configuration Precedence
298
299Values are loaded in this order (later overrides earlier):
300
3011. **Defaults** - Built-in default values
3022. **Config file** - Values from YAML file
3033. **Environment variables** - OS environment overrides
304
305Example:
306```yaml
307# config.yaml
308server:
309 grpc_addr: ":50051"
310```
311
312```bash
313# Environment override
314export MUXSTR_SERVER_GRPC_ADDR=":9000"
315
316# Result: gRPC listens on :9000 (env var wins)
317```
318
319## Reloading Configuration
320
321Configuration can be reloaded without restart (future feature):
322
323```go
324// Watch for changes
325watcher, err := config.Watch("config.yaml")
326if err != nil {
327 log.Fatal(err)
328}
329
330for cfg := range watcher.Updates {
331 // Apply new configuration
332 updateServer(cfg)
333}
334```
335
336## Best Practices
337
3381. **Use config files for static settings**: Server addresses, paths, etc.
3392. **Use env vars for deployment-specific settings**: Secrets, environment-specific URLs
3403. **Keep secrets out of config files**: Use env vars or secret management
3414. **Version control your config**: Check in config.yaml (without secrets)
3425. **Document custom settings**: Add comments to config.yaml
3436. **Validate in CI**: Run `relay -config config.yaml -validate` in CI pipeline
3447. **Use different configs per environment**: `config.dev.yaml`, `config.prod.yaml`
345
346## Example Configurations
347
348### Development
349
350```yaml
351server:
352 grpc_addr: ":50051"
353 http_addr: ":8080"
354
355database:
356 path: "relay-dev.db"
357
358auth:
359 enabled: false
360
361rate_limit:
362 enabled: false
363
364metrics:
365 enabled: true
366 addr: ":9090"
367
368logging:
369 level: "debug"
370 format: "text"
371```
372
373### Production
374
375```yaml
376server:
377 grpc_addr: ":50051"
378 http_addr: ":8080"
379 public_url: "relay.example.com"
380 read_timeout: "30s"
381 write_timeout: "30s"
382
383database:
384 path: "/var/lib/muxstr/relay.db"
385 max_connections: 50
386
387auth:
388 enabled: true
389 required: false
390 timestamp_window: 60
391
392rate_limit:
393 enabled: true
394 default_rps: 10
395 default_burst: 20
396 methods:
397 "/nostr.v1.NostrRelay/PublishEvent":
398 rps: 2
399 burst: 5
400
401metrics:
402 enabled: true
403 addr: ":9090"
404
405logging:
406 level: "info"
407 format: "json"
408 output: "/var/log/muxstr/relay.log"
409```
410
411### High-Performance
412
413```yaml
414server:
415 grpc_addr: ":50051"
416 http_addr: ":8080"
417
418database:
419 path: "/mnt/fast-ssd/relay.db"
420 max_connections: 100
421 max_lifetime: "30m"
422
423auth:
424 enabled: true
425 required: true
426 timestamp_window: 30
427
428rate_limit:
429 enabled: true
430 default_rps: 100
431 default_burst: 200
432
433metrics:
434 enabled: true
435 addr: ":9090"
436
437logging:
438 level: "warn"
439 format: "json"
440```
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..87ca4eb
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,324 @@
1package config
2
3import (
4 "fmt"
5 "os"
6 "strings"
7 "time"
8
9 "gopkg.in/yaml.v3"
10)
11
12// Config holds all configuration for the relay.
13type Config struct {
14 Server ServerConfig `yaml:"server"`
15 Database DatabaseConfig `yaml:"database"`
16 Auth AuthConfig `yaml:"auth"`
17 RateLimit RateLimitConfig `yaml:"rate_limit"`
18 Metrics MetricsConfig `yaml:"metrics"`
19 Logging LoggingConfig `yaml:"logging"`
20 Storage StorageConfig `yaml:"storage"`
21}
22
23// ServerConfig holds server configuration.
24type ServerConfig struct {
25 GrpcAddr string `yaml:"grpc_addr"`
26 HttpAddr string `yaml:"http_addr"`
27 PublicURL string `yaml:"public_url"`
28 ReadTimeout time.Duration `yaml:"read_timeout"`
29 WriteTimeout time.Duration `yaml:"write_timeout"`
30}
31
32// DatabaseConfig holds database configuration.
33type DatabaseConfig struct {
34 Path string `yaml:"path"`
35 MaxConnections int `yaml:"max_connections"`
36 MaxLifetime time.Duration `yaml:"max_lifetime"`
37}
38
39// AuthConfig holds authentication configuration.
40type AuthConfig struct {
41 Enabled bool `yaml:"enabled"`
42 Required bool `yaml:"required"`
43 TimestampWindow int64 `yaml:"timestamp_window"`
44 AllowedPubkeys []string `yaml:"allowed_pubkeys"`
45 SkipMethods []string `yaml:"skip_methods"`
46}
47
48// RateLimitConfig holds rate limiting configuration.
49type RateLimitConfig struct {
50 Enabled bool `yaml:"enabled"`
51 DefaultRPS float64 `yaml:"default_rps"`
52 DefaultBurst int `yaml:"default_burst"`
53 IPRPS float64 `yaml:"ip_rps"`
54 IPBurst int `yaml:"ip_burst"`
55 Methods map[string]MethodLimit `yaml:"methods"`
56 Users map[string]UserLimit `yaml:"users"`
57 SkipMethods []string `yaml:"skip_methods"`
58 SkipUsers []string `yaml:"skip_users"`
59 CleanupInterval time.Duration `yaml:"cleanup_interval"`
60 MaxIdleTime time.Duration `yaml:"max_idle_time"`
61}
62
63// MethodLimit defines rate limits for a specific method.
64type MethodLimit struct {
65 RPS float64 `yaml:"rps"`
66 Burst int `yaml:"burst"`
67}
68
69// UserLimit defines rate limits for a specific user.
70type UserLimit struct {
71 RPS float64 `yaml:"rps"`
72 Burst int `yaml:"burst"`
73 Methods map[string]MethodLimit `yaml:"methods"`
74}
75
76// MetricsConfig holds metrics configuration.
77type MetricsConfig struct {
78 Enabled bool `yaml:"enabled"`
79 Addr string `yaml:"addr"`
80 Path string `yaml:"path"`
81 Namespace string `yaml:"namespace"`
82 Subsystem string `yaml:"subsystem"`
83}
84
85// LoggingConfig holds logging configuration.
86type LoggingConfig struct {
87 Level string `yaml:"level"`
88 Format string `yaml:"format"`
89 Output string `yaml:"output"`
90}
91
92// StorageConfig holds storage configuration.
93type StorageConfig struct {
94 AutoCompact bool `yaml:"auto_compact"`
95 CompactInterval time.Duration `yaml:"compact_interval"`
96 MaxEventAge time.Duration `yaml:"max_event_age"`
97}
98
99// Default returns the default configuration.
100func Default() *Config {
101 return &Config{
102 Server: ServerConfig{
103 GrpcAddr: ":50051",
104 HttpAddr: ":8080",
105 ReadTimeout: 30 * time.Second,
106 WriteTimeout: 30 * time.Second,
107 },
108 Database: DatabaseConfig{
109 Path: "relay.db",
110 MaxConnections: 10,
111 MaxLifetime: 1 * time.Hour,
112 },
113 Auth: AuthConfig{
114 Enabled: false,
115 Required: false,
116 TimestampWindow: 60,
117 },
118 RateLimit: RateLimitConfig{
119 Enabled: false,
120 DefaultRPS: 10,
121 DefaultBurst: 20,
122 IPRPS: 5,
123 IPBurst: 10,
124 CleanupInterval: 5 * time.Minute,
125 MaxIdleTime: 10 * time.Minute,
126 },
127 Metrics: MetricsConfig{
128 Enabled: true,
129 Addr: ":9090",
130 Path: "/metrics",
131 Namespace: "muxstr",
132 Subsystem: "relay",
133 },
134 Logging: LoggingConfig{
135 Level: "info",
136 Format: "json",
137 Output: "stdout",
138 },
139 Storage: StorageConfig{
140 AutoCompact: true,
141 CompactInterval: 24 * time.Hour,
142 MaxEventAge: 0, // unlimited
143 },
144 }
145}
146
147// Load loads configuration from a YAML file and applies environment variable overrides.
148func Load(filename string) (*Config, error) {
149 // Start with defaults
150 cfg := Default()
151
152 // Read file if provided
153 if filename != "" {
154 data, err := os.ReadFile(filename)
155 if err != nil {
156 return nil, fmt.Errorf("failed to read config file: %w", err)
157 }
158
159 if err := yaml.Unmarshal(data, cfg); err != nil {
160 return nil, fmt.Errorf("failed to parse config file: %w", err)
161 }
162 }
163
164 // Apply environment variable overrides
165 applyEnvOverrides(cfg)
166
167 // Validate
168 if err := cfg.Validate(); err != nil {
169 return nil, fmt.Errorf("invalid configuration: %w", err)
170 }
171
172 return cfg, nil
173}
174
175// Validate validates the configuration.
176func (c *Config) Validate() error {
177 // Validate server addresses
178 if c.Server.GrpcAddr == "" {
179 return fmt.Errorf("server.grpc_addr is required")
180 }
181 if c.Server.HttpAddr == "" {
182 return fmt.Errorf("server.http_addr is required")
183 }
184
185 // Validate database path
186 if c.Database.Path == "" {
187 return fmt.Errorf("database.path is required")
188 }
189
190 // Validate metrics config if enabled
191 if c.Metrics.Enabled {
192 if c.Metrics.Addr == "" {
193 return fmt.Errorf("metrics.addr is required when metrics enabled")
194 }
195 if c.Metrics.Namespace == "" {
196 return fmt.Errorf("metrics.namespace is required when metrics enabled")
197 }
198 }
199
200 // Validate logging
201 validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
202 if !validLevels[c.Logging.Level] {
203 return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level)
204 }
205
206 validFormats := map[string]bool{"json": true, "text": true}
207 if !validFormats[c.Logging.Format] {
208 return fmt.Errorf("invalid logging.format: %s (must be json or text)", c.Logging.Format)
209 }
210
211 return nil
212}
213
214// applyEnvOverrides applies environment variable overrides to the configuration.
215// Environment variables follow the pattern: MUXSTR_<SECTION>_<KEY>
216func applyEnvOverrides(cfg *Config) {
217 // Server
218 if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" {
219 cfg.Server.GrpcAddr = val
220 }
221 if val := os.Getenv("MUXSTR_SERVER_HTTP_ADDR"); val != "" {
222 cfg.Server.HttpAddr = val
223 }
224 if val := os.Getenv("MUXSTR_SERVER_PUBLIC_URL"); val != "" {
225 cfg.Server.PublicURL = val
226 }
227 if val := os.Getenv("MUXSTR_SERVER_READ_TIMEOUT"); val != "" {
228 if d, err := time.ParseDuration(val); err == nil {
229 cfg.Server.ReadTimeout = d
230 }
231 }
232 if val := os.Getenv("MUXSTR_SERVER_WRITE_TIMEOUT"); val != "" {
233 if d, err := time.ParseDuration(val); err == nil {
234 cfg.Server.WriteTimeout = d
235 }
236 }
237
238 // Database
239 if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" {
240 cfg.Database.Path = val
241 }
242 if val := os.Getenv("MUXSTR_DATABASE_MAX_CONNECTIONS"); val != "" {
243 var n int
244 if _, err := fmt.Sscanf(val, "%d", &n); err == nil {
245 cfg.Database.MaxConnections = n
246 }
247 }
248
249 // Auth
250 if val := os.Getenv("MUXSTR_AUTH_ENABLED"); val != "" {
251 cfg.Auth.Enabled = parseBool(val)
252 }
253 if val := os.Getenv("MUXSTR_AUTH_REQUIRED"); val != "" {
254 cfg.Auth.Required = parseBool(val)
255 }
256 if val := os.Getenv("MUXSTR_AUTH_TIMESTAMP_WINDOW"); val != "" {
257 var n int64
258 if _, err := fmt.Sscanf(val, "%d", &n); err == nil {
259 cfg.Auth.TimestampWindow = n
260 }
261 }
262 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_PUBKEYS"); val != "" {
263 cfg.Auth.AllowedPubkeys = strings.Split(val, ",")
264 }
265
266 // Rate limit
267 if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" {
268 cfg.RateLimit.Enabled = parseBool(val)
269 }
270 if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS"); val != "" {
271 var n float64
272 if _, err := fmt.Sscanf(val, "%f", &n); err == nil {
273 cfg.RateLimit.DefaultRPS = n
274 }
275 }
276 if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_BURST"); val != "" {
277 var n int
278 if _, err := fmt.Sscanf(val, "%d", &n); err == nil {
279 cfg.RateLimit.DefaultBurst = n
280 }
281 }
282
283 // Metrics
284 if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" {
285 cfg.Metrics.Enabled = parseBool(val)
286 }
287 if val := os.Getenv("MUXSTR_METRICS_ADDR"); val != "" {
288 cfg.Metrics.Addr = val
289 }
290 if val := os.Getenv("MUXSTR_METRICS_PATH"); val != "" {
291 cfg.Metrics.Path = val
292 }
293
294 // Logging
295 if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" {
296 cfg.Logging.Level = val
297 }
298 if val := os.Getenv("MUXSTR_LOGGING_FORMAT"); val != "" {
299 cfg.Logging.Format = val
300 }
301 if val := os.Getenv("MUXSTR_LOGGING_OUTPUT"); val != "" {
302 cfg.Logging.Output = val
303 }
304}
305
306// parseBool parses a boolean from a string.
307func parseBool(s string) bool {
308 s = strings.ToLower(s)
309 return s == "true" || s == "1" || s == "yes" || s == "on"
310}
311
312// Save saves the configuration to a YAML file.
313func (c *Config) Save(filename string) error {
314 data, err := yaml.Marshal(c)
315 if err != nil {
316 return fmt.Errorf("failed to marshal config: %w", err)
317 }
318
319 if err := os.WriteFile(filename, data, 0644); err != nil {
320 return fmt.Errorf("failed to write config file: %w", err)
321 }
322
323 return nil
324}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..50d9b67
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,288 @@
1package config
2
3import (
4 "os"
5 "testing"
6 "time"
7)
8
9func TestDefault(t *testing.T) {
10 cfg := Default()
11
12 if cfg.Server.GrpcAddr != ":50051" {
13 t.Errorf("expected default grpc_addr :50051, got %s", cfg.Server.GrpcAddr)
14 }
15
16 if cfg.Database.Path != "relay.db" {
17 t.Errorf("expected default db path relay.db, got %s", cfg.Database.Path)
18 }
19
20 if cfg.Metrics.Enabled != true {
21 t.Error("expected metrics enabled by default")
22 }
23}
24
25func TestLoadYAML(t *testing.T) {
26 // Create temporary config file
27 tmpfile, err := os.CreateTemp("", "config-*.yaml")
28 if err != nil {
29 t.Fatal(err)
30 }
31 defer os.Remove(tmpfile.Name())
32
33 configData := `
34server:
35 grpc_addr: ":9999"
36 http_addr: ":8888"
37
38database:
39 path: "test.db"
40
41auth:
42 enabled: true
43 required: true
44 timestamp_window: 120
45
46rate_limit:
47 enabled: true
48 default_rps: 50
49 default_burst: 100
50
51metrics:
52 enabled: true
53 addr: ":9191"
54 namespace: "test"
55`
56
57 if _, err := tmpfile.Write([]byte(configData)); err != nil {
58 t.Fatal(err)
59 }
60 tmpfile.Close()
61
62 // Load config
63 cfg, err := Load(tmpfile.Name())
64 if err != nil {
65 t.Fatalf("failed to load config: %v", err)
66 }
67
68 // Verify values
69 if cfg.Server.GrpcAddr != ":9999" {
70 t.Errorf("expected grpc_addr :9999, got %s", cfg.Server.GrpcAddr)
71 }
72
73 if cfg.Database.Path != "test.db" {
74 t.Errorf("expected db path test.db, got %s", cfg.Database.Path)
75 }
76
77 if !cfg.Auth.Enabled {
78 t.Error("expected auth enabled")
79 }
80
81 if !cfg.Auth.Required {
82 t.Error("expected auth required")
83 }
84
85 if cfg.Auth.TimestampWindow != 120 {
86 t.Errorf("expected timestamp window 120, got %d", cfg.Auth.TimestampWindow)
87 }
88
89 if cfg.RateLimit.DefaultRPS != 50 {
90 t.Errorf("expected rate limit 50, got %.1f", cfg.RateLimit.DefaultRPS)
91 }
92
93 if cfg.Metrics.Namespace != "test" {
94 t.Errorf("expected metrics namespace test, got %s", cfg.Metrics.Namespace)
95 }
96}
97
98func TestEnvOverrides(t *testing.T) {
99 // Set environment variables
100 os.Setenv("MUXSTR_SERVER_GRPC_ADDR", ":7777")
101 os.Setenv("MUXSTR_AUTH_ENABLED", "true")
102 os.Setenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS", "200")
103 defer func() {
104 os.Unsetenv("MUXSTR_SERVER_GRPC_ADDR")
105 os.Unsetenv("MUXSTR_AUTH_ENABLED")
106 os.Unsetenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS")
107 }()
108
109 // Load with empty file (just defaults + env)
110 cfg, err := Load("")
111 if err != nil {
112 t.Fatalf("failed to load config: %v", err)
113 }
114
115 // Verify env overrides
116 if cfg.Server.GrpcAddr != ":7777" {
117 t.Errorf("expected env override :7777, got %s", cfg.Server.GrpcAddr)
118 }
119
120 if !cfg.Auth.Enabled {
121 t.Error("expected auth enabled from env")
122 }
123
124 if cfg.RateLimit.DefaultRPS != 200 {
125 t.Errorf("expected rate limit 200 from env, got %.1f", cfg.RateLimit.DefaultRPS)
126 }
127}
128
129func TestValidation(t *testing.T) {
130 tests := []struct {
131 name string
132 cfg *Config
133 wantErr bool
134 }{
135 {
136 name: "valid default config",
137 cfg: Default(),
138 wantErr: false,
139 },
140 {
141 name: "missing grpc_addr",
142 cfg: &Config{
143 Server: ServerConfig{
144 HttpAddr: ":8080",
145 },
146 Database: DatabaseConfig{
147 Path: "test.db",
148 },
149 },
150 wantErr: true,
151 },
152 {
153 name: "missing http_addr",
154 cfg: &Config{
155 Server: ServerConfig{
156 GrpcAddr: ":50051",
157 },
158 Database: DatabaseConfig{
159 Path: "test.db",
160 },
161 },
162 wantErr: true,
163 },
164 {
165 name: "missing database path",
166 cfg: &Config{
167 Server: ServerConfig{
168 GrpcAddr: ":50051",
169 HttpAddr: ":8080",
170 },
171 Database: DatabaseConfig{},
172 },
173 wantErr: true,
174 },
175 {
176 name: "invalid log level",
177 cfg: &Config{
178 Server: ServerConfig{
179 GrpcAddr: ":50051",
180 HttpAddr: ":8080",
181 },
182 Database: DatabaseConfig{
183 Path: "test.db",
184 },
185 Logging: LoggingConfig{
186 Level: "invalid",
187 Format: "json",
188 },
189 },
190 wantErr: true,
191 },
192 }
193
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 err := tt.cfg.Validate()
197 if (err != nil) != tt.wantErr {
198 t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
199 }
200 })
201 }
202}
203
204func TestSaveAndLoad(t *testing.T) {
205 // Create config
206 cfg := Default()
207 cfg.Server.GrpcAddr = ":9999"
208 cfg.Auth.Enabled = true
209 cfg.RateLimit.DefaultRPS = 100
210
211 // Save to temp file
212 tmpfile, err := os.CreateTemp("", "config-*.yaml")
213 if err != nil {
214 t.Fatal(err)
215 }
216 defer os.Remove(tmpfile.Name())
217 tmpfile.Close()
218
219 if err := cfg.Save(tmpfile.Name()); err != nil {
220 t.Fatalf("failed to save config: %v", err)
221 }
222
223 // Load it back
224 loaded, err := Load(tmpfile.Name())
225 if err != nil {
226 t.Fatalf("failed to load config: %v", err)
227 }
228
229 // Verify
230 if loaded.Server.GrpcAddr != ":9999" {
231 t.Errorf("expected grpc_addr :9999, got %s", loaded.Server.GrpcAddr)
232 }
233
234 if !loaded.Auth.Enabled {
235 t.Error("expected auth enabled")
236 }
237
238 if loaded.RateLimit.DefaultRPS != 100 {
239 t.Errorf("expected rate limit 100, got %.1f", loaded.RateLimit.DefaultRPS)
240 }
241}
242
243func TestDurationParsing(t *testing.T) {
244 // Create config with durations
245 tmpfile, err := os.CreateTemp("", "config-*.yaml")
246 if err != nil {
247 t.Fatal(err)
248 }
249 defer os.Remove(tmpfile.Name())
250
251 configData := `
252server:
253 grpc_addr: ":50051"
254 http_addr: ":8080"
255 read_timeout: "1m"
256 write_timeout: "2m"
257
258database:
259 path: "test.db"
260 max_lifetime: "30m"
261
262rate_limit:
263 cleanup_interval: "10m"
264 max_idle_time: "20m"
265`
266
267 if _, err := tmpfile.Write([]byte(configData)); err != nil {
268 t.Fatal(err)
269 }
270 tmpfile.Close()
271
272 cfg, err := Load(tmpfile.Name())
273 if err != nil {
274 t.Fatalf("failed to load config: %v", err)
275 }
276
277 if cfg.Server.ReadTimeout != 1*time.Minute {
278 t.Errorf("expected read timeout 1m, got %v", cfg.Server.ReadTimeout)
279 }
280
281 if cfg.Server.WriteTimeout != 2*time.Minute {
282 t.Errorf("expected write timeout 2m, got %v", cfg.Server.WriteTimeout)
283 }
284
285 if cfg.Database.MaxLifetime != 30*time.Minute {
286 t.Errorf("expected max lifetime 30m, got %v", cfg.Database.MaxLifetime)
287 }
288}