Files
RenewCMS/test/runtime_settings_injection_http_test.go
T

247 lines
8.4 KiB
Go

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")
}
}