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:
1 parent
e474f03d91
commit
0b4e614ec9
15 files changed
+722
-66
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user