refac: runtime settings are read once at startup and passed in through the composition root; no handler, router or repository reads environment variables

This commit is contained in:
fsylvain committed 2026-09-24 01:30:00 +02:00
1 parent e474f03d91
commit 0b4e614ec9
15 files changed
+722 -66

No files matched your search

+2
View File
@@ -218,6 +218,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
@@ -237,6 +238,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
+1 -2
View File
@@ -15,8 +15,7 @@ func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewImageRepository(db)
t.Setenv("UPLOAD_DIR", t.TempDir())
repo := persistence.NewImageRepository(db, t.TempDir())
allowedContentTypes := imageDomain.AllowedContentTypes()
expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"}
+1
View File
@@ -138,6 +138,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
+113
View File
@@ -0,0 +1,113 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"testing"
)
func setBaseSettingsEnv(t *testing.T) {
t.Helper()
t.Setenv("ENVIRONMENT", "production")
t.Setenv("PUBLIC_URL", "https://cms.example.com")
t.Setenv("UPLOAD_DIR", "./data/uploads")
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("DB_FILE", "./data/renewcms.db")
t.Setenv("PORT", "8080")
t.Setenv("SMTP_EMAIL", "notify@example.com")
t.Setenv("SMTP_PASSWORD", "hunter2")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_PORT", "465")
t.Setenv("CORS_ALLOWED_ORIGINS", "")
}
func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("CORS_ALLOWED_ORIGINS", "https://cms.example.com;https://admin.example.com")
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if settings.Environment != "production" {
t.Errorf("expected Environment %q, got %q", "production", settings.Environment)
}
if settings.PublicURL != "https://cms.example.com" {
t.Errorf("expected PublicURL %q, got %q", "https://cms.example.com", settings.PublicURL)
}
if settings.UploadDir != "./data/uploads" {
t.Errorf("expected UploadDir %q, got %q", "./data/uploads", settings.UploadDir)
}
if settings.JWTSecret != "test-secret" {
t.Errorf("expected JWTSecret %q, got %q", "test-secret", settings.JWTSecret)
}
if settings.DBFile != "./data/renewcms.db" {
t.Errorf("expected DBFile %q, got %q", "./data/renewcms.db", settings.DBFile)
}
if settings.Port != "8080" {
t.Errorf("expected Port %q, got %q", "8080", settings.Port)
}
if settings.Smtp.Email != "notify@example.com" {
t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email)
}
if settings.Smtp.Password != "hunter2" {
t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password)
}
if settings.Smtp.Host != "smtp.example.com" {
t.Errorf("expected Smtp.Host %q, got %q", "smtp.example.com", settings.Smtp.Host)
}
if settings.Smtp.Port != 465 {
t.Errorf("expected Smtp.Port %d, got %d", 465, settings.Smtp.Port)
}
expectedOrigins := []string{"https://cms.example.com", "https://admin.example.com"}
if len(settings.CorsAllowedOrigins) != len(expectedOrigins) {
t.Fatalf("expected %d origins, got %v", len(expectedOrigins), settings.CorsAllowedOrigins)
}
for i, origin := range expectedOrigins {
if settings.CorsAllowedOrigins[i] != origin {
t.Errorf("expected origin %d to be %q, got %q", i, origin, settings.CorsAllowedOrigins[i])
}
}
}
func TestNewSettingsFromEnvEmptyCorsOriginsIsNil(t *testing.T) {
setBaseSettingsEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if settings.CorsAllowedOrigins != nil {
t.Errorf("expected nil CorsAllowedOrigins, got %v", settings.CorsAllowedOrigins)
}
}
func TestNewSettingsFromEnvSingleCorsOrigin(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("CORS_ALLOWED_ORIGINS", "https://cms.example.com")
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(settings.CorsAllowedOrigins) != 1 || settings.CorsAllowedOrigins[0] != "https://cms.example.com" {
t.Errorf("expected a single origin %q, got %v", "https://cms.example.com", settings.CorsAllowedOrigins)
}
}
func TestNewSettingsFromEnvInvalidSmtpPortReturnsError(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("SMTP_PORT", "not-a-number")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error for a non-numeric SMTP_PORT")
}
}
func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) {
settings := config.Settings{Environment: "development"}
if settings.IsProduction() {
t.Error("expected IsProduction to be false for environment \"development\"")
}
}
@@ -0,0 +1,246 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/handlers/auth"
validator "RenewCMS/api/pkg/validator"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
imageDomain "RenewCMS/internal/domain/image"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) {
t.Helper()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
hasher := hashing.NewBcryptHasher()
hashed, err := hasher.Hash("hunter2example")
if err != nil {
t.Fatalf("failed to hash password: %v", err)
}
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: hashed, IsVerified: true}
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
isProduction,
)
r := chi.NewRouter()
r.Post("/login", authHandler.Login)
return r, userRepo
}
func performLoginRequest(t *testing.T, router *chi.Mux) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(map[string]string{"username": "alice", "password": "hunter2example"})
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func secureAttributeFromResponse(t *testing.T, rec *httptest.ResponseRecorder) bool {
t.Helper()
values := rec.Result().Header.Values("Set-Cookie")
for _, value := range values {
cookie, err := http.ParseSetCookie(value)
if err != nil {
t.Fatalf("failed to parse Set-Cookie header %q: %v", value, err)
}
if cookie.Name == "jwt" {
return cookie.Secure
}
}
t.Fatalf("expected a jwt Set-Cookie header, got %v", values)
return false
}
func TestRuntimeSettingsInjectionSecureCookie(t *testing.T) {
validator.InitValidator()
outline := []struct {
injectedEnvironment string
processEnvironment string
secureExpected bool
}{
{"production", "production", true},
{"production", "development", true},
{"development", "production", false},
{"development", "development", false},
}
for _, tc := range outline {
t.Run("Secure follows the injected environment "+tc.injectedEnvironment+"/"+tc.processEnvironment, func(t *testing.T) {
isProduction := tc.injectedEnvironment == "production"
router, _ := newSecureCookieAuthRouter(t, isProduction)
t.Setenv("ENVIRONMENT", tc.processEnvironment)
rec := performLoginRequest(t, router)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
if got := secureAttributeFromResponse(t, rec); got != tc.secureExpected {
t.Errorf("expected Secure=%v, got %v", tc.secureExpected, got)
}
})
}
t.Run("Changing the process environment after construction does not change already-built behaviour", func(t *testing.T) {
router, _ := newSecureCookieAuthRouter(t, true)
t.Setenv("ENVIRONMENT", "development")
rec := performLoginRequest(t, router)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
if got := secureAttributeFromResponse(t, rec); !got {
t.Errorf("expected Secure=true, got %v", got)
}
})
}
func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
t.Helper()
articleRepo := newMockArticleRepo()
userRepo := newMockUserRepo()
imageRepo := newMockImageRepo()
mailRepo := newMockMailRepo()
hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(userRepo, hasher),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo),
}
}
func TestRuntimeSettingsInjectionCors(t *testing.T) {
validator.InitValidator()
container := newRuntimeSettingsContainer(t)
t.Run("CORS allows a request from an injected allowed origin", func(t *testing.T) {
settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}}
router := api.InitRoutes(container, settings)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.Header.Set("Origin", "https://cms.example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "https://cms.example.com" {
t.Errorf("expected Access-Control-Allow-Origin %q, got %q", "https://cms.example.com", got)
}
})
t.Run("CORS does not allow a request from an origin outside the injected list", func(t *testing.T) {
settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}}
router := api.InitRoutes(container, settings)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.Header.Set("Origin", "https://attacker.example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
}
})
t.Run("An empty injected origins list disables CORS entirely", func(t *testing.T) {
settings := config.Settings{CorsAllowedOrigins: nil}
router := api.InitRoutes(container, settings)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.Header.Set("Origin", "https://cms.example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
}
})
}
func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
injectedUploadDir := t.TempDir()
repo := persistence.NewImageRepository(db, injectedUploadDir)
processUploadDir := t.TempDir()
t.Setenv("UPLOAD_DIR", processUploadDir)
result, err := repo.Create(imageDomain.ImageInput{
Content: bytes.NewReader([]byte("fake-image-data")),
Filename: "upload.png",
ContentType: "image/png",
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" {
t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got)
}
fileName := filepath.Base(result.Path)
if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil {
t.Errorf("expected the file to exist under the injected upload dir: %v", err)
}
if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) {
t.Errorf("expected the file to not exist under the process UPLOAD_DIR")
}
}
func TestRuntimeSettingsInjectionMailer(t *testing.T) {
smtp := config.SmtpSettings{
Email: "notify@example.com",
Host: "smtp.example.com",
Port: 587,
Password: "unused-in-test",
}
repo := mailer.NewMailRepository(smtp)
if repo == nil {
t.Fatalf("expected a non-nil MailRepository")
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing
if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil {
t.Fatalf("failed to seed image: %v", err)
}
repo := persistence.NewImageRepository(db)
repo := persistence.NewImageRepository(db, t.TempDir())
err := repo.Delete(9)
assertWriteResultIsImageNotFound(t, err)