Harden env storage and admin cache handling
- commit
- b467aa8364042442c44f9a6f906be4f6004b98c1
- author
- brice · · 2026-03-05 21:45 UTC
- parent
- ec63ba9
- signature
- unsigned
20 files · +201 -169
M internal/adapters/in/cli/controlplane_resolver_test.go +2 -0
| @@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 30 | 30 | ||
| 31 | 31 | [auth] | |
| 32 | 32 | enabled = true | |
| 33 | + | secrets_backend = "unsafe" | |
| 33 | 34 | `), 0o600) | |
| 34 | 35 | if err != nil { | |
| 35 | 36 | t.Fatalf("failed to write config: %v", err) | |
| @@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 70 | 71 | ||
| 71 | 72 | [auth] | |
| 72 | 73 | enabled = false | |
| 74 | + | secrets_backend = "unsafe" | |
| 73 | 75 | `), 0o600) | |
| 74 | 76 | if err != nil { | |
| 75 | 77 | t.Fatalf("failed to write config: %v", err) | |
| @@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||||
| 30 | 30 | ||||
| 31 | [auth] | 31 | [auth] | ||
| 32 | enabled = true | 32 | enabled = true | ||
| 33 | + | secrets_backend = "unsafe" | |||
| 33 | `), 0o600) | 34 | `), 0o600) | ||
| 34 | if err != nil { | 35 | if err != nil { | ||
| 35 | t.Fatalf("failed to write config: %v", err) | 36 | t.Fatalf("failed to write config: %v", err) | ||
| @@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||||
| 70 | 71 | ||||
| 71 | [auth] | 72 | [auth] | ||
| 72 | enabled = false | 73 | enabled = false | ||
| 74 | + | secrets_backend = "unsafe" | |||
| 73 | `), 0o600) | 75 | `), 0o600) | ||
| 74 | if err != nil { | 76 | if err != nil { | ||
| 75 | t.Fatalf("failed to write config: %v", err) | 77 | t.Fatalf("failed to write config: %v", err) | ||
M internal/adapters/in/http/admin/middleware.go +4 -0
| @@ -33,6 +33,8 @@ func AuthMiddleware( | |||
| 33 | 33 | // Check global rate limit | |
| 34 | 34 | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { | |
| 35 | 35 | w.Header().Set("Content-Type", "application/json") | |
| 36 | + | w.Header().Set("Cache-Control", "no-store") | |
| 37 | + | w.Header().Set("Pragma", "no-cache") | |
| 36 | 38 | w.Header().Set("Retry-After", "1") | |
| 37 | 39 | w.WriteHeader(http.StatusTooManyRequests) | |
| 38 | 40 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -44,6 +46,8 @@ func AuthMiddleware( | |||
| 44 | 46 | ip := middleware.GetClientIP(r, trustedNets) | |
| 45 | 47 | if !ipLimiter.Allow(ctx, "ip:"+ip) { | |
| 46 | 48 | w.Header().Set("Content-Type", "application/json") | |
| 49 | + | w.Header().Set("Cache-Control", "no-store") | |
| 50 | + | w.Header().Set("Pragma", "no-cache") | |
| 47 | 51 | w.Header().Set("Retry-After", "1") | |
| 48 | 52 | w.WriteHeader(http.StatusTooManyRequests) | |
| 49 | 53 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -33,6 +33,8 @@ func AuthMiddleware( | |||||
| 33 | // Check global rate limit | 33 | // Check global rate limit | ||
| 34 | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { | 34 | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { | ||
| 35 | w.Header().Set("Content-Type", "application/json") | 35 | w.Header().Set("Content-Type", "application/json") | ||
| 36 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 37 | + | w.Header().Set("Pragma", "no-cache") | |||
| 36 | w.Header().Set("Retry-After", "1") | 38 | w.Header().Set("Retry-After", "1") | ||
| 37 | w.WriteHeader(http.StatusTooManyRequests) | 39 | w.WriteHeader(http.StatusTooManyRequests) | ||
| 38 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | 40 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | ||
| @@ -44,6 +46,8 @@ func AuthMiddleware( | |||||
| 44 | ip := middleware.GetClientIP(r, trustedNets) | 46 | ip := middleware.GetClientIP(r, trustedNets) | ||
| 45 | if !ipLimiter.Allow(ctx, "ip:"+ip) { | 47 | if !ipLimiter.Allow(ctx, "ip:"+ip) { | ||
| 46 | w.Header().Set("Content-Type", "application/json") | 48 | w.Header().Set("Content-Type", "application/json") | ||
| 49 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 50 | + | w.Header().Set("Pragma", "no-cache") | |||
| 47 | w.Header().Set("Retry-After", "1") | 51 | w.Header().Set("Retry-After", "1") | ||
| 48 | w.WriteHeader(http.StatusTooManyRequests) | 52 | w.WriteHeader(http.StatusTooManyRequests) | ||
| 49 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | 53 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | ||
M internal/adapters/in/http/admin/middleware_test.go +4 -0
| @@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) { | |||
| 42 | 42 | ||
| 43 | 43 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 44 | 44 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |
| 45 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 46 | + | assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 47 | 49 | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |
| @@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |||
| 67 | 69 | ||
| 68 | 70 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 69 | 71 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | |
| 72 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 73 | + | assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) | |
| 70 | 74 | } | |
| 71 | 75 | ||
| 72 | 76 | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |
| @@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) { | |||||
| 42 | 42 | ||||
| 43 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | 43 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | ||
| 44 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | 44 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | ||
| 45 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 46 | + | assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) | |||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 47 | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | 49 | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | ||
| @@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |||||
| 67 | 69 | ||||
| 68 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | 70 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | ||
| 69 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | 71 | assert.Contains(t, rec.Body.String(), "rate limit exceeded") | ||
| 72 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 73 | + | assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) | |||
| 70 | } | 74 | } | ||
| 71 | 75 | ||||
| 72 | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | 76 | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | ||
M internal/adapters/out/domainsecrets/store.go +7 -11
| @@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||
| 285 | 285 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 286 | 286 | // | |
| 287 | 287 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | |
| 288 | - | func (s *FileStore) getEnvFilePath(domainName string) string { | |
| 289 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 288 | + | func (s *FileStore) getEnvFilePath(domainName string) (string, error) { | |
| 289 | + | storageKey, err := domain.NewEnvStorageKey(domainName) | |
| 290 | 290 | if err != nil { | |
| 291 | 291 | s.log.Warn(). | |
| 292 | 292 | Str(zerowrap.FieldLayer, "adapter"). | |
| @@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||
| 294 | 294 | Str("domain", domainName). | |
| 295 | 295 | Err(err). | |
| 296 | 296 | Msg("rejected invalid domain") | |
| 297 | - | return "" | |
| 297 | + | return "", err | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | - | fullPath := filepath.Join(s.envDir, safeDomain+".env") | |
| 300 | + | fullPath := filepath.Join(s.envDir, storageKey.FileName()) | |
| 301 | 301 | ||
| 302 | 302 | // SECURITY: Final validation - ensure path stays within envDir | |
| 303 | 303 | cleanPath := filepath.Clean(fullPath) | |
| @@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||
| 309 | 309 | Str("domain", domainName). | |
| 310 | 310 | Str("attempted_path", fullPath). | |
| 311 | 311 | Msg("path traversal attempt blocked - path escapes env directory") | |
| 312 | - | return "" | |
| 312 | + | return "", domain.ErrPathTraversal | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | - | return fullPath | |
| 315 | + | return fullPath, nil | |
| 316 | 316 | } | |
| 317 | 317 | ||
| 318 | 318 | // validateEnvFilePath validates that a domain produces a valid env file path. | |
| 319 | 319 | // Returns an error if the domain is invalid or would result in path traversal. | |
| 320 | 320 | func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |
| 321 | - | path := s.getEnvFilePath(domainName) | |
| 322 | - | if path == "" { | |
| 323 | - | return "", domain.ErrPathTraversal | |
| 324 | - | } | |
| 325 | - | return path, nil | |
| 321 | + | return s.getEnvFilePath(domainName) | |
| 326 | 322 | } | |
| 327 | 323 | ||
| 328 | 324 | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |
| @@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||||
| 285 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | 285 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | ||
| 286 | // | 286 | // | ||
| 287 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | 287 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | ||
| 288 | - | func (s *FileStore) getEnvFilePath(domainName string) string { | 288 | + | func (s *FileStore) getEnvFilePath(domainName string) (string, error) { |
| 289 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | 289 | + | storageKey, err := domain.NewEnvStorageKey(domainName) |
| 290 | if err != nil { | 290 | if err != nil { | ||
| 291 | s.log.Warn(). | 291 | s.log.Warn(). | ||
| 292 | Str(zerowrap.FieldLayer, "adapter"). | 292 | Str(zerowrap.FieldLayer, "adapter"). | ||
| @@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||||
| 294 | Str("domain", domainName). | 294 | Str("domain", domainName). | ||
| 295 | Err(err). | 295 | Err(err). | ||
| 296 | Msg("rejected invalid domain") | 296 | Msg("rejected invalid domain") | ||
| 297 | - | return "" | 297 | + | return "", err |
| 298 | } | 298 | } | ||
| 299 | 299 | ||||
| 300 | - | fullPath := filepath.Join(s.envDir, safeDomain+".env") | 300 | + | fullPath := filepath.Join(s.envDir, storageKey.FileName()) |
| 301 | 301 | ||||
| 302 | // SECURITY: Final validation - ensure path stays within envDir | 302 | // SECURITY: Final validation - ensure path stays within envDir | ||
| 303 | cleanPath := filepath.Clean(fullPath) | 303 | cleanPath := filepath.Clean(fullPath) | ||
| @@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||||
| 309 | Str("domain", domainName). | 309 | Str("domain", domainName). | ||
| 310 | Str("attempted_path", fullPath). | 310 | Str("attempted_path", fullPath). | ||
| 311 | Msg("path traversal attempt blocked - path escapes env directory") | 311 | Msg("path traversal attempt blocked - path escapes env directory") | ||
| 312 | - | return "" | 312 | + | return "", domain.ErrPathTraversal |
| 313 | } | 313 | } | ||
| 314 | 314 | ||||
| 315 | - | return fullPath | 315 | + | return fullPath, nil |
| 316 | } | 316 | } | ||
| 317 | 317 | ||||
| 318 | // validateEnvFilePath validates that a domain produces a valid env file path. | 318 | // validateEnvFilePath validates that a domain produces a valid env file path. | ||
| 319 | // Returns an error if the domain is invalid or would result in path traversal. | 319 | // Returns an error if the domain is invalid or would result in path traversal. | ||
| 320 | func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | 320 | func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | ||
| 321 | - | path := s.getEnvFilePath(domainName) | 321 | + | return s.getEnvFilePath(domainName) |
| 322 | - | if path == "" { | |||
| 323 | - | return "", domain.ErrPathTraversal | |||
| 324 | - | } | |||
| 325 | - | return path, nil | |||
| 326 | } | 322 | } | ||
| 327 | 323 | ||||
| 328 | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | 324 | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | ||
M internal/adapters/out/domainsecrets/store_test.go +8 -6
| @@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) { | |||
| 101 | 101 | require.NoError(t, err) | |
| 102 | 102 | ||
| 103 | 103 | // Verify file exists inside tmpDir | |
| 104 | - | expectedFile := filepath.Join(tmpDir, "example__com.env") | |
| 104 | + | expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env") | |
| 105 | 105 | _, err = os.Stat(expectedFile) | |
| 106 | 106 | assert.NoError(t, err, "expected env file to exist at %s", expectedFile) | |
| 107 | 107 | ||
| @@ -407,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) { | |||
| 407 | 407 | wantEmpty bool | |
| 408 | 408 | wantFilename string | |
| 409 | 409 | }{ | |
| 410 | - | {"example.com", false, "example__com.env"}, | |
| 411 | - | {"sub.example.com", false, "sub__example__com.env"}, | |
| 412 | - | {"example.com:8080", false, "example__com-_8080.env"}, | |
| 413 | - | {"example.com/path", false, "example__com--path.env"}, | |
| 410 | + | {"example.com", false, "ZXhhbXBsZS5jb20.env"}, | |
| 411 | + | {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"}, | |
| 412 | + | {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"}, | |
| 413 | + | {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"}, | |
| 414 | 414 | {"../evil", true, ""}, | |
| 415 | 415 | {"", true, ""}, | |
| 416 | 416 | } | |
| 417 | 417 | ||
| 418 | 418 | for _, tt := range tests { | |
| 419 | 419 | t.Run(tt.domain, func(t *testing.T) { | |
| 420 | - | path := store.getEnvFilePath(tt.domain) | |
| 420 | + | path, err := store.getEnvFilePath(tt.domain) | |
| 421 | 421 | ||
| 422 | 422 | if tt.wantEmpty { | |
| 423 | + | assert.Error(t, err) | |
| 423 | 424 | assert.Empty(t, path) | |
| 424 | 425 | } else { | |
| 426 | + | require.NoError(t, err) | |
| 425 | 427 | assert.NotEmpty(t, path) | |
| 426 | 428 | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | |
| 427 | 429 | } | |
| @@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) { | |||||
| 101 | require.NoError(t, err) | 101 | require.NoError(t, err) | ||
| 102 | 102 | ||||
| 103 | // Verify file exists inside tmpDir | 103 | // Verify file exists inside tmpDir | ||
| 104 | - | expectedFile := filepath.Join(tmpDir, "example__com.env") | 104 | + | expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env") |
| 105 | _, err = os.Stat(expectedFile) | 105 | _, err = os.Stat(expectedFile) | ||
| 106 | assert.NoError(t, err, "expected env file to exist at %s", expectedFile) | 106 | assert.NoError(t, err, "expected env file to exist at %s", expectedFile) | ||
| 107 | 107 | ||||
| @@ -407,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) { | |||||
| 407 | wantEmpty bool | 407 | wantEmpty bool | ||
| 408 | wantFilename string | 408 | wantFilename string | ||
| 409 | }{ | 409 | }{ | ||
| 410 | - | {"example.com", false, "example__com.env"}, | 410 | + | {"example.com", false, "ZXhhbXBsZS5jb20.env"}, |
| 411 | - | {"sub.example.com", false, "sub__example__com.env"}, | 411 | + | {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"}, |
| 412 | - | {"example.com:8080", false, "example__com-_8080.env"}, | 412 | + | {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"}, |
| 413 | - | {"example.com/path", false, "example__com--path.env"}, | 413 | + | {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"}, |
| 414 | {"../evil", true, ""}, | 414 | {"../evil", true, ""}, | ||
| 415 | {"", true, ""}, | 415 | {"", true, ""}, | ||
| 416 | } | 416 | } | ||
| 417 | 417 | ||||
| 418 | for _, tt := range tests { | 418 | for _, tt := range tests { | ||
| 419 | t.Run(tt.domain, func(t *testing.T) { | 419 | t.Run(tt.domain, func(t *testing.T) { | ||
| 420 | - | path := store.getEnvFilePath(tt.domain) | 420 | + | path, err := store.getEnvFilePath(tt.domain) |
| 421 | 421 | ||||
| 422 | if tt.wantEmpty { | 422 | if tt.wantEmpty { | ||
| 423 | + | assert.Error(t, err) | |||
| 423 | assert.Empty(t, path) | 424 | assert.Empty(t, path) | ||
| 424 | } else { | 425 | } else { | ||
| 426 | + | require.NoError(t, err) | |||
| 425 | assert.NotEmpty(t, path) | 427 | assert.NotEmpty(t, path) | ||
| 426 | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | 428 | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | ||
| 427 | } | 429 | } | ||
M internal/adapters/out/envloader/file.go +21 -10
| @@ -63,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||
| 63 | 63 | log := zerowrap.FromCtx(ctx) | |
| 64 | 64 | ||
| 65 | 65 | envVars := []string{} | |
| 66 | - | envFile := l.getEnvFilePath(domain) | |
| 66 | + | envFile, err := l.getEnvFilePath(domain) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, log.WrapErr(err, "invalid env file domain") | |
| 69 | + | } | |
| 67 | 70 | ||
| 68 | 71 | // Check if env file exists | |
| 69 | 72 | if _, err := os.Stat(envFile); os.IsNotExist(err) { | |
| @@ -143,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 143 | 146 | }) | |
| 144 | 147 | log := zerowrap.FromCtx(ctx) | |
| 145 | 148 | ||
| 146 | - | envFile := l.getEnvFilePath(domain) | |
| 149 | + | envFile, err := l.getEnvFilePath(domain) | |
| 150 | + | if err != nil { | |
| 151 | + | return log.WrapErr(err, "invalid env file domain") | |
| 152 | + | } | |
| 147 | 153 | ||
| 148 | 154 | // Check if file already exists | |
| 149 | 155 | if _, err := os.Stat(envFile); err == nil { | |
| @@ -170,10 +176,14 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 170 | 176 | } | |
| 171 | 177 | ||
| 172 | 178 | // EnvFileExists checks if an environment file exists for a domain. | |
| 173 | - | func (l *FileLoader) EnvFileExists(domain string) bool { | |
| 174 | - | envFile := l.getEnvFilePath(domain) | |
| 175 | - | _, err := os.Stat(envFile) | |
| 176 | - | return err == nil | |
| 179 | + | func (l *FileLoader) EnvFileExists(domain string) (bool, error) { | |
| 180 | + | envFile, err := l.getEnvFilePath(domain) | |
| 181 | + | if err != nil { | |
| 182 | + | return false, err | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | _, err = os.Stat(envFile) | |
| 186 | + | return err == nil, nil | |
| 177 | 187 | } | |
| 178 | 188 | ||
| 179 | 189 | // resolveSecrets resolves any secret references in the value. | |
| @@ -223,10 +233,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, | |||
| 223 | 233 | return result, nil | |
| 224 | 234 | } | |
| 225 | 235 | ||
| 226 | - | func (l *FileLoader) getEnvFilePath(domainName string) string { | |
| 227 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 236 | + | func (l *FileLoader) getEnvFilePath(domainName string) (string, error) { | |
| 237 | + | storageKey, err := domain.NewEnvStorageKey(domainName) | |
| 228 | 238 | if err != nil { | |
| 229 | - | return filepath.Join(l.envDir, domainName+".env") | |
| 239 | + | return "", err | |
| 230 | 240 | } | |
| 231 | - | return filepath.Join(l.envDir, safeDomain+".env") | |
| 241 | + | ||
| 242 | + | return filepath.Join(l.envDir, storageKey.FileName()), nil | |
| 232 | 243 | } | |
| @@ -63,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||||
| 63 | log := zerowrap.FromCtx(ctx) | 63 | log := zerowrap.FromCtx(ctx) | ||
| 64 | 64 | ||||
| 65 | envVars := []string{} | 65 | envVars := []string{} | ||
| 66 | - | envFile := l.getEnvFilePath(domain) | 66 | + | envFile, err := l.getEnvFilePath(domain) |
| 67 | + | if err != nil { | |||
| 68 | + | return nil, log.WrapErr(err, "invalid env file domain") | |||
| 69 | + | } | |||
| 67 | 70 | ||||
| 68 | // Check if env file exists | 71 | // Check if env file exists | ||
| 69 | if _, err := os.Stat(envFile); os.IsNotExist(err) { | 72 | if _, err := os.Stat(envFile); os.IsNotExist(err) { | ||
| @@ -143,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||||
| 143 | }) | 146 | }) | ||
| 144 | log := zerowrap.FromCtx(ctx) | 147 | log := zerowrap.FromCtx(ctx) | ||
| 145 | 148 | ||||
| 146 | - | envFile := l.getEnvFilePath(domain) | 149 | + | envFile, err := l.getEnvFilePath(domain) |
| 150 | + | if err != nil { | |||
| 151 | + | return log.WrapErr(err, "invalid env file domain") | |||
| 152 | + | } | |||
| 147 | 153 | ||||
| 148 | // Check if file already exists | 154 | // Check if file already exists | ||
| 149 | if _, err := os.Stat(envFile); err == nil { | 155 | if _, err := os.Stat(envFile); err == nil { | ||
| @@ -170,10 +176,14 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||||
| 170 | } | 176 | } | ||
| 171 | 177 | ||||
| 172 | // EnvFileExists checks if an environment file exists for a domain. | 178 | // EnvFileExists checks if an environment file exists for a domain. | ||
| 173 | - | func (l *FileLoader) EnvFileExists(domain string) bool { | 179 | + | func (l *FileLoader) EnvFileExists(domain string) (bool, error) { |
| 174 | - | envFile := l.getEnvFilePath(domain) | 180 | + | envFile, err := l.getEnvFilePath(domain) |
| 175 | - | _, err := os.Stat(envFile) | 181 | + | if err != nil { |
| 176 | - | return err == nil | 182 | + | return false, err |
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | _, err = os.Stat(envFile) | |||
| 186 | + | return err == nil, nil | |||
| 177 | } | 187 | } | ||
| 178 | 188 | ||||
| 179 | // resolveSecrets resolves any secret references in the value. | 189 | // resolveSecrets resolves any secret references in the value. | ||
| @@ -223,10 +233,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, | |||||
| 223 | return result, nil | 233 | return result, nil | ||
| 224 | } | 234 | } | ||
| 225 | 235 | ||||
| 226 | - | func (l *FileLoader) getEnvFilePath(domainName string) string { | 236 | + | func (l *FileLoader) getEnvFilePath(domainName string) (string, error) { |
| 227 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | 237 | + | storageKey, err := domain.NewEnvStorageKey(domainName) |
| 228 | if err != nil { | 238 | if err != nil { | ||
| 229 | - | return filepath.Join(l.envDir, domainName+".env") | 239 | + | return "", err |
| 230 | } | 240 | } | ||
| 231 | - | return filepath.Join(l.envDir, safeDomain+".env") | 241 | + | |
| 242 | + | return filepath.Join(l.envDir, storageKey.FileName()), nil | |||
| 232 | } | 243 | } | ||
M internal/adapters/out/envloader/file_test.go +35 -11
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | func TestFileLoader_ResolveSecrets(t *testing.T) { | |
| @@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) { | |||
| 121 | 122 | NODE_ENV=production | |
| 122 | 123 | API_URL=https://api.example.com | |
| 123 | 124 | ` | |
| 124 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | |
| 125 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 125 | 128 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 126 | 129 | ||
| 127 | 130 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -146,7 +149,9 @@ API_URL=https://api.example.com | |||
| 146 | 149 | envContent := `NODE_ENV=production | |
| 147 | 150 | API_KEY=${pass:app/api-key} | |
| 148 | 151 | ` | |
| 149 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | |
| 152 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 150 | 155 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 151 | 156 | ||
| 152 | 157 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key} | |||
| 164 | 169 | envContent := `DOUBLE_QUOTED="hello world" | |
| 165 | 170 | SINGLE_QUOTED='hello world' | |
| 166 | 171 | ` | |
| 167 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | |
| 172 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 168 | 175 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 169 | 176 | ||
| 170 | 177 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world' | |||
| 183 | 190 | invalid line without equals | |
| 184 | 191 | ALSO_VALID=another | |
| 185 | 192 | ` | |
| 186 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | |
| 193 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 187 | 196 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 188 | 197 | ||
| 189 | 198 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -205,14 +214,15 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) { | |||
| 205 | 214 | domain string | |
| 206 | 215 | expected string | |
| 207 | 216 | }{ | |
| 208 | - | {"app.example.com", "app__example__com.env"}, | |
| 209 | - | {"api.sub.example.com", "api__sub__example__com.env"}, | |
| 210 | - | {"localhost:8080", "localhost-_8080.env"}, | |
| 217 | + | {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"}, | |
| 218 | + | {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"}, | |
| 219 | + | {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"}, | |
| 211 | 220 | } | |
| 212 | 221 | ||
| 213 | 222 | for _, tt := range tests { | |
| 214 | 223 | t.Run(tt.domain, func(t *testing.T) { | |
| 215 | - | result := loader.getEnvFilePath(tt.domain) | |
| 224 | + | result, err := loader.getEnvFilePath(tt.domain) | |
| 225 | + | require.NoError(t, err) | |
| 216 | 226 | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | |
| 217 | 227 | }) | |
| 218 | 228 | } | |
| @@ -225,11 +235,25 @@ func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) { | |||
| 225 | 235 | loader, err := NewFileLoader(tmpDir, log) | |
| 226 | 236 | require.NoError(t, err) | |
| 227 | 237 | ||
| 228 | - | dotPath := loader.getEnvFilePath("app.example.com") | |
| 229 | - | portPath := loader.getEnvFilePath("app:example:com") | |
| 230 | - | slashPath := loader.getEnvFilePath("app/example/com") | |
| 238 | + | dotPath, err := loader.getEnvFilePath("app.example.com") | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | portPath, err := loader.getEnvFilePath("app:example:com") | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | slashPath, err := loader.getEnvFilePath("app/example/com") | |
| 243 | + | require.NoError(t, err) | |
| 231 | 244 | ||
| 232 | 245 | assert.NotEqual(t, dotPath, portPath) | |
| 233 | 246 | assert.NotEqual(t, dotPath, slashPath) | |
| 234 | 247 | assert.NotEqual(t, portPath, slashPath) | |
| 235 | 248 | } | |
| 249 | + | ||
| 250 | + | func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) { | |
| 251 | + | tmpDir := t.TempDir() | |
| 252 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 253 | + | ||
| 254 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | ||
| 257 | + | _, err = loader.getEnvFilePath("../etc/passwd") | |
| 258 | + | require.ErrorIs(t, err, domain.ErrPathTraversal) | |
| 259 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | func TestFileLoader_ResolveSecrets(t *testing.T) { | 17 | func TestFileLoader_ResolveSecrets(t *testing.T) { | ||
| @@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) { | |||||
| 121 | NODE_ENV=production | 122 | NODE_ENV=production | ||
| 122 | API_URL=https://api.example.com | 123 | API_URL=https://api.example.com | ||
| 123 | ` | 124 | ` | ||
| 124 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | 125 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") |
| 126 | + | require.NoError(t, err) | |||
| 127 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 125 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | 128 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | ||
| 126 | 129 | ||||
| 127 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | 130 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | ||
| @@ -146,7 +149,9 @@ API_URL=https://api.example.com | |||||
| 146 | envContent := `NODE_ENV=production | 149 | envContent := `NODE_ENV=production | ||
| 147 | API_KEY=${pass:app/api-key} | 150 | API_KEY=${pass:app/api-key} | ||
| 148 | ` | 151 | ` | ||
| 149 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | 152 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") |
| 153 | + | require.NoError(t, err) | |||
| 154 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 150 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | 155 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | ||
| 151 | 156 | ||||
| 152 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | 157 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | ||
| @@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key} | |||||
| 164 | envContent := `DOUBLE_QUOTED="hello world" | 169 | envContent := `DOUBLE_QUOTED="hello world" | ||
| 165 | SINGLE_QUOTED='hello world' | 170 | SINGLE_QUOTED='hello world' | ||
| 166 | ` | 171 | ` | ||
| 167 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | 172 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") |
| 173 | + | require.NoError(t, err) | |||
| 174 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 168 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | 175 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | ||
| 169 | 176 | ||||
| 170 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | 177 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | ||
| @@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world' | |||||
| 183 | invalid line without equals | 190 | invalid line without equals | ||
| 184 | ALSO_VALID=another | 191 | ALSO_VALID=another | ||
| 185 | ` | 192 | ` | ||
| 186 | - | envFile := filepath.Join(tmpDir, "app__example__com.env") | 193 | + | storageKey, err := domain.NewEnvStorageKey("app.example.com") |
| 194 | + | require.NoError(t, err) | |||
| 195 | + | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 187 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | 196 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | ||
| 188 | 197 | ||||
| 189 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | 198 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | ||
| @@ -205,14 +214,15 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) { | |||||
| 205 | domain string | 214 | domain string | ||
| 206 | expected string | 215 | expected string | ||
| 207 | }{ | 216 | }{ | ||
| 208 | - | {"app.example.com", "app__example__com.env"}, | 217 | + | {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"}, |
| 209 | - | {"api.sub.example.com", "api__sub__example__com.env"}, | 218 | + | {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"}, |
| 210 | - | {"localhost:8080", "localhost-_8080.env"}, | 219 | + | {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"}, |
| 211 | } | 220 | } | ||
| 212 | 221 | ||||
| 213 | for _, tt := range tests { | 222 | for _, tt := range tests { | ||
| 214 | t.Run(tt.domain, func(t *testing.T) { | 223 | t.Run(tt.domain, func(t *testing.T) { | ||
| 215 | - | result := loader.getEnvFilePath(tt.domain) | 224 | + | result, err := loader.getEnvFilePath(tt.domain) |
| 225 | + | require.NoError(t, err) | |||
| 216 | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | 226 | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | ||
| 217 | }) | 227 | }) | ||
| 218 | } | 228 | } | ||
| @@ -225,11 +235,25 @@ func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) { | |||||
| 225 | loader, err := NewFileLoader(tmpDir, log) | 235 | loader, err := NewFileLoader(tmpDir, log) | ||
| 226 | require.NoError(t, err) | 236 | require.NoError(t, err) | ||
| 227 | 237 | ||||
| 228 | - | dotPath := loader.getEnvFilePath("app.example.com") | 238 | + | dotPath, err := loader.getEnvFilePath("app.example.com") |
| 229 | - | portPath := loader.getEnvFilePath("app:example:com") | 239 | + | require.NoError(t, err) |
| 230 | - | slashPath := loader.getEnvFilePath("app/example/com") | 240 | + | portPath, err := loader.getEnvFilePath("app:example:com") |
| 241 | + | require.NoError(t, err) | |||
| 242 | + | slashPath, err := loader.getEnvFilePath("app/example/com") | |||
| 243 | + | require.NoError(t, err) | |||
| 231 | 244 | ||||
| 232 | assert.NotEqual(t, dotPath, portPath) | 245 | assert.NotEqual(t, dotPath, portPath) | ||
| 233 | assert.NotEqual(t, dotPath, slashPath) | 246 | assert.NotEqual(t, dotPath, slashPath) | ||
| 234 | assert.NotEqual(t, portPath, slashPath) | 247 | assert.NotEqual(t, portPath, slashPath) | ||
| 235 | } | 248 | } | ||
| 249 | + | ||||
| 250 | + | func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) { | |||
| 251 | + | tmpDir := t.TempDir() | |||
| 252 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 253 | + | ||||
| 254 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 255 | + | require.NoError(t, err) | |||
| 256 | + | ||||
| 257 | + | _, err = loader.getEnvFilePath("../etc/passwd") | |||
| 258 | + | require.ErrorIs(t, err, domain.ErrPathTraversal) | |||
| 259 | + | } | |||
M internal/adapters/out/envloader/pass_loader.go +3 -3
| @@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | // EnvFileExists checks if a manifest exists for a domain in pass. | |
| 107 | - | func (l *PassLoader) EnvFileExists(domain string) bool { | |
| 107 | + | func (l *PassLoader) EnvFileExists(domain string) (bool, error) { | |
| 108 | 108 | exists, err := l.store.ManifestExists(domain) | |
| 109 | 109 | if err != nil { | |
| 110 | 110 | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |
| 111 | - | return false | |
| 111 | + | return false, err | |
| 112 | 112 | } | |
| 113 | - | return exists | |
| 113 | + | return exists, nil | |
| 114 | 114 | } | |
| @@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | // EnvFileExists checks if a manifest exists for a domain in pass. | 106 | // EnvFileExists checks if a manifest exists for a domain in pass. | ||
| 107 | - | func (l *PassLoader) EnvFileExists(domain string) bool { | 107 | + | func (l *PassLoader) EnvFileExists(domain string) (bool, error) { |
| 108 | exists, err := l.store.ManifestExists(domain) | 108 | exists, err := l.store.ManifestExists(domain) | ||
| 109 | if err != nil { | 109 | if err != nil { | ||
| 110 | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | 110 | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | ||
| 111 | - | return false | 111 | + | return false, err |
| 112 | } | 112 | } | ||
| 113 | - | return exists | 113 | + | return exists, nil |
| 114 | } | 114 | } | ||
M internal/app/kernel_test.go +1 -0
| @@ -22,6 +22,7 @@ data_dir = %q | |||
| 22 | 22 | ||
| 23 | 23 | [auth] | |
| 24 | 24 | enabled = false | |
| 25 | + | secrets_backend = "unsafe" | |
| 25 | 26 | `, dataDir) | |
| 26 | 27 | ||
| 27 | 28 | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | |
| @@ -22,6 +22,7 @@ data_dir = %q | |||||
| 22 | 22 | ||||
| 23 | [auth] | 23 | [auth] | ||
| 24 | enabled = false | 24 | enabled = false | ||
| 25 | + | secrets_backend = "unsafe" | |||
| 25 | `, dataDir) | 26 | `, dataDir) | ||
| 26 | 27 | ||||
| 27 | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | 28 | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | ||
M internal/app/run.go +1 -1
| @@ -2347,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2347 | 2347 | v.SetDefault("auth.enabled", true) | |
| 2348 | 2348 | // Note: auth.type is intentionally not set - it's inferred from config | |
| 2349 | 2349 | // If password_hash is set -> password mode, otherwise -> token mode | |
| 2350 | - | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 2350 | + | v.SetDefault("auth.secrets_backend", "") | |
| 2351 | 2351 | v.SetDefault("auth.token_expiry", "720h") | |
| 2352 | 2352 | v.SetDefault("api.rate_limit.enabled", true) | |
| 2353 | 2353 | v.SetDefault("api.rate_limit.global_rps", 500) | |
| @@ -2347,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2347 | v.SetDefault("auth.enabled", true) | 2347 | v.SetDefault("auth.enabled", true) | ||
| 2348 | // Note: auth.type is intentionally not set - it's inferred from config | 2348 | // Note: auth.type is intentionally not set - it's inferred from config | ||
| 2349 | // If password_hash is set -> password mode, otherwise -> token mode | 2349 | // If password_hash is set -> password mode, otherwise -> token mode | ||
| 2350 | - | v.SetDefault("auth.secrets_backend", "unsafe") | 2350 | + | v.SetDefault("auth.secrets_backend", "") |
| 2351 | v.SetDefault("auth.token_expiry", "720h") | 2351 | v.SetDefault("auth.token_expiry", "720h") | ||
| 2352 | v.SetDefault("api.rate_limit.enabled", true) | 2352 | v.SetDefault("api.rate_limit.enabled", true) | ||
| 2353 | v.SetDefault("api.rate_limit.global_rps", 500) | 2353 | v.SetDefault("api.rate_limit.global_rps", 500) | ||
M internal/app/run_auth_config_test.go +14 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/gordon/internal/domain" | |
| 9 | 9 | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/spf13/viper" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |
| @@ -44,3 +45,16 @@ func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) { | |||
| 44 | 45 | t.Fatalf("unexpected error: %v", err) | |
| 45 | 46 | } | |
| 46 | 47 | } | |
| 48 | + | ||
| 49 | + | func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |
| 50 | + | v := viper.New() | |
| 51 | + | ||
| 52 | + | err := loadConfig(v, "") | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatalf("expected no error, got %v", err) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | if got := v.GetString("auth.secrets_backend"); got != "" { | |
| 58 | + | t.Fatalf("expected empty default for auth.secrets_backend, got %q", got) | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/gordon/internal/domain" | 8 | "github.com/bnema/gordon/internal/domain" | ||
| 9 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | + | "github.com/spf13/viper" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | 13 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | ||
| @@ -44,3 +45,16 @@ func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) { | |||||
| 44 | t.Fatalf("unexpected error: %v", err) | 45 | t.Fatalf("unexpected error: %v", err) | ||
| 45 | } | 46 | } | ||
| 46 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |||
| 50 | + | v := viper.New() | |||
| 51 | + | ||||
| 52 | + | err := loadConfig(v, "") | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatalf("expected no error, got %v", err) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | if got := v.GetString("auth.secrets_backend"); got != "" { | |||
| 58 | + | t.Fatalf("expected empty default for auth.secrets_backend, got %q", got) | |||
| 59 | + | } | |||
| 60 | + | } | |||
M internal/boundaries/in/mocks/mock_registry_service.go +0 -80
| @@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||
| 231 | 231 | return _c | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | - | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |
| 235 | - | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |
| 236 | - | _mock.Called(imageName) | |
| 237 | - | return | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |
| 241 | - | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |
| 242 | - | *mock.Call | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // ClearDeployEventSuppression is a helper method to define mock.On call | |
| 246 | - | // - imageName string | |
| 247 | - | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 248 | - | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 252 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 253 | - | var arg0 string | |
| 254 | - | if args[0] != nil { | |
| 255 | - | arg0 = args[0].(string) | |
| 256 | - | } | |
| 257 | - | run( | |
| 258 | - | arg0, | |
| 259 | - | ) | |
| 260 | - | }) | |
| 261 | - | return _c | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 265 | - | _c.Call.Return() | |
| 266 | - | return _c | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 270 | - | _c.Run(run) | |
| 271 | - | return _c | |
| 272 | - | } | |
| 273 | - | ||
| 274 | 234 | // DeleteManifest provides a mock function for the type MockRegistryService | |
| 275 | 235 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | |
| 276 | 236 | ret := _mock.Called(ctx, name, reference) | |
| @@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||
| 935 | 895 | _c.Call.Return(run) | |
| 936 | 896 | return _c | |
| 937 | 897 | } | |
| 938 | - | ||
| 939 | - | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |
| 940 | - | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |
| 941 | - | _mock.Called(imageName) | |
| 942 | - | return | |
| 943 | - | } | |
| 944 | - | ||
| 945 | - | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |
| 946 | - | type MockRegistryService_SuppressDeployEvent_Call struct { | |
| 947 | - | *mock.Call | |
| 948 | - | } | |
| 949 | - | ||
| 950 | - | // SuppressDeployEvent is a helper method to define mock.On call | |
| 951 | - | // - imageName string | |
| 952 | - | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |
| 953 | - | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |
| 954 | - | } | |
| 955 | - | ||
| 956 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 957 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 958 | - | var arg0 string | |
| 959 | - | if args[0] != nil { | |
| 960 | - | arg0 = args[0].(string) | |
| 961 | - | } | |
| 962 | - | run( | |
| 963 | - | arg0, | |
| 964 | - | ) | |
| 965 | - | }) | |
| 966 | - | return _c | |
| 967 | - | } | |
| 968 | - | ||
| 969 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |
| 970 | - | _c.Call.Return() | |
| 971 | - | return _c | |
| 972 | - | } | |
| 973 | - | ||
| 974 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 975 | - | _c.Run(run) | |
| 976 | - | return _c | |
| 977 | - | } | |
| @@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||||
| 231 | return _c | 231 | return _c | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | - | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |||
| 235 | - | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |||
| 236 | - | _mock.Called(imageName) | |||
| 237 | - | return | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |||
| 241 | - | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |||
| 242 | - | *mock.Call | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // ClearDeployEventSuppression is a helper method to define mock.On call | |||
| 246 | - | // - imageName string | |||
| 247 | - | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 248 | - | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 252 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 253 | - | var arg0 string | |||
| 254 | - | if args[0] != nil { | |||
| 255 | - | arg0 = args[0].(string) | |||
| 256 | - | } | |||
| 257 | - | run( | |||
| 258 | - | arg0, | |||
| 259 | - | ) | |||
| 260 | - | }) | |||
| 261 | - | return _c | |||
| 262 | - | } | |||
| 263 | - | ||||
| 264 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 265 | - | _c.Call.Return() | |||
| 266 | - | return _c | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 270 | - | _c.Run(run) | |||
| 271 | - | return _c | |||
| 272 | - | } | |||
| 273 | - | ||||
| 274 | // DeleteManifest provides a mock function for the type MockRegistryService | 234 | // DeleteManifest provides a mock function for the type MockRegistryService | ||
| 275 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | 235 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | ||
| 276 | ret := _mock.Called(ctx, name, reference) | 236 | ret := _mock.Called(ctx, name, reference) | ||
| @@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||||
| 935 | _c.Call.Return(run) | 895 | _c.Call.Return(run) | ||
| 936 | return _c | 896 | return _c | ||
| 937 | } | 897 | } | ||
| 938 | - | ||||
| 939 | - | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |||
| 940 | - | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |||
| 941 | - | _mock.Called(imageName) | |||
| 942 | - | return | |||
| 943 | - | } | |||
| 944 | - | ||||
| 945 | - | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |||
| 946 | - | type MockRegistryService_SuppressDeployEvent_Call struct { | |||
| 947 | - | *mock.Call | |||
| 948 | - | } | |||
| 949 | - | ||||
| 950 | - | // SuppressDeployEvent is a helper method to define mock.On call | |||
| 951 | - | // - imageName string | |||
| 952 | - | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 953 | - | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |||
| 954 | - | } | |||
| 955 | - | ||||
| 956 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 957 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 958 | - | var arg0 string | |||
| 959 | - | if args[0] != nil { | |||
| 960 | - | arg0 = args[0].(string) | |||
| 961 | - | } | |||
| 962 | - | run( | |||
| 963 | - | arg0, | |||
| 964 | - | ) | |||
| 965 | - | }) | |||
| 966 | - | return _c | |||
| 967 | - | } | |||
| 968 | - | ||||
| 969 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |||
| 970 | - | _c.Call.Return() | |||
| 971 | - | return _c | |||
| 972 | - | } | |||
| 973 | - | ||||
| 974 | - | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 975 | - | _c.Run(run) | |||
| 976 | - | return _c | |||
| 977 | - | } | |||
A internal/boundaries/in/mocks/mock_registry_service_deploy.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package mocks | |
| 2 | + | ||
| 3 | + | // SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency. | |
| 4 | + | // Admin handler tests that only need registry behavior do not assert on deploy suppression. | |
| 5 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {} | |
| 6 | + | ||
| 7 | + | // ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency. | |
| 8 | + | // Admin handler tests that only need registry behavior do not assert on deploy suppression. | |
| 9 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {} | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package mocks | |||
| 2 | + | ||||
| 3 | + | // SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency. | |||
| 4 | + | // Admin handler tests that only need registry behavior do not assert on deploy suppression. | |||
| 5 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {} | |||
| 6 | + | ||||
| 7 | + | // ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency. | |||
| 8 | + | // Admin handler tests that only need registry behavior do not assert on deploy suppression. | |||
| 9 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {} | |||
M internal/boundaries/out/envloader.go +1 -1
| @@ -12,5 +12,5 @@ type EnvLoader interface { | |||
| 12 | 12 | CreateEnvFile(ctx context.Context, domain string) error | |
| 13 | 13 | ||
| 14 | 14 | // EnvFileExists checks if an environment file exists for a domain. | |
| 15 | - | EnvFileExists(domain string) bool | |
| 15 | + | EnvFileExists(domain string) (bool, error) | |
| 16 | 16 | } | |
| @@ -12,5 +12,5 @@ type EnvLoader interface { | |||||
| 12 | CreateEnvFile(ctx context.Context, domain string) error | 12 | CreateEnvFile(ctx context.Context, domain string) error | ||
| 13 | 13 | ||||
| 14 | // EnvFileExists checks if an environment file exists for a domain. | 14 | // EnvFileExists checks if an environment file exists for a domain. | ||
| 15 | - | EnvFileExists(domain string) bool | 15 | + | EnvFileExists(domain string) (bool, error) |
| 16 | } | 16 | } | ||
M internal/boundaries/out/mocks/mock_env_loader.go +14 -5
| @@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co | |||
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | // EnvFileExists provides a mock function for the type MockEnvLoader | |
| 98 | - | func (_mock *MockEnvLoader) EnvFileExists(domain string) bool { | |
| 98 | + | func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) { | |
| 99 | 99 | ret := _mock.Called(domain) | |
| 100 | 100 | ||
| 101 | 101 | if len(ret) == 0 { | |
| @@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool { | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | var r0 bool | |
| 106 | + | var r1 error | |
| 107 | + | if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok { | |
| 108 | + | return returnFunc(domain) | |
| 109 | + | } | |
| 106 | 110 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |
| 107 | 111 | r0 = returnFunc(domain) | |
| 108 | 112 | } else { | |
| 109 | 113 | r0 = ret.Get(0).(bool) | |
| 110 | 114 | } | |
| 111 | - | return r0 | |
| 115 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 116 | + | r1 = returnFunc(domain) | |
| 117 | + | } else { | |
| 118 | + | r1 = ret.Error(1) | |
| 119 | + | } | |
| 120 | + | return r0, r1 | |
| 112 | 121 | } | |
| 113 | 122 | ||
| 114 | 123 | // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists' | |
| @@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn | |||
| 135 | 144 | return _c | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 138 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call { | |
| 139 | - | _c.Call.Return(b) | |
| 147 | + | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call { | |
| 148 | + | _c.Call.Return(b, err) | |
| 140 | 149 | return _c | |
| 141 | 150 | } | |
| 142 | 151 | ||
| 143 | - | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call { | |
| 152 | + | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call { | |
| 144 | 153 | _c.Call.Return(run) | |
| 145 | 154 | return _c | |
| 146 | 155 | } | |
| @@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co | |||||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | // EnvFileExists provides a mock function for the type MockEnvLoader | 97 | // EnvFileExists provides a mock function for the type MockEnvLoader | ||
| 98 | - | func (_mock *MockEnvLoader) EnvFileExists(domain string) bool { | 98 | + | func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) { |
| 99 | ret := _mock.Called(domain) | 99 | ret := _mock.Called(domain) | ||
| 100 | 100 | ||||
| 101 | if len(ret) == 0 { | 101 | if len(ret) == 0 { | ||
| @@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool { | |||||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | var r0 bool | 105 | var r0 bool | ||
| 106 | + | var r1 error | |||
| 107 | + | if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok { | |||
| 108 | + | return returnFunc(domain) | |||
| 109 | + | } | |||
| 106 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | 110 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | ||
| 107 | r0 = returnFunc(domain) | 111 | r0 = returnFunc(domain) | ||
| 108 | } else { | 112 | } else { | ||
| 109 | r0 = ret.Get(0).(bool) | 113 | r0 = ret.Get(0).(bool) | ||
| 110 | } | 114 | } | ||
| 111 | - | return r0 | 115 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { |
| 116 | + | r1 = returnFunc(domain) | |||
| 117 | + | } else { | |||
| 118 | + | r1 = ret.Error(1) | |||
| 119 | + | } | |||
| 120 | + | return r0, r1 | |||
| 112 | } | 121 | } | ||
| 113 | 122 | ||||
| 114 | // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists' | 123 | // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists' | ||
| @@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn | |||||
| 135 | return _c | 144 | return _c | ||
| 136 | } | 145 | } | ||
| 137 | 146 | ||||
| 138 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call { | 147 | + | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call { |
| 139 | - | _c.Call.Return(b) | 148 | + | _c.Call.Return(b, err) |
| 140 | return _c | 149 | return _c | ||
| 141 | } | 150 | } | ||
| 142 | 151 | ||||
| 143 | - | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call { | 152 | + | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call { |
| 144 | _c.Call.Return(run) | 153 | _c.Call.Return(run) | ||
| 145 | return _c | 154 | return _c | ||
| 146 | } | 155 | } | ||
M internal/domain/env.go +5 -21
| @@ -7,34 +7,18 @@ import ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | // envDomainRegex validates domain names used for env secrets. | |
| 11 | - | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 12 | - | // Underscores are intentionally disallowed because they would collide with the | |
| 13 | - | // on-disk filename encoding used for dots/ports/path separators. | |
| 14 | - | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 15 | 10 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |
| 16 | 11 | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |
| 17 | 12 | ||
| 18 | 13 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |
| 19 | - | // Returns a safe domain string suitable for filenames. | |
| 20 | - | // Uses distinct replacements to avoid collisions between ".", ":", and "/". | |
| 14 | + | // Returns the collision-resistant storage key used for filenames. | |
| 21 | 15 | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |
| 22 | - | if domainName == "" { | |
| 23 | - | return "", ErrPathTraversal | |
| 16 | + | safeDomain, err := NewEnvStorageKey(domainName) | |
| 17 | + | if err != nil { | |
| 18 | + | return "", err | |
| 24 | 19 | } | |
| 25 | 20 | ||
| 26 | - | if strings.Contains(domainName, "..") { | |
| 27 | - | return "", ErrPathTraversal | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | if !envDomainRegex.MatchString(domainName) { | |
| 31 | - | return "", ErrPathTraversal | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | safeDomain := strings.ReplaceAll(domainName, ".", "__") | |
| 35 | - | safeDomain = strings.ReplaceAll(safeDomain, ":", "-_") | |
| 36 | - | safeDomain = strings.ReplaceAll(safeDomain, "/", "--") | |
| 37 | - | return safeDomain, nil | |
| 21 | + | return string(safeDomain), nil | |
| 38 | 22 | } | |
| 39 | 23 | ||
| 40 | 24 | // ParseEnvData parses env data into a key-value map. | |
| @@ -7,34 +7,18 @@ import ( | |||||
| 7 | "strings" | 7 | "strings" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | - | // envDomainRegex validates domain names used for env secrets. | |||
| 11 | - | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |||
| 12 | - | // Underscores are intentionally disallowed because they would collide with the | |||
| 13 | - | // on-disk filename encoding used for dots/ports/path separators. | |||
| 14 | - | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |||
| 15 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | 10 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | ||
| 16 | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | 11 | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | ||
| 17 | 12 | ||||
| 18 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | 13 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | ||
| 19 | - | // Returns a safe domain string suitable for filenames. | 14 | + | // Returns the collision-resistant storage key used for filenames. |
| 20 | - | // Uses distinct replacements to avoid collisions between ".", ":", and "/". | |||
| 21 | func SanitizeDomainForEnvFile(domainName string) (string, error) { | 15 | func SanitizeDomainForEnvFile(domainName string) (string, error) { | ||
| 22 | - | if domainName == "" { | 16 | + | safeDomain, err := NewEnvStorageKey(domainName) |
| 23 | - | return "", ErrPathTraversal | 17 | + | if err != nil { |
| 18 | + | return "", err | |||
| 24 | } | 19 | } | ||
| 25 | 20 | ||||
| 26 | - | if strings.Contains(domainName, "..") { | 21 | + | return string(safeDomain), nil |
| 27 | - | return "", ErrPathTraversal | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | if !envDomainRegex.MatchString(domainName) { | |||
| 31 | - | return "", ErrPathTraversal | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | safeDomain := strings.ReplaceAll(domainName, ".", "__") | |||
| 35 | - | safeDomain = strings.ReplaceAll(safeDomain, ":", "-_") | |||
| 36 | - | safeDomain = strings.ReplaceAll(safeDomain, "/", "--") | |||
| 37 | - | return safeDomain, nil | |||
| 38 | } | 22 | } | ||
| 39 | 23 | ||||
| 40 | // ParseEnvData parses env data into a key-value map. | 24 | // ParseEnvData parses env data into a key-value map. | ||
M internal/domain/env_test.go +8 -7
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/base64" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 15 | 16 | want string | |
| 16 | 17 | wantErr bool | |
| 17 | 18 | }{ | |
| 18 | - | {name: "simple domain", domain: "example.com", want: "example__com"}, | |
| 19 | - | {name: "subdomain", domain: "app.example.com", want: "app__example__com"}, | |
| 20 | - | {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"}, | |
| 21 | - | {name: "domain with path", domain: "example.com/path", want: "example__com--path"}, | |
| 22 | - | {name: "single char", domain: "a", want: "a"}, | |
| 23 | - | {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"}, | |
| 19 | + | {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))}, | |
| 20 | + | {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))}, | |
| 21 | + | {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))}, | |
| 22 | + | {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))}, | |
| 23 | + | {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))}, | |
| 24 | + | {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))}, | |
| 24 | 25 | {name: "empty", domain: "", wantErr: true}, | |
| 25 | 26 | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |
| 26 | 27 | {name: "double dots", domain: "foo..bar", wantErr: true}, | |
| @@ -28,7 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 28 | 29 | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |
| 29 | 30 | {name: "space", domain: "has space", wantErr: true}, | |
| 30 | 31 | {name: "special chars", domain: "bad$domain", wantErr: true}, | |
| 31 | - | {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true}, | |
| 32 | + | {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true}, | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | for _, tt := range tests { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/base64" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||||
| 15 | want string | 16 | want string | ||
| 16 | wantErr bool | 17 | wantErr bool | ||
| 17 | }{ | 18 | }{ | ||
| 18 | - | {name: "simple domain", domain: "example.com", want: "example__com"}, | 19 | + | {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))}, |
| 19 | - | {name: "subdomain", domain: "app.example.com", want: "app__example__com"}, | 20 | + | {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))}, |
| 20 | - | {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"}, | 21 | + | {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))}, |
| 21 | - | {name: "domain with path", domain: "example.com/path", want: "example__com--path"}, | 22 | + | {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))}, |
| 22 | - | {name: "single char", domain: "a", want: "a"}, | 23 | + | {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))}, |
| 23 | - | {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"}, | 24 | + | {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))}, |
| 24 | {name: "empty", domain: "", wantErr: true}, | 25 | {name: "empty", domain: "", wantErr: true}, | ||
| 25 | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | 26 | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | ||
| 26 | {name: "double dots", domain: "foo..bar", wantErr: true}, | 27 | {name: "double dots", domain: "foo..bar", wantErr: true}, | ||
| @@ -28,7 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||||
| 28 | {name: "ends with dot", domain: "trailing.", wantErr: true}, | 29 | {name: "ends with dot", domain: "trailing.", wantErr: true}, | ||
| 29 | {name: "space", domain: "has space", wantErr: true}, | 30 | {name: "space", domain: "has space", wantErr: true}, | ||
| 30 | {name: "special chars", domain: "bad$domain", wantErr: true}, | 31 | {name: "special chars", domain: "bad$domain", wantErr: true}, | ||
| 31 | - | {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true}, | 32 | + | {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true}, |
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | for _, tt := range tests { | 35 | for _, tt := range tests { | ||
A internal/domain/envfile.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "regexp" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // envDomainRegex validates domain names used for env-backed secret storage. | |
| 10 | + | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 11 | + | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 12 | + | ||
| 13 | + | // EnvStorageKey is the domain-level storage identifier used for env-backed secrets. | |
| 14 | + | // It is deterministic, collision-resistant, and safe to use in file names. | |
| 15 | + | type EnvStorageKey string | |
| 16 | + | ||
| 17 | + | // NewEnvStorageKey validates a route domain and returns the storage-safe identifier | |
| 18 | + | // used by env/secrets adapters. The identifier is reversible and unique for the input. | |
| 19 | + | func NewEnvStorageKey(domainName string) (EnvStorageKey, error) { | |
| 20 | + | if err := ValidateEnvStorageDomain(domainName); err != nil { | |
| 21 | + | return "", err | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ValidateEnvStorageDomain validates domains used for env-backed secret storage. | |
| 28 | + | func ValidateEnvStorageDomain(domainName string) error { | |
| 29 | + | if domainName == "" { | |
| 30 | + | return ErrPathTraversal | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | if strings.Contains(domainName, "..") { | |
| 34 | + | return ErrPathTraversal | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if !envDomainRegex.MatchString(domainName) { | |
| 38 | + | return ErrPathTraversal | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // FileName returns the on-disk env filename for this storage key. | |
| 45 | + | func (k EnvStorageKey) FileName() string { | |
| 46 | + | return string(k) + ".env" | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/base64" | |||
| 5 | + | "regexp" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // envDomainRegex validates domain names used for env-backed secret storage. | |||
| 10 | + | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |||
| 11 | + | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |||
| 12 | + | ||||
| 13 | + | // EnvStorageKey is the domain-level storage identifier used for env-backed secrets. | |||
| 14 | + | // It is deterministic, collision-resistant, and safe to use in file names. | |||
| 15 | + | type EnvStorageKey string | |||
| 16 | + | ||||
| 17 | + | // NewEnvStorageKey validates a route domain and returns the storage-safe identifier | |||
| 18 | + | // used by env/secrets adapters. The identifier is reversible and unique for the input. | |||
| 19 | + | func NewEnvStorageKey(domainName string) (EnvStorageKey, error) { | |||
| 20 | + | if err := ValidateEnvStorageDomain(domainName); err != nil { | |||
| 21 | + | return "", err | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // ValidateEnvStorageDomain validates domains used for env-backed secret storage. | |||
| 28 | + | func ValidateEnvStorageDomain(domainName string) error { | |||
| 29 | + | if domainName == "" { | |||
| 30 | + | return ErrPathTraversal | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if strings.Contains(domainName, "..") { | |||
| 34 | + | return ErrPathTraversal | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if !envDomainRegex.MatchString(domainName) { | |||
| 38 | + | return ErrPathTraversal | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // FileName returns the on-disk env filename for this storage key. | |||
| 45 | + | func (k EnvStorageKey) FileName() string { | |||
| 46 | + | return string(k) + ".env" | |||
| 47 | + | } | |||
M internal/usecase/container/autoroute.go +10 -7
| @@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, | |||
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | 275 | // Load existing env file for this domain | |
| 276 | - | envFileName := domainToEnvFileName(routeDomain) | |
| 276 | + | envFileName, err := domainToEnvFileName(routeDomain) | |
| 277 | + | if err != nil { | |
| 278 | + | return fmt.Errorf("invalid env storage domain: %w", err) | |
| 279 | + | } | |
| 277 | 280 | envFileDst := filepath.Join(h.envDir, envFileName) | |
| 278 | 281 | ||
| 279 | 282 | existingEnv := make(map[string]string) | |
| @@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) { | |||
| 465 | 468 | } | |
| 466 | 469 | ||
| 467 | 470 | // domainToEnvFileName converts a domain to an env file name. | |
| 468 | - | // e.g., "app.mydomain.com" -> "app_mydomain_com.env" | |
| 469 | - | // Must match the naming convention in envloader.FileLoader.getEnvFilePath | |
| 470 | - | func domainToEnvFileName(domainName string) string { | |
| 471 | - | name, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 471 | + | // Must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 472 | + | func domainToEnvFileName(domainName string) (string, error) { | |
| 473 | + | storageKey, err := domain.NewEnvStorageKey(domainName) | |
| 472 | 474 | if err != nil { | |
| 473 | - | return domainName + ".env" | |
| 475 | + | return "", err | |
| 474 | 476 | } | |
| 475 | - | return name + ".env" | |
| 477 | + | ||
| 478 | + | return storageKey.FileName(), nil | |
| 476 | 479 | } | |
| 477 | 480 | ||
| 478 | 481 | // writeEnvFile writes environment variables to a file. | |
| @@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, | |||||
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | // Load existing env file for this domain | 275 | // Load existing env file for this domain | ||
| 276 | - | envFileName := domainToEnvFileName(routeDomain) | 276 | + | envFileName, err := domainToEnvFileName(routeDomain) |
| 277 | + | if err != nil { | |||
| 278 | + | return fmt.Errorf("invalid env storage domain: %w", err) | |||
| 279 | + | } | |||
| 277 | envFileDst := filepath.Join(h.envDir, envFileName) | 280 | envFileDst := filepath.Join(h.envDir, envFileName) | ||
| 278 | 281 | ||||
| 279 | existingEnv := make(map[string]string) | 282 | existingEnv := make(map[string]string) | ||
| @@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) { | |||||
| 465 | } | 468 | } | ||
| 466 | 469 | ||||
| 467 | // domainToEnvFileName converts a domain to an env file name. | 470 | // domainToEnvFileName converts a domain to an env file name. | ||
| 468 | - | // e.g., "app.mydomain.com" -> "app_mydomain_com.env" | 471 | + | // Must match the naming convention in envloader.FileLoader.getEnvFilePath. |
| 469 | - | // Must match the naming convention in envloader.FileLoader.getEnvFilePath | 472 | + | func domainToEnvFileName(domainName string) (string, error) { |
| 470 | - | func domainToEnvFileName(domainName string) string { | 473 | + | storageKey, err := domain.NewEnvStorageKey(domainName) |
| 471 | - | name, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 472 | if err != nil { | 474 | if err != nil { | ||
| 473 | - | return domainName + ".env" | 475 | + | return "", err |
| 474 | } | 476 | } | ||
| 475 | - | return name + ".env" | 477 | + | |
| 478 | + | return storageKey.FileName(), nil | |||
| 476 | } | 479 | } | ||
| 477 | 480 | ||||
| 478 | // writeEnvFile writes environment variables to a file. | 481 | // writeEnvFile writes environment variables to a file. | ||
M internal/usecase/container/autoroute_test.go +7 -6
| @@ -28,33 +28,34 @@ func TestDomainToEnvFileName(t *testing.T) { | |||
| 28 | 28 | { | |
| 29 | 29 | name: "simple domain", | |
| 30 | 30 | domain: "app.example.com", | |
| 31 | - | expected: "app__example__com.env", | |
| 31 | + | expected: "YXBwLmV4YW1wbGUuY29t.env", | |
| 32 | 32 | }, | |
| 33 | 33 | { | |
| 34 | 34 | name: "domain with port", | |
| 35 | 35 | domain: "app.example.com:8080", | |
| 36 | - | expected: "app__example__com-_8080.env", | |
| 36 | + | expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env", | |
| 37 | 37 | }, | |
| 38 | 38 | { | |
| 39 | 39 | name: "domain with path", | |
| 40 | 40 | domain: "app.example.com/api", | |
| 41 | - | expected: "app__example__com--api.env", | |
| 41 | + | expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env", | |
| 42 | 42 | }, | |
| 43 | 43 | { | |
| 44 | 44 | name: "complex domain", | |
| 45 | 45 | domain: "sub.app.example.com:443/v1", | |
| 46 | - | expected: "sub__app__example__com-_443--v1.env", | |
| 46 | + | expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env", | |
| 47 | 47 | }, | |
| 48 | 48 | { | |
| 49 | 49 | name: "single word", | |
| 50 | 50 | domain: "localhost", | |
| 51 | - | expected: "localhost.env", | |
| 51 | + | expected: "bG9jYWxob3N0.env", | |
| 52 | 52 | }, | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | for _, tt := range tests { | |
| 56 | 56 | t.Run(tt.name, func(t *testing.T) { | |
| 57 | - | result := domainToEnvFileName(tt.domain) | |
| 57 | + | result, err := domainToEnvFileName(tt.domain) | |
| 58 | + | require.NoError(t, err) | |
| 58 | 59 | assert.Equal(t, tt.expected, result) | |
| 59 | 60 | }) | |
| 60 | 61 | } | |
| @@ -28,33 +28,34 @@ func TestDomainToEnvFileName(t *testing.T) { | |||||
| 28 | { | 28 | { | ||
| 29 | name: "simple domain", | 29 | name: "simple domain", | ||
| 30 | domain: "app.example.com", | 30 | domain: "app.example.com", | ||
| 31 | - | expected: "app__example__com.env", | 31 | + | expected: "YXBwLmV4YW1wbGUuY29t.env", |
| 32 | }, | 32 | }, | ||
| 33 | { | 33 | { | ||
| 34 | name: "domain with port", | 34 | name: "domain with port", | ||
| 35 | domain: "app.example.com:8080", | 35 | domain: "app.example.com:8080", | ||
| 36 | - | expected: "app__example__com-_8080.env", | 36 | + | expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env", |
| 37 | }, | 37 | }, | ||
| 38 | { | 38 | { | ||
| 39 | name: "domain with path", | 39 | name: "domain with path", | ||
| 40 | domain: "app.example.com/api", | 40 | domain: "app.example.com/api", | ||
| 41 | - | expected: "app__example__com--api.env", | 41 | + | expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env", |
| 42 | }, | 42 | }, | ||
| 43 | { | 43 | { | ||
| 44 | name: "complex domain", | 44 | name: "complex domain", | ||
| 45 | domain: "sub.app.example.com:443/v1", | 45 | domain: "sub.app.example.com:443/v1", | ||
| 46 | - | expected: "sub__app__example__com-_443--v1.env", | 46 | + | expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env", |
| 47 | }, | 47 | }, | ||
| 48 | { | 48 | { | ||
| 49 | name: "single word", | 49 | name: "single word", | ||
| 50 | domain: "localhost", | 50 | domain: "localhost", | ||
| 51 | - | expected: "localhost.env", | 51 | + | expected: "bG9jYWxob3N0.env", |
| 52 | }, | 52 | }, | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | for _, tt := range tests { | 55 | for _, tt := range tests { | ||
| 56 | t.Run(tt.name, func(t *testing.T) { | 56 | t.Run(tt.name, func(t *testing.T) { | ||
| 57 | - | result := domainToEnvFileName(tt.domain) | 57 | + | result, err := domainToEnvFileName(tt.domain) |
| 58 | + | require.NoError(t, err) | |||
| 58 | assert.Equal(t, tt.expected, result) | 59 | assert.Equal(t, tt.expected, result) | ||
| 59 | }) | 60 | }) | ||
| 60 | } | 61 | } | ||