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

+11 -11
View File
@@ -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 {
+7 -10
View File
@@ -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)
}
+17 -19
View File
@@ -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)
+15 -9
View File
@@ -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
+120
View File
@@ -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 "<injected environment>"
And the auth handler is built from that settings value
And the process environment variable ENVIRONMENT is set to "<process environment>"
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 <secure expected>
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
+113
View File
@@ -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, <injected production flag>)` — 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.
@@ -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, ";")
}
@@ -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)
@@ -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 {
+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)