diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 62ac288..c866698 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -5,6 +5,7 @@ import ( domainImage "RenewCMS/internal/domain/image" domainMail "RenewCMS/internal/domain/mail" domainUser "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/persistence/models" @@ -43,8 +44,7 @@ type Repositories struct { MailRepo domainMail.Repository } -func getDb() *gorm.DB { - dbName := os.Getenv("DB_FILE") +func getDb(dbName string) *gorm.DB { if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { panic("Unable to create necessary subdirectories: " + err.Error()) } @@ -60,20 +60,20 @@ func getDb() *gorm.DB { return db } -func getRepositories(db *gorm.DB) *Repositories { +func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { return &Repositories{ ArticleRepo: persistence.NewArticleRepository(db), UserRepo: persistence.NewUserRepository(db), - ImageRepo: persistence.NewImageRepository(db), - MailRepo: mailer.NewMailRepository(), + ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), + MailRepo: mailer.NewMailRepository(settings.Smtp), } } -func getUseCases(repos *Repositories) *UseCases { +func getUseCases(repos *Repositories, settings config.Settings) *UseCases { hasher := hashing.NewBcryptHasher() return &UseCases{ - Token: jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil), + Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), @@ -91,10 +91,10 @@ func getUseCases(repos *Repositories) *UseCases { } } -func InitContainer() (*UseCases, func() error) { - db := getDb() - repos := getRepositories(db) - return getUseCases(repos), closeDb(db) +func InitContainer(settings config.Settings) (*UseCases, func() error) { + db := getDb(settings.DBFile) + repos := getRepositories(db, settings) + return getUseCases(repos, settings), closeDb(db) } func closeDb(db *gorm.DB) func() error { diff --git a/api/handlers/auth/authHandler.go b/api/handlers/auth/authHandler.go index 15e0ae7..c199b03 100644 --- a/api/handlers/auth/authHandler.go +++ b/api/handlers/auth/authHandler.go @@ -6,7 +6,6 @@ import ( "RenewCMS/internal/infrastructure/useCases" "encoding/json" "net/http" - "os" "time" "github.com/go-chi/jwtauth/v5" @@ -18,6 +17,7 @@ type Handler struct { ListUseCase *useCases.ListUsersUseCase CreateUseCase *useCases.CreateUserUseCase AuthenticateUseCase *useCases.AuthenticateUserUseCase + IsProduction bool } type RegisterCredentials struct { @@ -31,29 +31,26 @@ type LoginCredentials struct { Password string `json:"password" validate:"required,min=8"` } -func shouldCookieBeSecure() bool { - return os.Getenv("ENVIRONMENT") == "production" -} - -func removeJwtCookie(w http.ResponseWriter) { +func (h *Handler) removeJwtCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: "", Expires: time.Now(), MaxAge: -1, - Secure: shouldCookieBeSecure(), + Secure: h.IsProduction, HttpOnly: true, Path: "/", }) } -func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase) *Handler { +func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *Handler { return &Handler{ Token: token, GetUseCase: get, ListUseCase: list, CreateUseCase: create, AuthenticateUseCase: authenticate, + IsProduction: isProduction, } } @@ -66,7 +63,7 @@ func (h *Handler) SetJwtCookie(w *http.ResponseWriter, userId uint32) error { Name: "jwt", Value: tokenString, Expires: time.Now().Add(24 * time.Hour), - Secure: shouldCookieBeSecure(), + Secure: h.IsProduction, HttpOnly: true, Path: "/", }) @@ -176,7 +173,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { } func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) { - removeJwtCookie(w) + h.removeJwtCookie(w) message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"}) _, _ = w.Write(message) } diff --git a/api/router.go b/api/router.go index 90f69ae..de9cce4 100644 --- a/api/router.go +++ b/api/router.go @@ -6,10 +6,9 @@ import ( "RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/image" "RenewCMS/api/middleware" + "RenewCMS/internal/infrastructure/config" "encoding/json" "net/http" - "os" - "strings" "github.com/MadAppGang/httplog" "github.com/go-chi/chi/v5" @@ -18,8 +17,8 @@ import ( "github.com/go-chi/jwtauth/v5" ) -func enableLoggingIfDev(r *chi.Mux, routerName string) { - if os.Getenv("ENVIRONMENT") == "development" { +func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) { + if environment == "development" { r.Use(httplog.LoggerWithName(routerName)) } } @@ -51,10 +50,10 @@ func NewArticleRouter(container *UseCases) http.Handler { return r } -func NewAuthRouter(container *UseCases) http.Handler { +func NewAuthRouter(container *UseCases, isProduction bool) http.Handler { r := chi.NewRouter() - authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase) + authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) r.Post("/register", authHandler.Register) r.Post("/login", authHandler.Login) @@ -62,10 +61,10 @@ func NewAuthRouter(container *UseCases) http.Handler { return r } -func InitBackendRoutes(container *UseCases) *chi.Mux { +func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { r := chi.NewRouter() - enableLoggingIfDev(r, "backend") + enableLoggingIfDev(r, "backend", settings.Environment) r.Use(chimiddleware.GetHead) r.Use(middleware.JsonContentTypeMiddleware) @@ -74,15 +73,15 @@ func InitBackendRoutes(container *UseCases) *chi.Mux { r.Use(jwtauth.Verifier(container.Token)) r.Mount("/article", NewArticleRouter(container)) }) - r.Mount("/auth", NewAuthRouter(container)) + r.Mount("/auth", NewAuthRouter(container, settings.IsProduction())) return r } -func InitFrontendRoutes() *chi.Mux { +func InitFrontendRoutes(environment string) *chi.Mux { r := chi.NewRouter() - enableLoggingIfDev(r, "frontend") + enableLoggingIfDev(r, "frontend", environment) r.Mount("/", frontend.NewFrontendRouter()) @@ -94,13 +93,12 @@ func GetHealth(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) } -func enableCorsIfConfigured(r *chi.Mux) { - origins := os.Getenv("CORS_ALLOWED_ORIGINS") - if origins == "" { +func enableCorsIfConfigured(r *chi.Mux, origins []string) { + if len(origins) == 0 { return } r.Use(cors.Handler(cors.Options{ - AllowedOrigins: strings.Split(origins, ";"), + AllowedOrigins: origins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, @@ -109,12 +107,12 @@ func enableCorsIfConfigured(r *chi.Mux) { })) } -func InitRoutes(container *UseCases) *chi.Mux { - backend := InitBackendRoutes(container) - frontend := InitFrontendRoutes() +func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { + backend := InitBackendRoutes(container, settings) + frontend := InitFrontendRoutes(settings.Environment) apiRouter := chi.NewRouter() - enableCorsIfConfigured(apiRouter) + enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins) apiRouter.Use(chimiddleware.GetHead) apiRouter.Get("/healthz", GetHealth) apiRouter.Mount("/v1", backend) diff --git a/cmd/server/main.go b/cmd/server/main.go index c9e1215..b1f48c9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "RenewCMS/api" validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/infrastructure/config" "context" "errors" "fmt" @@ -53,12 +54,17 @@ func main() { func run() error { initEnvVariables() - container, closeDb := api.InitContainer() + settings, err := config.NewSettingsFromEnv() + if err != nil { + return err + } + + container, closeDb := api.InitContainer(settings) defer closeDatabase(closeDb) validator.InitValidator() - server := newServer(api.InitRoutes(container)) - return serveUntilStopped(server) + server := newServer(api.InitRoutes(container, settings), settings) + return serveUntilStopped(server, settings) } func initEnvVariables() { @@ -98,9 +104,9 @@ func closeDatabase(closeDb func() error) { } } -func newServer(handler http.Handler) *http.Server { +func newServer(handler http.Handler, settings config.Settings) *http.Server { return &http.Server{ - Addr: ":" + os.Getenv("PORT"), + Addr: ":" + settings.Port, Handler: handler, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, @@ -109,12 +115,12 @@ func newServer(handler http.Handler) *http.Server { } } -func serveUntilStopped(server *http.Server) error { +func serveUntilStopped(server *http.Server, settings config.Settings) error { stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() serverErr := make(chan error, 1) - go listen(server, serverErr) + go listen(server, serverErr, settings) select { case err := <-serverErr: @@ -124,8 +130,8 @@ func serveUntilStopped(server *http.Server) error { } } -func listen(server *http.Server, serverErr chan<- error) { - log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, os.Getenv("ENVIRONMENT"), server.Addr, os.Getenv("PUBLIC_URL")) +func listen(server *http.Server, serverErr chan<- error, settings config.Settings) { + log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, settings.Environment, server.Addr, settings.PublicURL) err := server.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { err = nil diff --git a/features/runtime_settings_injection.feature b/features/runtime_settings_injection.feature new file mode 100644 index 0000000..ea305f3 --- /dev/null +++ b/features/runtime_settings_injection.feature @@ -0,0 +1,120 @@ +Feature: Runtime settings are read once at startup + + As a maintainer of RenewCMS + I want the environment, CORS origins, public URL, upload dir and SMTP + settings to be read from the process environment exactly once, in the + composition root, and handed down explicitly from there + So that no handler, router or repository reads an environment variable at + request or construction time, and changing the process environment after + startup can never change already-built behaviour + + Background: + Given before this slice, os.Getenv is called outside cmd/server/main.go + in four places: api/router.go's enableLoggingIfDev reads ENVIRONMENT + on every router build, api/router.go's enableCorsIfConfigured reads + CORS_ALLOWED_ORIGINS on every router build, + api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every + Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send + reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call, + and internal/infrastructure/persistence.ImageRepository.Create and + .Delete read UPLOAD_DIR on every call + And this slice introduces internal/infrastructure/config.Settings, a + plain struct with fields Environment, CorsAllowedOrigins ([]string), + PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct + (Email, Password, Host, Port) + And cmd/server/main.go's initEnvVariables still loads the .env file and + applies the same defaults and required-var checks as before, then + builds exactly one config.Settings value from the process environment, + before api.InitContainer or api.InitRoutes is ever called + And api.InitContainer and api.InitRoutes now take that config.Settings + value, and every downstream constructor receives its own slice of it + explicitly instead of reading os.Getenv: auth.NewHandler receives + whether the environment is production, mailer.NewMailRepository + receives the Smtp settings, persistence.NewImageRepository receives + the upload dir, and the router's logging and CORS setup receive the + environment string and the CORS origins list as parameters + And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT, + CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT + and UPLOAD_DIR appears only inside cmd/server/main.go and + internal/infrastructure/config + And a component built from a config.Settings value keeps behaving + according to the values it was built with even if the process + environment changes afterwards, because it holds the captured values, + not the environment + And GET /healthz is mounted directly on the outer API router + (api/router.go), not under /v1, so the CORS middleware wraps it the + same way it wraps every route + + Scenario Outline: The jwt cookie's Secure flag follows the injected environment, not the process environment + Given a config.Settings value with Environment "" + And the auth handler is built from that settings value + And the process environment variable ENVIRONMENT is set to "" + When the client sends POST /v1/auth/login with valid credentials for an + existing, verified user + Then the response's Set-Cookie header for the "jwt" cookie has its + Secure attribute set to + + Examples: + | injected environment | process environment | secure expected | + | production | production | true | + | production | development | true | + | development | production | false | + | development | development | false | + + Scenario: CORS allows a request from an injected allowed origin + Given a config.Settings value with CorsAllowedOrigins + ["https://cms.example.com"] + And the API router is built from that settings value + When the client sends GET /healthz with header + Origin: https://cms.example.com + Then the response has header Access-Control-Allow-Origin: + https://cms.example.com + + Scenario: CORS does not allow a request from an origin outside the injected list + Given a config.Settings value with CorsAllowedOrigins + ["https://cms.example.com"] + And the API router is built from that settings value + When the client sends GET /healthz with header + Origin: https://attacker.example.com + Then the response has no Access-Control-Allow-Origin header + + Scenario: An empty injected origins list disables CORS entirely + Given a config.Settings value with CorsAllowedOrigins as an empty list + And the API router is built from that settings value + When the client sends GET /healthz with header + Origin: https://cms.example.com + Then the response has no Access-Control-Allow-Origin header + + Scenario: An uploaded image is stored under the injected upload directory + Given a config.Settings value with UploadDir pointing at a fresh + directory created for this scenario + And the image repository is built from that settings value, over a + fresh in-memory database + And the process environment variable UPLOAD_DIR is unset or points at a + different directory + When internal/infrastructure/persistence.ImageRepository.Create is + called with a PNG file + Then the image file is written inside the injected upload directory, + not inside the process environment's UPLOAD_DIR + And the returned image's Path still starts with "/static/uploadedImages/" + + Scenario: Changing the process environment after construction does not change already-built behaviour + Given a config.Settings value with Environment "production" + And the auth handler is built from that settings value + When the process environment variable ENVIRONMENT is then changed to + "development" + And the client sends POST /v1/auth/login with valid credentials for an + existing, verified user + Then the response's Set-Cookie header for the "jwt" cookie still has its + Secure attribute set to true + + Scenario: The mailer is constructed from injected SMTP settings, not the environment + Given a config.Settings value with Smtp.Email "notify@example.com", + Smtp.Host "smtp.example.com" and Smtp.Port 587 + And internal/infrastructure/mailer.NewMailRepository is called with that + Smtp settings value and no SMTP_* environment variable set + Then constructing the MailRepository does not read any SMTP_* + environment variable + And the maintainer verifies by manual test (see QA procedure) that an + actual send still uses those injected values, since a real SMTP + dialog is never exercised by the automated suite diff --git a/features/runtime_settings_injection.qa.md b/features/runtime_settings_injection.qa.md new file mode 100644 index 0000000..d52447f --- /dev/null +++ b/features/runtime_settings_injection.qa.md @@ -0,0 +1,113 @@ +# QA procedure: runtime_settings_injection + +Package: `test` (black-box). + +## Setup shared by every case +1. Call `validator.InitValidator()` once. +2. For the secure-cookie and upload-directory cases, build only the pieces + under test directly (no real SQLite file), mirroring + `test/article_publishing_http_test.go` and siblings. +3. For the CORS cases, build the **real** router construction path — see + "CORS" section below. A test-local reimplementation of the CORS wiring + (a helper that copies `enableCorsIfConfigured`'s logic instead of calling + it) is not acceptable: the point of these scenarios is to prove the + actual `api/router.go` wiring, driven by injected settings, behaves + correctly, not that some parallel logic does. +4. Never set `t.Setenv` before constructing a component under test in a way + that the component is expected to read; the whole point of this suite is + that env is captured once elsewhere. Where a scenario needs to prove + the environment has no effect, set it with `t.Setenv` explicitly *after* + construction, and say so in the test's own scenario name (not as a code + comment). + +## Secure cookie flag (Scenario Outline + regression scenario) +5. Build a `mockUserRepo` (reuse `test/user_test.go`'s fake) seeded with one + verified user and a known bcrypt hash via `hashing.NewBcryptHasher()`. +6. Build `auth.NewHandler(token, getUseCase, listUseCase, createUseCase, + authenticateUseCase, )` — this slice adds a + constructor parameter carrying the environment/production flag in place + of `shouldCookieBeSecure`'s `os.Getenv` call. Mount only `POST /login` on + a fresh `chi.NewRouter()`. +7. For each row of the outline: optionally `t.Setenv("ENVIRONMENT", ...)` + with the process value from the table (this must have zero effect), then + send `POST /login` with valid JSON credentials for the seeded user via + `httptest.NewRequest` + `httptest.NewRecorder()`. Read the `Set-Cookie` + response header value with `rec.Result().Header.Values("Set-Cookie")`, + parse it with `http.ParseSetCookie(...)`, and assert `.Secure` equals the + table's expected value. +8. For the "changing the process environment after construction" scenario, + build the handler once with the production flag fixed at construction + time, then call `t.Setenv("ENVIRONMENT", "development")`, then perform the + same login request and assert `Secure` is still `true`. + +## CORS +9. Build a `*api.UseCases` (or whatever the composition-root container is + named after this slice) backed by the existing in-memory fakes + (`mockArticleRepo`, `mockUserRepo`, `mockImageRepo`, `mockMailRepo`, a + test `jwtauth.New("HS256", []byte("test-secret"), nil)`), then call the + real `api.InitRoutes(container, settings)` (or whichever exported + constructor this slice ends up exposing for the outer router, e.g. an + exported `NewApiRouter(container, settings)`) with a + `config.Settings{CorsAllowedOrigins: ...}` value. Do not hand-roll a + second `cors.Handler` call in the test; call the same construction path + `cmd/server/main.go` calls. +10. For the allowed-origin case, build the router with + `CorsAllowedOrigins: []string{"https://cms.example.com"}`, send + `GET /healthz` with header `Origin: https://cms.example.com`, and assert + the response header `Access-Control-Allow-Origin` equals + `"https://cms.example.com"`. +11. For the disallowed-origin case, same router, send the request with + `Origin: https://attacker.example.com`, and assert the response has no + `Access-Control-Allow-Origin` header (`rec.Result().Header.Get(...)` + returns `""`). +12. For the empty-list case, build the router with `CorsAllowedOrigins: nil` + (or `[]string{}`), send a request to `GET /healthz` with any `Origin` + header, and assert the header is again absent. + +## Upload directory +13. Reuse `openInMemoryDb(t)` and `models.Image{}` migration as + `test/image_extension_mapping_test.go` does, but construct the + repository as `persistence.NewImageRepository(db, uploadDir)` where + `uploadDir := t.TempDir()` — no `t.Setenv("UPLOAD_DIR", ...)` at all. + Additionally call `t.Setenv("UPLOAD_DIR", t.TempDir())` with a + *different* temp directory right before `Create`, to prove it has no + effect. +14. Call `repo.Create(imageDomain.ImageInput{...})` with a PNG payload. + Assert no error; assert the returned `Path` starts with + `/static/uploadedImages/`; assert, via `os.Stat`, that the file exists + under the constructor's `uploadDir` and does **not** exist under the + `t.Setenv` directory from step 13. +15. Update `test/image_extension_mapping_test.go` in the same slice to pass + `t.TempDir()` straight into `persistence.NewImageRepository(db, + uploadDir)` instead of `t.Setenv("UPLOAD_DIR", ...)`, since the + constructor signature changes. + +## Mailer +16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email: + "notify@example.com", Host: "smtp.example.com", Port: 587, Password: + "unused-in-test"})` with no `SMTP_*` environment variable set (do not + call `t.Setenv` for any `SMTP_*` name in this test). Assert construction + does not panic and does not error — this is the only automated + assertion available, since exercising `Send` requires a real SMTP dialog + that this suite deliberately never performs (matches + `test/mail_test.go`, which already tests `SendMailUseCase` against + `mockMailRepo`, not the real `MailRepository`). + +## Manual verification (not exercised by the Go test) +- Real SMTP send: run `task dev` (or a built binary) with a real `.env` + populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a + flow that calls `SendMailUseCase.SendMail`, and confirm the message + actually leaves via the injected host/port/credentials, and that changing + those env vars requires a process restart to take effect (there is no + live-reload). +- Dev-only request logging: run the binary with `ENVIRONMENT=development` + vs `ENVIRONMENT=production` and confirm `httplog` output is present only + in the development case; this is not response-observable so it isn't + asserted by `httptest`. +- Confirm `api/dependecyInjection.go`'s `getDb` and `getUseCases` consume + `DB_FILE` and `JWT_SECRET` from the same `config.Settings` value built in + `cmd/server/main.go`, rather than calling `os.Getenv` themselves — this + stays inside the composition root per CLAUDE.md and is not a behaviour + change, so it has no dedicated scenario, but review the diff to confirm + no stray `os.Getenv` remains there either. +- SPA-visible behaviour: none: this slice has no SPA-facing change. diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go new file mode 100644 index 0000000..124e9e2 --- /dev/null +++ b/internal/infrastructure/config/settings.go @@ -0,0 +1,59 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +type SmtpSettings struct { + Email string + Password string + Host string + Port int +} + +type Settings struct { + Environment string + CorsAllowedOrigins []string + PublicURL string + UploadDir string + JWTSecret string + DBFile string + Port string + Smtp SmtpSettings +} + +func (s Settings) IsProduction() bool { + return s.Environment == "production" +} + +func NewSettingsFromEnv() (Settings, error) { + smtpPort, err := strconv.Atoi(os.Getenv("SMTP_PORT")) + if err != nil { + return Settings{}, err + } + + return Settings{ + Environment: os.Getenv("ENVIRONMENT"), + CorsAllowedOrigins: parseCorsAllowedOrigins(os.Getenv("CORS_ALLOWED_ORIGINS")), + PublicURL: os.Getenv("PUBLIC_URL"), + UploadDir: os.Getenv("UPLOAD_DIR"), + JWTSecret: os.Getenv("JWT_SECRET"), + DBFile: os.Getenv("DB_FILE"), + Port: os.Getenv("PORT"), + Smtp: SmtpSettings{ + Email: os.Getenv("SMTP_EMAIL"), + Password: os.Getenv("SMTP_PASSWORD"), + Host: os.Getenv("SMTP_HOST"), + Port: smtpPort, + }, + }, nil +} + +func parseCorsAllowedOrigins(raw string) []string { + if raw == "" { + return nil + } + return strings.Split(raw, ";") +} diff --git a/internal/infrastructure/mailer/mailRepository.go b/internal/infrastructure/mailer/mailRepository.go index d5b29e2..6538ee4 100644 --- a/internal/infrastructure/mailer/mailRepository.go +++ b/internal/infrastructure/mailer/mailRepository.go @@ -2,30 +2,31 @@ package mailer import ( domain "RenewCMS/internal/domain/mail" + "RenewCMS/internal/infrastructure/config" "bytes" "embed" "html/template" "log" - "os" - "strconv" "gopkg.in/gomail.v2" ) -type MailRepository struct{} +type MailRepository struct { + smtp config.SmtpSettings +} //go:embed templates/* var mailTemplateFiles embed.FS -func NewMailRepository() *MailRepository { - return &MailRepository{} +func NewMailRepository(smtp config.SmtpSettings) *MailRepository { + return &MailRepository{smtp: smtp} } func (m MailRepository) Send(receiverAddress string, templateName string, data any) error { - from := os.Getenv("SMTP_EMAIL") - password := os.Getenv("SMTP_PASSWORD") - smtpHost := os.Getenv("SMTP_HOST") - smtpPort, _ := strconv.Atoi(os.Getenv("SMTP_PORT")) + from := m.smtp.Email + password := m.smtp.Password + smtpHost := m.smtp.Host + smtpPort := m.smtp.Port d := gomail.NewDialer(smtpHost, smtpPort, from, password) diff --git a/internal/infrastructure/persistence/repositories/imageRepository.go b/internal/infrastructure/persistence/repositories/imageRepository.go index d5329d9..d6bddc7 100644 --- a/internal/infrastructure/persistence/repositories/imageRepository.go +++ b/internal/infrastructure/persistence/repositories/imageRepository.go @@ -13,11 +13,12 @@ import ( ) type ImageRepository struct { - db *gorm.DB + db *gorm.DB + uploadDir string } -func NewImageRepository(db *gorm.DB) *ImageRepository { - return &ImageRepository{db} +func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository { + return &ImageRepository{db, uploadDir} } var contentTypeExtensions = map[string]string{ @@ -28,7 +29,7 @@ var contentTypeExtensions = map[string]string{ } func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) { - uploadDir := os.Getenv("UPLOAD_DIR") + uploadDir := i.uploadDir fileBytes, err := io.ReadAll(input.Content) if err != nil { @@ -57,7 +58,7 @@ func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) { } func (i ImageRepository) Delete(id uint32) error { - uploadDir := os.Getenv("UPLOAD_DIR") + uploadDir := i.uploadDir var entityImage entity.Image err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error if err != nil { diff --git a/test/hasher_test.go b/test/hasher_test.go index 71b63bd..7eaa0b2 100644 --- a/test/hasher_test.go +++ b/test/hasher_test.go @@ -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() diff --git a/test/image_extension_mapping_test.go b/test/image_extension_mapping_test.go index 19e8e3c..b6c8d74 100644 --- a/test/image_extension_mapping_test.go +++ b/test/image_extension_mapping_test.go @@ -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"} diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 83059e9..f765f14 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -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() diff --git a/test/runtime_settings_from_env_test.go b/test/runtime_settings_from_env_test.go new file mode 100644 index 0000000..b0ef581 --- /dev/null +++ b/test/runtime_settings_from_env_test.go @@ -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\"") + } +} diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go new file mode 100644 index 0000000..7eb2071 --- /dev/null +++ b/test/runtime_settings_injection_http_test.go @@ -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") + } +} diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 3cddd36..c275a0f 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -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)