fix(backend): harden final MVP invariants
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -45,5 +46,52 @@ func LoadFromDir(configDir string) (Config, error) {
|
||||
if cfg.MaxUploadBytes <= 0 {
|
||||
cfg.MaxUploadBytes = 32 << 20
|
||||
}
|
||||
if strings.TrimSpace(cfg.StorageDir) == "" {
|
||||
return Config{}, fmt.Errorf("storage_dir must not be empty")
|
||||
}
|
||||
hasAllowedOrigin := false
|
||||
for _, origin := range cfg.AllowedOrigins {
|
||||
if strings.TrimSpace(origin) != "" {
|
||||
hasAllowedOrigin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAllowedOrigin {
|
||||
return Config{}, fmt.Errorf("allowed_origins must include at least one origin")
|
||||
}
|
||||
if err := validateSecret(cfg.Env, "auth_secret", cfg.AuthSecret); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateSecret(cfg.Env, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateSecret(environment, field, value string) error {
|
||||
secret := strings.TrimSpace(value)
|
||||
if secret == "" {
|
||||
return fmt.Errorf("%s must not be empty", field)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(environment), "production") || strings.EqualFold(strings.TrimSpace(environment), "prod") {
|
||||
if len(secret) < 32 || isCommonSecret(secret) {
|
||||
return fmt.Errorf("%s must be at least 32 characters and must not use a development sentinel in production", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCommonSecret(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch normalized {
|
||||
case "secret", "password", "default", "admin":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,38 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDevelopmentConfigMatchesLocalWorkspaceContract(t *testing.T) {
|
||||
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||
|
||||
cfg, err := LoadFromDir(filepath.Join("..", "..", "etc"))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "development", cfg.Env)
|
||||
require.Equal(t, "9150", cfg.Port)
|
||||
require.Equal(t, "postgres://postgres:postgres@localhost:5432/agent_dev?sslmode=disable", cfg.DSN)
|
||||
require.NotEmpty(t, strings.TrimSpace(cfg.StorageDir))
|
||||
require.Equal(t, int64(32<<20), cfg.MaxUploadBytes)
|
||||
require.ElementsMatch(t, []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:4173",
|
||||
"http://127.0.0.1:4173",
|
||||
"http://localhost:4174",
|
||||
"http://127.0.0.1:4174",
|
||||
"http://localhost:4175",
|
||||
"http://127.0.0.1:4175",
|
||||
"http://localhost:4176",
|
||||
"http://127.0.0.1:4176",
|
||||
"http://tauri.localhost",
|
||||
}, cfg.AllowedOrigins)
|
||||
}
|
||||
|
||||
func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "dev-system", "dev-ai")
|
||||
@@ -31,7 +58,7 @@ func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
|
||||
func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "", "dev-ai")
|
||||
writeConfig(t, configDir, "agent.prod.yaml", "production", "80", "postgres://prod", "/data/files", "prod-auth", "prod-system", "prod-ai")
|
||||
writeConfig(t, configDir, "agent.prod.yaml", "production", "80", "postgres://prod", "/data/files", "production-auth-signing-key-2026-safe", "prod-system", "production-ai-encryption-key-2026-safe")
|
||||
t.Setenv("SENLIN_APP_MODE", "prod")
|
||||
|
||||
cfg, err := LoadFromDir(configDir)
|
||||
@@ -41,12 +68,98 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
|
||||
require.Equal(t, "80", cfg.Port)
|
||||
require.Equal(t, "postgres://prod", cfg.DSN)
|
||||
require.Equal(t, "/data/files", cfg.StorageDir)
|
||||
require.Equal(t, "prod-auth", cfg.AuthSecret)
|
||||
require.Equal(t, "production-auth-signing-key-2026-safe", cfg.AuthSecret)
|
||||
require.Equal(t, "prod-system", cfg.SystemAIKey)
|
||||
require.Equal(t, "prod-ai", cfg.AIKeyEncryptionSecret)
|
||||
require.Equal(t, "production-ai-encryption-key-2026-safe", cfg.AIKeyEncryptionSecret)
|
||||
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
|
||||
}
|
||||
|
||||
func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai")
|
||||
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||
|
||||
_, err := LoadFromDir(configDir)
|
||||
|
||||
require.ErrorContains(t, err, "storage_dir")
|
||||
}
|
||||
|
||||
func TestLoadFromDirRejectsMissingAllowedOrigins(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
content := []byte("env: development\n" +
|
||||
"port: \"9150\"\n" +
|
||||
"dsn: \"postgres://agent\"\n" +
|
||||
"storage_dir: \"./data/files\"\n" +
|
||||
"auth_secret: \"dev-auth\"\n" +
|
||||
"ai_key_encryption_secret: \"dev-ai\"\n")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "agent.dev.yaml"), content, 0o600))
|
||||
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||
|
||||
_, err := LoadFromDir(configDir)
|
||||
|
||||
require.ErrorContains(t, err, "allowed_origins")
|
||||
}
|
||||
|
||||
func TestLoadFromDirRejectsUnsafeProductionSecrets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authSecret string
|
||||
encryptionSecret string
|
||||
expectedFieldName string
|
||||
}{
|
||||
{name: "empty auth", authSecret: "", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||
{name: "short auth", authSecret: "short", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||
{name: "development auth sentinel", authSecret: "development-auth-secret-change-me", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||
{name: "dev auth sentinel", authSecret: "dev-secret-dev-secret-dev-secret-000", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||
{name: "empty encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "", expectedFieldName: "ai_key_encryption_secret"},
|
||||
{name: "short encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "short", expectedFieldName: "ai_key_encryption_secret"},
|
||||
{name: "common encryption sentinel", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "change-me-change-me-change-me-change-me", expectedFieldName: "ai_key_encryption_secret"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.prod.yaml", "production", "80", "postgres://prod", "/data/files", test.authSecret, "", test.encryptionSecret)
|
||||
t.Setenv("SENLIN_APP_MODE", "prod")
|
||||
|
||||
_, err := LoadFromDir(configDir)
|
||||
|
||||
require.ErrorContains(t, err, test.expectedFieldName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDirRequiresExplicitDevelopmentSecrets(t *testing.T) {
|
||||
for _, missingField := range []string{"auth", "encryption"} {
|
||||
t.Run(missingField, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
authSecret, encryptionSecret := "development-auth", "development-encryption"
|
||||
if missingField == "auth" {
|
||||
authSecret = ""
|
||||
} else {
|
||||
encryptionSecret = ""
|
||||
}
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://dev", "./files", authSecret, "", encryptionSecret)
|
||||
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||
|
||||
_, err := LoadFromDir(configDir)
|
||||
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDirAllowsExplicitTestSecrets(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.test.yaml", "test", "9150", "postgres://test", "./files", "test-auth", "", "test-ai")
|
||||
t.Setenv("SENLIN_APP_MODE", "test")
|
||||
|
||||
cfg, err := LoadFromDir(configDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-auth", cfg.AuthSecret)
|
||||
require.Equal(t, "test-ai", cfg.AIKeyEncryptionSecret)
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) {
|
||||
t.Helper()
|
||||
allowedOrigins := " - http://localhost:5173\n - http://tauri.localhost\n"
|
||||
|
||||
Reference in New Issue
Block a user