Refac/domain driven #1
No files matched your search
+11
-11
@@ -5,6 +5,7 @@ import (
|
|||||||
domainImage "RenewCMS/internal/domain/image"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
domainMail "RenewCMS/internal/domain/mail"
|
domainMail "RenewCMS/internal/domain/mail"
|
||||||
domainUser "RenewCMS/internal/domain/user"
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"RenewCMS/internal/infrastructure/hashing"
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
"RenewCMS/internal/infrastructure/mailer"
|
"RenewCMS/internal/infrastructure/mailer"
|
||||||
"RenewCMS/internal/infrastructure/persistence/models"
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
@@ -43,8 +44,7 @@ type Repositories struct {
|
|||||||
MailRepo domainMail.Repository
|
MailRepo domainMail.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDb() *gorm.DB {
|
func getDb(dbName string) *gorm.DB {
|
||||||
dbName := os.Getenv("DB_FILE")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
||||||
panic("Unable to create necessary subdirectories: " + err.Error())
|
panic("Unable to create necessary subdirectories: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -60,20 +60,20 @@ func getDb() *gorm.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRepositories(db *gorm.DB) *Repositories {
|
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||||
return &Repositories{
|
return &Repositories{
|
||||||
ArticleRepo: persistence.NewArticleRepository(db),
|
ArticleRepo: persistence.NewArticleRepository(db),
|
||||||
UserRepo: persistence.NewUserRepository(db),
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
ImageRepo: persistence.NewImageRepository(db),
|
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||||
MailRepo: mailer.NewMailRepository(),
|
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUseCases(repos *Repositories) *UseCases {
|
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
|
||||||
return &UseCases{
|
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),
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
|
||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
||||||
@@ -91,10 +91,10 @@ func getUseCases(repos *Repositories) *UseCases {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitContainer() (*UseCases, func() error) {
|
func InitContainer(settings config.Settings) (*UseCases, func() error) {
|
||||||
db := getDb()
|
db := getDb(settings.DBFile)
|
||||||
repos := getRepositories(db)
|
repos := getRepositories(db, settings)
|
||||||
return getUseCases(repos), closeDb(db)
|
return getUseCases(repos, settings), closeDb(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeDb(db *gorm.DB) func() error {
|
func closeDb(db *gorm.DB) func() error {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
@@ -18,6 +17,7 @@ type Handler struct {
|
|||||||
ListUseCase *useCases.ListUsersUseCase
|
ListUseCase *useCases.ListUsersUseCase
|
||||||
CreateUseCase *useCases.CreateUserUseCase
|
CreateUseCase *useCases.CreateUserUseCase
|
||||||
AuthenticateUseCase *useCases.AuthenticateUserUseCase
|
AuthenticateUseCase *useCases.AuthenticateUserUseCase
|
||||||
|
IsProduction bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegisterCredentials struct {
|
type RegisterCredentials struct {
|
||||||
@@ -31,29 +31,26 @@ type LoginCredentials struct {
|
|||||||
Password string `json:"password" validate:"required,min=8"`
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldCookieBeSecure() bool {
|
func (h *Handler) removeJwtCookie(w http.ResponseWriter) {
|
||||||
return os.Getenv("ENVIRONMENT") == "production"
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeJwtCookie(w http.ResponseWriter) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "jwt",
|
Name: "jwt",
|
||||||
Value: "",
|
Value: "",
|
||||||
Expires: time.Now(),
|
Expires: time.Now(),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
Secure: shouldCookieBeSecure(),
|
Secure: h.IsProduction,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: "/",
|
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{
|
return &Handler{
|
||||||
Token: token,
|
Token: token,
|
||||||
GetUseCase: get,
|
GetUseCase: get,
|
||||||
ListUseCase: list,
|
ListUseCase: list,
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
AuthenticateUseCase: authenticate,
|
AuthenticateUseCase: authenticate,
|
||||||
|
IsProduction: isProduction,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +63,7 @@ func (h *Handler) SetJwtCookie(w *http.ResponseWriter, userId uint32) error {
|
|||||||
Name: "jwt",
|
Name: "jwt",
|
||||||
Value: tokenString,
|
Value: tokenString,
|
||||||
Expires: time.Now().Add(24 * time.Hour),
|
Expires: time.Now().Add(24 * time.Hour),
|
||||||
Secure: shouldCookieBeSecure(),
|
Secure: h.IsProduction,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
@@ -176,7 +173,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Logout(w http.ResponseWriter, _ *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"})
|
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
||||||
_, _ = w.Write(message)
|
_, _ = w.Write(message)
|
||||||
}
|
}
|
||||||
+17
-19
@@ -6,10 +6,9 @@ import (
|
|||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
"RenewCMS/api/handlers/image"
|
"RenewCMS/api/handlers/image"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/MadAppGang/httplog"
|
"github.com/MadAppGang/httplog"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -18,8 +17,8 @@ import (
|
|||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func enableLoggingIfDev(r *chi.Mux, routerName string) {
|
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
|
||||||
if os.Getenv("ENVIRONMENT") == "development" {
|
if environment == "development" {
|
||||||
r.Use(httplog.LoggerWithName(routerName))
|
r.Use(httplog.LoggerWithName(routerName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,10 +50,10 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthRouter(container *UseCases) http.Handler {
|
func NewAuthRouter(container *UseCases, isProduction bool) http.Handler {
|
||||||
r := chi.NewRouter()
|
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("/register", authHandler.Register)
|
||||||
r.Post("/login", authHandler.Login)
|
r.Post("/login", authHandler.Login)
|
||||||
@@ -62,10 +61,10 @@ func NewAuthRouter(container *UseCases) http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitBackendRoutes(container *UseCases) *chi.Mux {
|
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
enableLoggingIfDev(r, "backend")
|
enableLoggingIfDev(r, "backend", settings.Environment)
|
||||||
|
|
||||||
r.Use(chimiddleware.GetHead)
|
r.Use(chimiddleware.GetHead)
|
||||||
r.Use(middleware.JsonContentTypeMiddleware)
|
r.Use(middleware.JsonContentTypeMiddleware)
|
||||||
@@ -74,15 +73,15 @@ func InitBackendRoutes(container *UseCases) *chi.Mux {
|
|||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
r.Mount("/article", NewArticleRouter(container))
|
r.Mount("/article", NewArticleRouter(container))
|
||||||
})
|
})
|
||||||
r.Mount("/auth", NewAuthRouter(container))
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction()))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitFrontendRoutes() *chi.Mux {
|
func InitFrontendRoutes(environment string) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
enableLoggingIfDev(r, "frontend")
|
enableLoggingIfDev(r, "frontend", environment)
|
||||||
|
|
||||||
r.Mount("/", frontend.NewFrontendRouter())
|
r.Mount("/", frontend.NewFrontendRouter())
|
||||||
|
|
||||||
@@ -94,13 +93,12 @@ func GetHealth(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableCorsIfConfigured(r *chi.Mux) {
|
func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
||||||
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
if len(origins) == 0 {
|
||||||
if origins == "" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: strings.Split(origins, ";"),
|
AllowedOrigins: origins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
@@ -109,12 +107,12 @@ func enableCorsIfConfigured(r *chi.Mux) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitRoutes(container *UseCases) *chi.Mux {
|
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
backend := InitBackendRoutes(container)
|
backend := InitBackendRoutes(container, settings)
|
||||||
frontend := InitFrontendRoutes()
|
frontend := InitFrontendRoutes(settings.Environment)
|
||||||
|
|
||||||
apiRouter := chi.NewRouter()
|
apiRouter := chi.NewRouter()
|
||||||
enableCorsIfConfigured(apiRouter)
|
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
|
||||||
apiRouter.Use(chimiddleware.GetHead)
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
apiRouter.Get("/healthz", GetHealth)
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
apiRouter.Mount("/v1", backend)
|
apiRouter.Mount("/v1", backend)
|
||||||
|
|||||||
+15
-9
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"RenewCMS/api"
|
"RenewCMS/api"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -53,12 +54,17 @@ func main() {
|
|||||||
func run() error {
|
func run() error {
|
||||||
initEnvVariables()
|
initEnvVariables()
|
||||||
|
|
||||||
container, closeDb := api.InitContainer()
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
container, closeDb := api.InitContainer(settings)
|
||||||
defer closeDatabase(closeDb)
|
defer closeDatabase(closeDb)
|
||||||
validator.InitValidator()
|
validator.InitValidator()
|
||||||
|
|
||||||
server := newServer(api.InitRoutes(container))
|
server := newServer(api.InitRoutes(container, settings), settings)
|
||||||
return serveUntilStopped(server)
|
return serveUntilStopped(server, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initEnvVariables() {
|
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{
|
return &http.Server{
|
||||||
Addr: ":" + os.Getenv("PORT"),
|
Addr: ":" + settings.Port,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
ReadTimeout: readTimeout,
|
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)
|
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
serverErr := make(chan error, 1)
|
serverErr := make(chan error, 1)
|
||||||
go listen(server, serverErr)
|
go listen(server, serverErr, settings)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serverErr:
|
case err := <-serverErr:
|
||||||
@@ -124,8 +130,8 @@ func serveUntilStopped(server *http.Server) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listen(server *http.Server, serverErr chan<- error) {
|
func listen(server *http.Server, serverErr chan<- error, settings config.Settings) {
|
||||||
log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, os.Getenv("ENVIRONMENT"), server.Addr, os.Getenv("PUBLIC_URL"))
|
log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, settings.Environment, server.Addr, settings.PublicURL)
|
||||||
err := server.ListenAndServe()
|
err := server.ListenAndServe()
|
||||||
if errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
err = nil
|
err = nil
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 (
|
import (
|
||||||
domain "RenewCMS/internal/domain/mail"
|
domain "RenewCMS/internal/domain/mail"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"gopkg.in/gomail.v2"
|
"gopkg.in/gomail.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MailRepository struct{}
|
type MailRepository struct {
|
||||||
|
smtp config.SmtpSettings
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed templates/*
|
//go:embed templates/*
|
||||||
var mailTemplateFiles embed.FS
|
var mailTemplateFiles embed.FS
|
||||||
|
|
||||||
func NewMailRepository() *MailRepository {
|
func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
|
||||||
return &MailRepository{}
|
return &MailRepository{smtp: smtp}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
||||||
from := os.Getenv("SMTP_EMAIL")
|
from := m.smtp.Email
|
||||||
password := os.Getenv("SMTP_PASSWORD")
|
password := m.smtp.Password
|
||||||
smtpHost := os.Getenv("SMTP_HOST")
|
smtpHost := m.smtp.Host
|
||||||
smtpPort, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
|
smtpPort := m.smtp.Port
|
||||||
|
|
||||||
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ImageRepository struct {
|
type ImageRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
uploadDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImageRepository(db *gorm.DB) *ImageRepository {
|
func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository {
|
||||||
return &ImageRepository{db}
|
return &ImageRepository{db, uploadDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentTypeExtensions = map[string]string{
|
var contentTypeExtensions = map[string]string{
|
||||||
@@ -28,7 +29,7 @@ var contentTypeExtensions = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
|
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
|
||||||
uploadDir := os.Getenv("UPLOAD_DIR")
|
uploadDir := i.uploadDir
|
||||||
|
|
||||||
fileBytes, err := io.ReadAll(input.Content)
|
fileBytes, err := io.ReadAll(input.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,7 +58,7 @@ func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i ImageRepository) Delete(id uint32) error {
|
func (i ImageRepository) Delete(id uint32) error {
|
||||||
uploadDir := os.Getenv("UPLOAD_DIR")
|
uploadDir := i.uploadDir
|
||||||
var entityImage entity.Image
|
var entityImage entity.Image
|
||||||
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
|
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
|||||||
useCases.NewListUsersUseCase(userRepo),
|
useCases.NewListUsersUseCase(userRepo),
|
||||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -237,6 +238,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
|||||||
useCases.NewListUsersUseCase(userRepo),
|
useCases.NewListUsersUseCase(userRepo),
|
||||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
|
|||||||
if err := db.AutoMigrate(&models.Image{}); err != nil {
|
if err := db.AutoMigrate(&models.Image{}); err != nil {
|
||||||
t.Fatalf("failed to migrate: %v", err)
|
t.Fatalf("failed to migrate: %v", err)
|
||||||
}
|
}
|
||||||
repo := persistence.NewImageRepository(db)
|
repo := persistence.NewImageRepository(db, t.TempDir())
|
||||||
t.Setenv("UPLOAD_DIR", t.TempDir())
|
|
||||||
|
|
||||||
allowedContentTypes := imageDomain.AllowedContentTypes()
|
allowedContentTypes := imageDomain.AllowedContentTypes()
|
||||||
expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"}
|
expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
|||||||
useCases.NewListUsersUseCase(userRepo),
|
useCases.NewListUsersUseCase(userRepo),
|
||||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setBaseSettingsEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("ENVIRONMENT", "production")
|
||||||
|
t.Setenv("PUBLIC_URL", "https://cms.example.com")
|
||||||
|
t.Setenv("UPLOAD_DIR", "./data/uploads")
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
t.Setenv("DB_FILE", "./data/renewcms.db")
|
||||||
|
t.Setenv("PORT", "8080")
|
||||||
|
t.Setenv("SMTP_EMAIL", "notify@example.com")
|
||||||
|
t.Setenv("SMTP_PASSWORD", "hunter2")
|
||||||
|
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
t.Setenv("SMTP_PORT", "465")
|
||||||
|
t.Setenv("CORS_ALLOWED_ORIGINS", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
|
||||||
|
setBaseSettingsEnv(t)
|
||||||
|
t.Setenv("CORS_ALLOWED_ORIGINS", "https://cms.example.com;https://admin.example.com")
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.Environment != "production" {
|
||||||
|
t.Errorf("expected Environment %q, got %q", "production", settings.Environment)
|
||||||
|
}
|
||||||
|
if settings.PublicURL != "https://cms.example.com" {
|
||||||
|
t.Errorf("expected PublicURL %q, got %q", "https://cms.example.com", settings.PublicURL)
|
||||||
|
}
|
||||||
|
if settings.UploadDir != "./data/uploads" {
|
||||||
|
t.Errorf("expected UploadDir %q, got %q", "./data/uploads", settings.UploadDir)
|
||||||
|
}
|
||||||
|
if settings.JWTSecret != "test-secret" {
|
||||||
|
t.Errorf("expected JWTSecret %q, got %q", "test-secret", settings.JWTSecret)
|
||||||
|
}
|
||||||
|
if settings.DBFile != "./data/renewcms.db" {
|
||||||
|
t.Errorf("expected DBFile %q, got %q", "./data/renewcms.db", settings.DBFile)
|
||||||
|
}
|
||||||
|
if settings.Port != "8080" {
|
||||||
|
t.Errorf("expected Port %q, got %q", "8080", settings.Port)
|
||||||
|
}
|
||||||
|
if settings.Smtp.Email != "notify@example.com" {
|
||||||
|
t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email)
|
||||||
|
}
|
||||||
|
if settings.Smtp.Password != "hunter2" {
|
||||||
|
t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password)
|
||||||
|
}
|
||||||
|
if settings.Smtp.Host != "smtp.example.com" {
|
||||||
|
t.Errorf("expected Smtp.Host %q, got %q", "smtp.example.com", settings.Smtp.Host)
|
||||||
|
}
|
||||||
|
if settings.Smtp.Port != 465 {
|
||||||
|
t.Errorf("expected Smtp.Port %d, got %d", 465, settings.Smtp.Port)
|
||||||
|
}
|
||||||
|
expectedOrigins := []string{"https://cms.example.com", "https://admin.example.com"}
|
||||||
|
if len(settings.CorsAllowedOrigins) != len(expectedOrigins) {
|
||||||
|
t.Fatalf("expected %d origins, got %v", len(expectedOrigins), settings.CorsAllowedOrigins)
|
||||||
|
}
|
||||||
|
for i, origin := range expectedOrigins {
|
||||||
|
if settings.CorsAllowedOrigins[i] != origin {
|
||||||
|
t.Errorf("expected origin %d to be %q, got %q", i, origin, settings.CorsAllowedOrigins[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSettingsFromEnvEmptyCorsOriginsIsNil(t *testing.T) {
|
||||||
|
setBaseSettingsEnv(t)
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if settings.CorsAllowedOrigins != nil {
|
||||||
|
t.Errorf("expected nil CorsAllowedOrigins, got %v", settings.CorsAllowedOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSettingsFromEnvSingleCorsOrigin(t *testing.T) {
|
||||||
|
setBaseSettingsEnv(t)
|
||||||
|
t.Setenv("CORS_ALLOWED_ORIGINS", "https://cms.example.com")
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(settings.CorsAllowedOrigins) != 1 || settings.CorsAllowedOrigins[0] != "https://cms.example.com" {
|
||||||
|
t.Errorf("expected a single origin %q, got %v", "https://cms.example.com", settings.CorsAllowedOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSettingsFromEnvInvalidSmtpPortReturnsError(t *testing.T) {
|
||||||
|
setBaseSettingsEnv(t)
|
||||||
|
t.Setenv("SMTP_PORT", "not-a-number")
|
||||||
|
|
||||||
|
_, err := config.NewSettingsFromEnv()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a non-numeric SMTP_PORT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) {
|
||||||
|
settings := config.Settings{Environment: "development"}
|
||||||
|
if settings.IsProduction() {
|
||||||
|
t.Error("expected IsProduction to be false for environment \"development\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api"
|
||||||
|
"RenewCMS/api/handlers/auth"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
|
"RenewCMS/internal/infrastructure/mailer"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
imageDomain "RenewCMS/internal/domain/image"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) {
|
||||||
|
t.Helper()
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
hashed, err := hasher.Hash("hunter2example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: hashed, IsVerified: true}
|
||||||
|
|
||||||
|
authHandler := auth.NewHandler(
|
||||||
|
token,
|
||||||
|
useCases.NewGetUserUseCase(userRepo),
|
||||||
|
useCases.NewListUsersUseCase(userRepo),
|
||||||
|
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||||
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||||
|
isProduction,
|
||||||
|
)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
return r, userRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func performLoginRequest(t *testing.T, router *chi.Mux) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]string{"username": "alice", "password": "hunter2example"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal body: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureAttributeFromResponse(t *testing.T, rec *httptest.ResponseRecorder) bool {
|
||||||
|
t.Helper()
|
||||||
|
values := rec.Result().Header.Values("Set-Cookie")
|
||||||
|
for _, value := range values {
|
||||||
|
cookie, err := http.ParseSetCookie(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse Set-Cookie header %q: %v", value, err)
|
||||||
|
}
|
||||||
|
if cookie.Name == "jwt" {
|
||||||
|
return cookie.Secure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("expected a jwt Set-Cookie header, got %v", values)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeSettingsInjectionSecureCookie(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
|
||||||
|
outline := []struct {
|
||||||
|
injectedEnvironment string
|
||||||
|
processEnvironment string
|
||||||
|
secureExpected bool
|
||||||
|
}{
|
||||||
|
{"production", "production", true},
|
||||||
|
{"production", "development", true},
|
||||||
|
{"development", "production", false},
|
||||||
|
{"development", "development", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range outline {
|
||||||
|
t.Run("Secure follows the injected environment "+tc.injectedEnvironment+"/"+tc.processEnvironment, func(t *testing.T) {
|
||||||
|
isProduction := tc.injectedEnvironment == "production"
|
||||||
|
router, _ := newSecureCookieAuthRouter(t, isProduction)
|
||||||
|
t.Setenv("ENVIRONMENT", tc.processEnvironment)
|
||||||
|
|
||||||
|
rec := performLoginRequest(t, router)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := secureAttributeFromResponse(t, rec); got != tc.secureExpected {
|
||||||
|
t.Errorf("expected Secure=%v, got %v", tc.secureExpected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Changing the process environment after construction does not change already-built behaviour", func(t *testing.T) {
|
||||||
|
router, _ := newSecureCookieAuthRouter(t, true)
|
||||||
|
t.Setenv("ENVIRONMENT", "development")
|
||||||
|
|
||||||
|
rec := performLoginRequest(t, router)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := secureAttributeFromResponse(t, rec); !got {
|
||||||
|
t.Errorf("expected Secure=true, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||||
|
t.Helper()
|
||||||
|
articleRepo := newMockArticleRepo()
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
imageRepo := newMockImageRepo()
|
||||||
|
mailRepo := newMockMailRepo()
|
||||||
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
|
||||||
|
return &api.UseCases{
|
||||||
|
Token: token,
|
||||||
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
|
||||||
|
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
|
||||||
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
|
||||||
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
|
||||||
|
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||||
|
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||||
|
UpdateUserUseCase: useCases.NewUpdateUserUseCase(userRepo, hasher),
|
||||||
|
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||||
|
DeleteUserUseCase: useCases.NewDeleteUserUseCase(userRepo),
|
||||||
|
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
|
||||||
|
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
|
||||||
|
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
|
||||||
|
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeSettingsInjectionCors(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
container := newRuntimeSettingsContainer(t)
|
||||||
|
|
||||||
|
t.Run("CORS allows a request from an injected allowed origin", func(t *testing.T) {
|
||||||
|
settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}}
|
||||||
|
router := api.InitRoutes(container, settings)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
req.Header.Set("Origin", "https://cms.example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "https://cms.example.com" {
|
||||||
|
t.Errorf("expected Access-Control-Allow-Origin %q, got %q", "https://cms.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CORS does not allow a request from an origin outside the injected list", func(t *testing.T) {
|
||||||
|
settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}}
|
||||||
|
router := api.InitRoutes(container, settings)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
req.Header.Set("Origin", "https://attacker.example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An empty injected origins list disables CORS entirely", func(t *testing.T) {
|
||||||
|
settings := config.Settings{CorsAllowedOrigins: nil}
|
||||||
|
router := api.InitRoutes(container, settings)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
req.Header.Set("Origin", "https://cms.example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
|
||||||
|
db := openInMemoryDb(t)
|
||||||
|
if err := db.AutoMigrate(&models.Image{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate: %v", err)
|
||||||
|
}
|
||||||
|
injectedUploadDir := t.TempDir()
|
||||||
|
repo := persistence.NewImageRepository(db, injectedUploadDir)
|
||||||
|
|
||||||
|
processUploadDir := t.TempDir()
|
||||||
|
t.Setenv("UPLOAD_DIR", processUploadDir)
|
||||||
|
|
||||||
|
result, err := repo.Create(imageDomain.ImageInput{
|
||||||
|
Content: bytes.NewReader([]byte("fake-image-data")),
|
||||||
|
Filename: "upload.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" {
|
||||||
|
t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := filepath.Base(result.Path)
|
||||||
|
if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil {
|
||||||
|
t.Errorf("expected the file to exist under the injected upload dir: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected the file to not exist under the process UPLOAD_DIR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeSettingsInjectionMailer(t *testing.T) {
|
||||||
|
smtp := config.SmtpSettings{
|
||||||
|
Email: "notify@example.com",
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
Password: "unused-in-test",
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := mailer.NewMailRepository(smtp)
|
||||||
|
if repo == nil {
|
||||||
|
t.Fatalf("expected a non-nil MailRepository")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing
|
|||||||
if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil {
|
if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil {
|
||||||
t.Fatalf("failed to seed image: %v", err)
|
t.Fatalf("failed to seed image: %v", err)
|
||||||
}
|
}
|
||||||
repo := persistence.NewImageRepository(db)
|
repo := persistence.NewImageRepository(db, t.TempDir())
|
||||||
|
|
||||||
err := repo.Delete(9)
|
err := repo.Delete(9)
|
||||||
assertWriteResultIsImageNotFound(t, err)
|
assertWriteResultIsImageNotFound(t, err)
|
||||||
|
|||||||
Reference in new issue
Block a user