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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user