refactor: consolidate lang into constants, rename services to email

- Merge lang package into constants (add IsValidLang, ValidateLang, AllLangs)
- Rename internal/services to internal/email for consistency with pdf package
- Rename types to avoid redundancy: EmailService→Service, EmailConfig→Config
- Update all imports and references across codebase
- Delete internal/lang directory (functions moved to constants)
This commit is contained in:
juanatsap
2025-12-06 17:05:17 +00:00
parent 30ed21ff7a
commit c89b67a06d
28 changed files with 241 additions and 290 deletions
+3 -3
View File
@@ -56,12 +56,12 @@ func Load() *Config {
WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15), WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15),
}, },
Template: TemplateConfig{ Template: TemplateConfig{
Dir: getEnv("TEMPLATE_DIR", "templates"), Dir: getEnv("TEMPLATE_DIR", c.DirTemplates),
PartialsDir: getEnv("PARTIALS_DIR", "templates/partials"), PartialsDir: getEnv("PARTIALS_DIR", c.DirPartials),
HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()), HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()),
}, },
Data: DataConfig{ Data: DataConfig{
Dir: getEnv("DATA_DIR", "data"), Dir: getEnv("DATA_DIR", c.DirData),
}, },
Email: EmailConfig{ Email: EmailConfig{
SMTPHost: getEnv("SMTP_HOST", "smtp.gmail.com"), SMTPHost: getEnv("SMTP_HOST", "smtp.gmail.com"),
+66 -1
View File
@@ -1,7 +1,10 @@
// Package constants provides global constants used across the application. // Package constants provides global constants used across the application.
package constants package constants
import "time" import (
"fmt"
"time"
)
// ============================================================================== // ==============================================================================
// HTTP CONTENT TYPES // HTTP CONTENT TYPES
@@ -80,6 +83,25 @@ var SupportedLanguages = map[string]bool{
LangSpanish: true, LangSpanish: true,
} }
// AllLangs returns all supported language codes
func AllLangs() []string {
return []string{LangEnglish, LangSpanish}
}
// IsValidLang checks if a language code is supported
func IsValidLang(lang string) bool {
return SupportedLanguages[lang]
}
// ValidateLang returns an error if the language code is unsupported.
// It provides helpful error messages showing all supported languages.
func ValidateLang(lang string) error {
if !IsValidLang(lang) {
return fmt.Errorf("unsupported language: %s (supported: %v)", lang, AllLangs())
}
return nil
}
// ============================================================================== // ==============================================================================
// CV PREFERENCES // CV PREFERENCES
// ============================================================================== // ==============================================================================
@@ -122,6 +144,20 @@ const (
const ( const (
TimeoutPDFGeneration = 30 * time.Second TimeoutPDFGeneration = 30 * time.Second
TimeoutHTTPRequest = 10 * time.Second TimeoutHTTPRequest = 10 * time.Second
TimeoutIdleConnection = 120 * time.Second
TimeoutGracefulShutdown = 30 * time.Second
FormMinSubmitTime = 2 * time.Second // Min time form must be displayed (bot protection)
)
// ==============================================================================
// DIRECTORIES
// ==============================================================================
const (
DirData = "data"
DirTemplates = "templates"
DirPartials = "templates/partials"
DirStatic = "static"
) )
// ============================================================================== // ==============================================================================
@@ -133,6 +169,27 @@ const (
A4HeightInches = 11.69 A4HeightInches = 11.69
) )
// ==============================================================================
// CSRF PROTECTION
// ==============================================================================
const (
CSRFTokenLength = 32
CSRFTokenTTL = 24 * time.Hour
CSRFCookieName = "csrf_token"
CSRFFormField = "csrf_token"
CSRFCleanupPeriod = 1 * time.Hour
)
// ==============================================================================
// CLEANUP INTERVALS
// ==============================================================================
const (
RateLimitCleanupPeriod = 10 * time.Minute // For contact rate limiter
RateLimitGeneralCleanupPeriod = 1 * time.Minute // For general rate limiter
)
// ============================================================================== // ==============================================================================
// SECURITY // SECURITY
// ============================================================================== // ==============================================================================
@@ -168,6 +225,7 @@ const (
HeaderRetryAfter = "Retry-After" HeaderRetryAfter = "Retry-After"
HeaderXForwardedFor = "X-Forwarded-For" HeaderXForwardedFor = "X-Forwarded-For"
HeaderXRealIP = "X-Real-IP" HeaderXRealIP = "X-Real-IP"
HeaderXCSRFToken = "X-CSRF-Token"
) )
// ============================================================================== // ==============================================================================
@@ -179,6 +237,13 @@ const (
HeaderAccept = "Accept" HeaderAccept = "Accept"
HeaderOrigin = "Origin" HeaderOrigin = "Origin"
HeaderReferer = "Referer" HeaderReferer = "Referer"
HeaderXRequestedWith = "X-Requested-With"
HeaderXBrowserReq = "X-Browser-Request"
)
// Header values
const (
HeaderValueXMLHTTPRequest = "XMLHttpRequest"
) )
// ============================================================================== // ==============================================================================
@@ -1,4 +1,4 @@
package services package email
import ( import (
"bytes" "bytes"
@@ -13,8 +13,8 @@ import (
"time" "time"
) )
// EmailConfig holds SMTP configuration // Config holds SMTP configuration
type EmailConfig struct { type Config struct {
SMTPHost string SMTPHost string
SMTPPort string SMTPPort string
SMTPUser string SMTPUser string
@@ -23,14 +23,14 @@ type EmailConfig struct {
ToEmail string ToEmail string
} }
// EmailService handles email sending operations // Service handles email sending operations
type EmailService struct { type Service struct {
config *EmailConfig config *Config
} }
// NewEmailService creates a new email service // NewService creates a new email service
func NewEmailService(config *EmailConfig) *EmailService { func NewService(config *Config) *Service {
return &EmailService{ return &Service{
config: config, config: config,
} }
} }
@@ -105,7 +105,7 @@ func containsNewlines(s string) bool {
} }
// SendContactForm sends a contact form email with HTML and plain text versions // SendContactForm sends a contact form email with HTML and plain text versions
func (e *EmailService) SendContactForm(data *ContactFormData) error { func (e *Service) SendContactForm(data *ContactFormData) error {
// Validate data // Validate data
if err := data.Validate(); err != nil { if err := data.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err) return fmt.Errorf("validation failed: %w", err)
@@ -148,7 +148,7 @@ type emailTemplateData struct {
} }
// buildEmailBody creates both HTML and plain text email bodies // buildEmailBody creates both HTML and plain text email bodies
func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) { func (e *Service) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) {
// Prepare template data with safe defaults // Prepare template data with safe defaults
tmplData := emailTemplateData{ tmplData := emailTemplateData{
Name: data.Name, Name: data.Name,
@@ -192,7 +192,7 @@ func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody
// sendMultipartEmail sends an email with both HTML and plain text parts // sendMultipartEmail sends an email with both HTML and plain text parts
func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error { func (e *Service) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error {
// Validate config // Validate config
if e.config.SMTPHost == "" || e.config.SMTPPort == "" { if e.config.SMTPHost == "" || e.config.SMTPPort == "" {
return fmt.Errorf("SMTP configuration incomplete") return fmt.Errorf("SMTP configuration incomplete")
@@ -259,7 +259,7 @@ func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo s
} }
// connectSMTP establishes an SMTP connection with TLS // connectSMTP establishes an SMTP connection with TLS
func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) { func (e *Service) connectSMTP(addr string) (*smtp.Client, error) {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
ServerName: e.config.SMTPHost, ServerName: e.config.SMTPHost,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
@@ -296,7 +296,7 @@ func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) {
} }
// formatMultipartMessage formats a multipart email with HTML and plain text // formatMultipartMessage formats a multipart email with HTML and plain text
func (e *EmailService) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string { func (e *Service) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string {
// Generate boundary for multipart // Generate boundary for multipart
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano()) boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
@@ -1,4 +1,4 @@
package services package email
// CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic // CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic
// Features: // Features:
+6 -6
View File
@@ -8,14 +8,14 @@ import (
"time" "time"
c "github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
// EmailSender is an interface for sending contact form emails // EmailSender is an interface for sending contact form emails
// This allows for easy mocking in tests // This allows for easy mocking in tests
type EmailSender interface { type EmailSender interface {
SendContactForm(data *services.ContactFormData) error SendContactForm(data *email.ContactFormData) error
} }
// ContactHandler handles contact form submissions // ContactHandler handles contact form submissions
@@ -88,7 +88,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
elapsed := time.Since(submitTime) elapsed := time.Since(submitTime)
// Reject if submitted too fast (< 2 seconds) // Reject if submitted too fast (< 2 seconds)
if elapsed < 2*time.Second { if elapsed < c.FormMinSubmitTime {
log.Printf("SECURITY: Form submitted too fast (%v) from IP %s", elapsed, getClientIP(r)) log.Printf("SECURITY: Form submitted too fast (%v) from IP %s", elapsed, getClientIP(r))
h.renderError(w, r, "Please take your time filling out the form.") h.renderError(w, r, "Please take your time filling out the form.")
return return
@@ -109,7 +109,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
} }
// Create email data // Create email data
emailData := &services.ContactFormData{ emailData := &email.ContactFormData{
Email: req.Email, Email: req.Email,
Name: req.Name, Name: req.Name,
Company: req.Company, Company: req.Company,
@@ -205,7 +205,7 @@ func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, mes
// getClientIP extracts the client IP address from the request // getClientIP extracts the client IP address from the request
func getClientIP(r *http.Request) string { func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (for proxies) // Check X-Forwarded-For header (for proxies)
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get(c.HeaderXForwardedFor)
if ip != "" { if ip != "" {
// X-Forwarded-For can contain multiple IPs, take the first one // X-Forwarded-For can contain multiple IPs, take the first one
ips := strings.Split(ip, ",") ips := strings.Split(ip, ",")
@@ -213,7 +213,7 @@ func getClientIP(r *http.Request) string {
} }
// Check X-Real-IP header // Check X-Real-IP header
ip = r.Header.Get("X-Real-IP") ip = r.Header.Get(c.HeaderXRealIP)
if ip != "" { if ip != "" {
return ip return ip
} }
+5 -6
View File
@@ -1,11 +1,10 @@
package handlers package handlers
import ( import (
"time"
"github.com/juanatsap/cv-site/internal/cache" "github.com/juanatsap/cv-site/internal/cache"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/pdf"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
@@ -19,16 +18,16 @@ import (
type CVHandler struct { type CVHandler struct {
templates *templates.Manager templates *templates.Manager
pdfGenerator *pdf.Generator pdfGenerator *pdf.Generator
emailService *services.EmailService emailService *email.Service
serverAddr string serverAddr string
dataCache *cache.DataCache dataCache *cache.DataCache
} }
// NewCVHandler creates a new CV handler // NewCVHandler creates a new CV handler
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *services.EmailService, dataCache *cache.DataCache) *CVHandler { func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler {
return &CVHandler{ return &CVHandler{
templates: tmpl, templates: tmpl,
pdfGenerator: pdf.NewGenerator(30 * time.Second), pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration),
emailService: emailService, emailService: emailService,
serverAddr: serverAddr, serverAddr: serverAddr,
dataCache: dataCache, dataCache: dataCache,
+8 -6
View File
@@ -5,6 +5,8 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
c "github.com/juanatsap/cv-site/internal/constants"
) )
// TestCmdKData tests the CmdKData handler // TestCmdKData tests the CmdKData handler
@@ -93,9 +95,9 @@ func TestCmdKData(t *testing.T) {
// If success, validate JSON response // If success, validate JSON response
if rec.Code == http.StatusOK { if rec.Code == http.StatusOK {
// Check content type // Check content type
contentType := rec.Header().Get("Content-Type") contentType := rec.Header().Get(c.HeaderContentType)
if contentType != "application/json" { if contentType != c.ContentTypeJSON {
t.Errorf("Expected Content-Type application/json, got %s", contentType) t.Errorf("Expected Content-Type %s, got %s", c.ContentTypeJSON, contentType)
} }
// Parse JSON response // Parse JSON response
@@ -192,11 +194,11 @@ func TestCmdKDataCaching(t *testing.T) {
handler.CmdKData(rec, req) handler.CmdKData(rec, req)
// Check cache header // Check cache header
cacheControl := rec.Header().Get("Cache-Control") cacheControl := rec.Header().Get(c.HeaderCacheControl)
if cacheControl == "" { if cacheControl == "" {
t.Error("Expected Cache-Control header to be set") t.Error("Expected Cache-Control header to be set")
} }
if cacheControl != "public, max-age=3600" { if cacheControl != c.CachePublic1Hour {
t.Errorf("Expected Cache-Control 'public, max-age=3600', got '%s'", cacheControl) t.Errorf("Expected Cache-Control '%s', got '%s'", c.CachePublic1Hour, cacheControl)
} }
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import (
c "github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil" "github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
) )
// ============================================================================== // ==============================================================================
@@ -80,7 +80,7 @@ func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
// Send email via EmailService // Send email via EmailService
if h.emailService != nil { if h.emailService != nil {
emailData := &services.ContactFormData{ emailData := &email.ContactFormData{
Email: formData.Email, Email: formData.Email,
Name: formData.Name, Name: formData.Name,
Company: formData.Company, Company: formData.Company,
+8 -7
View File
@@ -9,18 +9,19 @@ import (
"testing" "testing"
"time" "time"
"github.com/juanatsap/cv-site/internal/services" c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/email"
) )
// MockEmailService implements a mock email sender for testing // MockEmailService implements a mock email sender for testing
type MockEmailService struct { type MockEmailService struct {
SendCalled bool SendCalled bool
LastEmailData *services.ContactFormData LastEmailData *email.ContactFormData
ShouldFail bool ShouldFail bool
FailError error FailError error
} }
func (m *MockEmailService) SendContactForm(data *services.ContactFormData) error { func (m *MockEmailService) SendContactForm(data *email.ContactFormData) error {
m.SendCalled = true m.SendCalled = true
m.LastEmailData = data m.LastEmailData = data
if m.ShouldFail { if m.ShouldFail {
@@ -49,7 +50,7 @@ func TestHandleContact_ValidSubmission(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt)) formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode())) req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.HandleContact(w, req) handler.HandleContact(w, req)
@@ -103,7 +104,7 @@ func TestHandleContact_MissingFields(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(tt.formData.Encode())) req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(tt.formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.HandleContact(w, req) handler.HandleContact(w, req)
@@ -134,7 +135,7 @@ func TestHandleContact_HoneypotDetection(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt)) formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode())) req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.HandleContact(w, req) handler.HandleContact(w, req)
@@ -161,7 +162,7 @@ func TestHandleContact_TimingCheck(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt)) formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode())) req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.HandleContact(w, req) handler.HandleContact(w, req)
+1 -1
View File
@@ -345,7 +345,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
// This ensures graceful fallback to modular CSS if bundle not built // This ensures graceful fallback to modular CSS if bundle not built
isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction
if isProduction { if isProduction {
bundlePath := filepath.Join("static", "dist", "bundle.min.css") bundlePath := filepath.Join(c.DirStatic, "dist", "bundle.min.css")
if _, err := os.Stat(bundlePath); os.IsNotExist(err) { if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
// Bundle doesn't exist, fall back to modular CSS // Bundle doesn't exist, fall back to modular CSS
isProduction = false isProduction = false
+1 -1
View File
@@ -130,7 +130,7 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
} }
// Load and parse the plain text template with custom functions // Load and parse the plain text template with custom functions
tmplPath := filepath.Join("templates", "cv-text.txt") tmplPath := filepath.Join(c.DirTemplates, "cv-text.txt")
tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath) tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath)
if err != nil { if err != nil {
log.Printf("PlainText: Failed to load template: %v", err) log.Printf("PlainText: Failed to load template: %v", err)
+6 -12
View File
@@ -8,14 +8,10 @@ import (
) )
// TestPlainText tests the PlainText handler // TestPlainText tests the PlainText handler
// NOTE: This test requires running from project root due to template path resolution
// Run with: go test ./internal/handlers/ -run TestPlainText -v
// Or skip in CI: go test ./internal/handlers/ -run TestPlainText -short
func TestPlainText(t *testing.T) { func TestPlainText(t *testing.T) {
// Skip if running in short mode (CI) - requires project root // Change to project root for template path resolution
if testing.Short() { cleanup := chDirToProjectRoot(t)
t.Skip("Skipping PlainText test - requires running from project root") defer cleanup()
}
handler := newTestCVHandler(t, "localhost:8080", nil) handler := newTestCVHandler(t, "localhost:8080", nil)
@@ -122,12 +118,10 @@ func TestPlainText(t *testing.T) {
} }
// TestPlainTextDownloadFilename tests that download filename is correctly formatted // TestPlainTextDownloadFilename tests that download filename is correctly formatted
// NOTE: This test requires running from project root due to template path resolution
func TestPlainTextDownloadFilename(t *testing.T) { func TestPlainTextDownloadFilename(t *testing.T) {
// Skip if running in short mode (CI) - requires project root // Change to project root for template path resolution
if testing.Short() { cleanup := chDirToProjectRoot(t)
t.Skip("Skipping PlainTextDownloadFilename test - requires running from project root") defer cleanup()
}
handler := newTestCVHandler(t, "localhost:8080", nil) handler := newTestCVHandler(t, "localhost:8080", nil)
+1 -1
View File
@@ -63,7 +63,7 @@ func HandleError(w http.ResponseWriter, r *http.Request, err error) {
} }
// Determine response based on Accept header // Determine response based on Accept header
accept := r.Header.Get("Accept") accept := r.Header.Get(c.HeaderAccept)
isJSON := accept == c.ContentTypeJSON isJSON := accept == c.ContentTypeJSON
isHTMX := r.Header.Get(c.HeaderHXRequest) != "" isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
+56 -4
View File
@@ -1,14 +1,55 @@
package handlers package handlers
import ( import (
"os"
"path/filepath"
"runtime"
"testing" "testing"
"github.com/juanatsap/cv-site/internal/cache" "github.com/juanatsap/cv-site/internal/cache"
"github.com/juanatsap/cv-site/internal/config" "github.com/juanatsap/cv-site/internal/config"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
// chDirToProjectRoot changes the working directory to the project root.
// This is needed for tests that load files using paths relative to project root.
// Returns a cleanup function that restores the original directory.
func chDirToProjectRoot(t testing.TB) func() {
t.Helper()
// Get the directory of this test file
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("Failed to get current file path")
}
// Navigate from internal/handlers to project root (../../)
projectRoot := filepath.Join(filepath.Dir(filename), "..", "..")
projectRoot, err := filepath.Abs(projectRoot)
if err != nil {
t.Fatalf("Failed to get absolute path: %v", err)
}
// Save current directory
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
// Change to project root
if err := os.Chdir(projectRoot); err != nil {
t.Fatalf("Failed to change to project root: %v", err)
}
// Return cleanup function
return func() {
if err := os.Chdir(originalDir); err != nil {
t.Errorf("Failed to restore directory: %v", err)
}
}
}
// testCache is a shared cache instance for all tests // testCache is a shared cache instance for all tests
var testCache *cache.DataCache var testCache *cache.DataCache
@@ -28,12 +69,23 @@ func getTestCache(t testing.TB) *cache.DataCache {
} }
// newTestCVHandler creates a CVHandler for testing with all required dependencies // newTestCVHandler creates a CVHandler for testing with all required dependencies
func newTestCVHandler(t testing.TB, serverAddr string, emailService *services.EmailService) *CVHandler { // If fromProjectRoot is true, uses paths relative to project root; otherwise uses relative paths from handlers dir
func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Service) *CVHandler {
t.Helper() t.Helper()
// Determine if we're running from project root by checking if templates/ exists
templatesDir := "templates"
partialsDir := "templates/partials"
// If templates doesn't exist at current dir, try relative path from handlers
if _, err := os.Stat(templatesDir); os.IsNotExist(err) {
templatesDir = "../../templates"
partialsDir = "../../templates/partials"
}
cfg := &config.TemplateConfig{ cfg := &config.TemplateConfig{
Dir: "../../templates", Dir: templatesDir,
PartialsDir: "../../templates/partials", PartialsDir: partialsDir,
HotReload: false, HotReload: false,
} }
tmplManager, err := templates.NewManager(cfg) tmplManager, err := templates.NewManager(cfg)
-34
View File
@@ -1,34 +0,0 @@
package lang
import "fmt"
// Supported language codes
const (
English = "en"
Spanish = "es"
)
// All returns all supported language codes
func All() []string {
return []string{English, Spanish}
}
// IsValid checks if a language code is supported
func IsValid(lang string) bool {
return lang == English || lang == Spanish
}
// Validate returns an error if the language code is unsupported.
// It provides helpful error messages showing all supported languages.
//
// Example:
//
// if err := lang.Validate("fr"); err != nil {
// // err: unsupported language: fr (supported: [en es])
// }
func Validate(lang string) error {
if !IsValid(lang) {
return fmt.Errorf("unsupported language: %s (supported: %v)", lang, All())
}
return nil
}
-121
View File
@@ -1,121 +0,0 @@
package lang_test
import (
"testing"
"github.com/juanatsap/cv-site/internal/lang"
)
func TestIsValid(t *testing.T) {
tests := []struct {
name string
language string
want bool
}{
{
name: "Valid - English",
language: "en",
want: true,
},
{
name: "Valid - Spanish",
language: "es",
want: true,
},
{
name: "Invalid - French",
language: "fr",
want: false,
},
{
name: "Invalid - Empty",
language: "",
want: false,
},
{
name: "Invalid - Uppercase",
language: "EN",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := lang.IsValid(tt.language); got != tt.want {
t.Errorf("IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidate(t *testing.T) {
tests := []struct {
name string
language string
wantErr bool
}{
{
name: "Valid - English",
language: "en",
wantErr: false,
},
{
name: "Valid - Spanish",
language: "es",
wantErr: false,
},
{
name: "Invalid - French",
language: "fr",
wantErr: true,
},
{
name: "Invalid - Empty",
language: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := lang.Validate(tt.language)
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && tt.wantErr {
// Check that error message includes supported languages
errMsg := err.Error()
if errMsg == "" {
t.Error("Validate() error message is empty")
}
}
})
}
}
func TestAll(t *testing.T) {
all := lang.All()
if len(all) != 2 {
t.Errorf("All() returned %d languages, want 2", len(all))
}
// Check that it contains en and es
hasEN := false
hasES := false
for _, l := range all {
if l == "en" {
hasEN = true
}
if l == "es" {
hasES = true
}
}
if !hasEN {
t.Error("All() missing 'en'")
}
if !hasES {
t.Error("All() missing 'es'")
}
}
+2 -7
View File
@@ -8,11 +8,6 @@ import (
c "github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
const (
// Custom header that browser JavaScript must set
browserHeaderName = "X-Requested-With"
browserHeaderValue = "XMLHttpRequest"
)
// BrowserOnly restricts endpoint access to browser requests only // BrowserOnly restricts endpoint access to browser requests only
// Blocks curl, Postman, and other HTTP clients // Blocks curl, Postman, and other HTTP clients
@@ -44,8 +39,8 @@ func BrowserOnly(next http.Handler) http.Handler {
// For HTMX requests, check HX-Request header // For HTMX requests, check HX-Request header
// For fetch/XMLHttpRequest, check X-Requested-With header // For fetch/XMLHttpRequest, check X-Requested-With header
hasHTMXHeader := r.Header.Get(c.HeaderHXRequest) == "true" hasHTMXHeader := r.Header.Get(c.HeaderHXRequest) == "true"
hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue hasXMLHTTPHeader := r.Header.Get(c.HeaderXRequestedWith) == c.HeaderValueXMLHTTPRequest
hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true" hasCustomBrowserHeader := r.Header.Get(c.HeaderXBrowserReq) == "true"
if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader { if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r)) log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
+7 -7
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -65,7 +66,7 @@ func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p> <p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
</div>`)) </div>`))
} else { } else {
w.Header().Set(c.HeaderRetryAfter, "3600") // 1 hour w.Header().Set(c.HeaderRetryAfter, fmt.Sprintf("%d", int(c.RateLimitContactWindow.Seconds())))
http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests) http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
} }
return return
@@ -76,14 +77,13 @@ func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
} }
// allow checks if the request is allowed based on rate limit // allow checks if the request is allowed based on rate limit
// Limit: 5 submissions per hour
func (rl *ContactRateLimiter) allow(ip string) bool { func (rl *ContactRateLimiter) allow(ip string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
now := time.Now() now := time.Now()
limit := 5 limit := c.RateLimitContactRequests
window := 1 * time.Hour window := c.RateLimitContactWindow
entry, exists := rl.clients[ip] entry, exists := rl.clients[ip]
if !exists || now.After(entry.resetTime) { if !exists || now.After(entry.resetTime) {
@@ -105,7 +105,7 @@ func (rl *ContactRateLimiter) allow(ip string) bool {
// cleanup removes expired entries periodically // cleanup removes expired entries periodically
func (rl *ContactRateLimiter) cleanup() { func (rl *ContactRateLimiter) cleanup() {
ticker := time.NewTicker(10 * time.Minute) ticker := time.NewTicker(c.RateLimitCleanupPeriod)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
@@ -127,7 +127,7 @@ func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"total_clients": len(rl.clients), "total_clients": len(rl.clients),
"limit": 5, "limit": c.RateLimitContactRequests,
"window": "1 hour", "window": c.RateLimitContactWindow.String(),
} }
} }
+9 -16
View File
@@ -12,13 +12,6 @@ import (
c "github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
const (
csrfTokenLength = 32
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfTokenTTL = 24 * time.Hour
)
// csrfTokenEntry stores token with expiration // csrfTokenEntry stores token with expiration
type csrfTokenEntry struct { type csrfTokenEntry struct {
token string token string
@@ -76,7 +69,7 @@ func (csrf *CSRFProtection) Middleware(next http.Handler) http.Handler {
// generateToken creates a new CSRF token // generateToken creates a new CSRF token
func (csrf *CSRFProtection) generateToken() (string, error) { func (csrf *CSRFProtection) generateToken() (string, error) {
bytes := make([]byte, csrfTokenLength) bytes := make([]byte, c.CSRFTokenLength)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return "", err return "", err
} }
@@ -87,7 +80,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
csrf.mu.Lock() csrf.mu.Lock()
csrf.tokens[token] = &csrfTokenEntry{ csrf.tokens[token] = &csrfTokenEntry{
token: token, token: token,
expiresAt: time.Now().Add(csrfTokenTTL), expiresAt: time.Now().Add(c.CSRFTokenTTL),
} }
csrf.mu.Unlock() csrf.mu.Unlock()
@@ -98,7 +91,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
// This should be called when rendering forms // This should be called when rendering forms
func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) { func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
// Check if token exists in cookie // Check if token exists in cookie
cookie, err := r.Cookie(csrfCookieName) cookie, err := r.Cookie(c.CSRFCookieName)
if err == nil && cookie.Value != "" { if err == nil && cookie.Value != "" {
// Validate existing token // Validate existing token
csrf.mu.RLock() csrf.mu.RLock()
@@ -119,13 +112,13 @@ func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (st
// Set cookie // Set cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: csrfCookieName, Name: c.CSRFCookieName,
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
MaxAge: int(csrfTokenTTL.Seconds()), MaxAge: int(c.CSRFTokenTTL.Seconds()),
}) })
return token, nil return token, nil
@@ -138,12 +131,12 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
// Try form value first // Try form value first
if err := r.ParseForm(); err == nil { if err := r.ParseForm(); err == nil {
formToken = r.FormValue(csrfFormField) formToken = r.FormValue(c.CSRFFormField)
} }
// If not in form, try header (for AJAX requests) // If not in form, try header (for AJAX requests)
if formToken == "" { if formToken == "" {
formToken = r.Header.Get("X-CSRF-Token") formToken = r.Header.Get(c.HeaderXCSRFToken)
} }
if formToken == "" { if formToken == "" {
@@ -152,7 +145,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
} }
// Get token from cookie // Get token from cookie
cookie, err := r.Cookie(csrfCookieName) cookie, err := r.Cookie(c.CSRFCookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
log.Printf("CSRF: No token in cookie") log.Printf("CSRF: No token in cookie")
return false return false
@@ -184,7 +177,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
// cleanup removes expired tokens periodically // cleanup removes expired tokens periodically
func (csrf *CSRFProtection) cleanup() { func (csrf *CSRFProtection) cleanup() {
ticker := time.NewTicker(1 * time.Hour) ticker := time.NewTicker(c.CSRFCleanupPeriod)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
+1 -1
View File
@@ -208,7 +208,7 @@ func (rl *RateLimiter) allow(ip string) bool {
// cleanup removes expired entries periodically // cleanup removes expired entries periodically
func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(c.RateLimitGeneralCleanupPeriod)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
+4 -2
View File
@@ -7,6 +7,8 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
c "github.com/juanatsap/cv-site/internal/constants"
) )
// CV represents the complete curriculum vitae structure // CV represents the complete curriculum vitae structure
@@ -250,7 +252,7 @@ func LoadCV(lang string) (*CV, error) {
return nil, fmt.Errorf("unsupported language: %s", lang) return nil, fmt.Errorf("unsupported language: %s", lang)
} }
filename := fmt.Sprintf("data/cv-%s.json", lang) filename := fmt.Sprintf("%s/cv-%s.json", c.DirData, lang)
filepath, err := findDataFile(filename) filepath, err := findDataFile(filename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -286,7 +288,7 @@ func LoadUI(lang string) (*UI, error) {
return nil, fmt.Errorf("unsupported language: %s", lang) return nil, fmt.Errorf("unsupported language: %s", lang)
} }
filename := fmt.Sprintf("data/ui-%s.json", lang) filename := fmt.Sprintf("%s/ui-%s.json", c.DirData, lang)
filepath, err := findDataFile(filename) filepath, err := findDataFile(filename)
if err != nil { if err != nil {
return nil, err return nil, err
+3 -3
View File
@@ -5,18 +5,18 @@ import (
"strings" "strings"
"time" "time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/fileutil" "github.com/juanatsap/cv-site/internal/fileutil"
"github.com/juanatsap/cv-site/internal/lang"
) )
// LoadCV loads CV data from a JSON file for the specified language // LoadCV loads CV data from a JSON file for the specified language
func LoadCV(language string) (*CV, error) { func LoadCV(language string) (*CV, error) {
if err := lang.Validate(language); err != nil { if err := c.ValidateLang(language); err != nil {
return nil, err return nil, err
} }
var cvData CV var cvData CV
filename := fmt.Sprintf("data/cv-%s.json", language) filename := fmt.Sprintf("%s/cv-%s.json", c.DirData, language)
if err := fileutil.LoadJSON(filename, &cvData); err != nil { if err := fileutil.LoadJSON(filename, &cvData); err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -3,18 +3,18 @@ package ui
import ( import (
"fmt" "fmt"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/fileutil" "github.com/juanatsap/cv-site/internal/fileutil"
"github.com/juanatsap/cv-site/internal/lang"
) )
// LoadUI loads UI translations from a JSON file for the specified language // LoadUI loads UI translations from a JSON file for the specified language
func LoadUI(language string) (*UI, error) { func LoadUI(language string) (*UI, error) {
if err := lang.Validate(language); err != nil { if err := c.ValidateLang(language); err != nil {
return nil, err return nil, err
} }
var uiData UI var uiData UI
filename := fmt.Sprintf("data/ui-%s.json", language) filename := fmt.Sprintf("%s/ui-%s.json", c.DirData, language)
if err := fileutil.LoadJSON(filename, &uiData); err != nil { if err := fileutil.LoadJSON(filename, &uiData); err != nil {
return nil, err return nil, err
} }
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
c "github.com/juanatsap/cv-site/internal/constants"
) )
// Generator handles PDF generation using headless Chrome // Generator handles PDF generation using headless Chrome
@@ -19,7 +21,7 @@ type Generator struct {
// NewGenerator creates a new PDF generator with the specified timeout // NewGenerator creates a new PDF generator with the specified timeout
func NewGenerator(timeout time.Duration) *Generator { func NewGenerator(timeout time.Duration) *Generator {
if timeout == 0 { if timeout == 0 {
timeout = 30 * time.Second timeout = c.TimeoutPDFGeneration
} }
return &Generator{ return &Generator{
timeout: timeout, timeout: timeout,
@@ -176,7 +178,7 @@ func (g *Generator) GenerateFromURLWithOptions(ctx context.Context, url string,
// Check if this is a short version (to apply compact sidebar fonts) // Check if this is a short version (to apply compact sidebar fonts)
// The length parameter is passed as a cookie, not in the URL // The length parameter is passed as a cookie, not in the URL
isShortVersion := cookies["cv-length"] == "short" isShortVersion := cookies[c.CookieCVLength] == c.CVLengthShort
// Inject CSS to show sidebars AND restore their positioning // Inject CSS to show sidebars AND restore their positioning
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error { tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
+4 -4
View File
@@ -2,8 +2,8 @@ package routes
import ( import (
"net/http" "net/http"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/middleware"
) )
@@ -34,7 +34,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
// Contact form endpoint with full security chain: // Contact form endpoint with full security chain:
// BrowserOnly → RateLimiter → Handler // BrowserOnly → RateLimiter → Handler
// This blocks curl/Postman, enforces rate limits, then processes the request // This blocks curl/Postman, enforces rate limits, then processes the request
contactRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour) contactRateLimiter := middleware.NewRateLimiter(c.RateLimitContactRequests, c.RateLimitContactWindow)
protectedContactHandler := middleware.BrowserOnly( protectedContactHandler := middleware.BrowserOnly(
contactRateLimiter.Middleware( contactRateLimiter.Middleware(
http.HandlerFunc(cvHandler.HandleContact), http.HandlerFunc(cvHandler.HandleContact),
@@ -43,7 +43,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
mux.Handle("/api/contact", protectedContactHandler) mux.Handle("/api/contact", protectedContactHandler)
// Protected PDF endpoint with rate limiting (3 requests/minute per IP) // Protected PDF endpoint with rate limiting (3 requests/minute per IP)
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) pdfRateLimiter := middleware.NewRateLimiter(c.RateLimitPDFRequests, c.RateLimitPDFWindow)
protectedPDFHandler := middleware.OriginChecker( protectedPDFHandler := middleware.OriginChecker(
pdfRateLimiter.Middleware( pdfRateLimiter.Middleware(
http.HandlerFunc(cvHandler.ExportPDF), http.HandlerFunc(cvHandler.ExportPDF),
@@ -52,7 +52,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
mux.Handle("/export/pdf", protectedPDFHandler) mux.Handle("/export/pdf", protectedPDFHandler)
// Static files with cache control // Static files with cache control
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(c.DirStatic)))
mux.Handle("/static/", middleware.CacheControl(staticHandler)) mux.Handle("/static/", middleware.CacheControl(staticHandler))
// Apply comprehensive middleware chain // Apply comprehensive middleware chain
+5 -4
View File
@@ -13,9 +13,10 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/juanatsap/cv-site/internal/cache" "github.com/juanatsap/cv-site/internal/cache"
"github.com/juanatsap/cv-site/internal/config" "github.com/juanatsap/cv-site/internal/config"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/routes" "github.com/juanatsap/cv-site/internal/routes"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
@@ -51,7 +52,7 @@ func main() {
log.Println("📦 Data cache initialized (en, es)") log.Println("📦 Data cache initialized (en, es)")
// Initialize email service // Initialize email service
emailService := services.NewEmailService(&services.EmailConfig{ emailService := email.NewService(&email.Config{
SMTPHost: cfg.Email.SMTPHost, SMTPHost: cfg.Email.SMTPHost,
SMTPPort: cfg.Email.SMTPPort, SMTPPort: cfg.Email.SMTPPort,
SMTPUser: cfg.Email.SMTPUser, SMTPUser: cfg.Email.SMTPUser,
@@ -74,7 +75,7 @@ func main() {
Handler: handler, Handler: handler,
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second, ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second, WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
IdleTimeout: 120 * time.Second, IdleTimeout: c.TimeoutIdleConnection,
} }
// Start server in goroutine // Start server in goroutine
@@ -104,7 +105,7 @@ func main() {
log.Printf("🛑 Shutdown signal received: %v", sig) log.Printf("🛑 Shutdown signal received: %v", sig)
// Create shutdown context with timeout // Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), c.TimeoutGracefulShutdown)
defer cancel() defer cancel()
// Attempt graceful shutdown of HTTP server // Attempt graceful shutdown of HTTP server
+13 -13
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
) )
// TestSMTPConnection tests that SMTP credentials are valid and connection works // TestSMTPConnection tests that SMTP credentials are valid and connection works
@@ -123,7 +123,7 @@ func TestEmailServiceSend(t *testing.T) {
t.Skip("Skipping email send test: CONTACT_EMAIL not configured") t.Skip("Skipping email send test: CONTACT_EMAIL not configured")
} }
config := &services.EmailConfig{ config := &email.Config{
SMTPHost: host, SMTPHost: host,
SMTPPort: port, SMTPPort: port,
SMTPUser: user, SMTPUser: user,
@@ -132,9 +132,9 @@ func TestEmailServiceSend(t *testing.T) {
ToEmail: to, ToEmail: to,
} }
emailService := services.NewEmailService(config) emailService := email.NewService(config)
testData := &services.ContactFormData{ testData := &email.ContactFormData{
Email: "test-sender@example.com", Email: "test-sender@example.com",
Name: "Integration Test", Name: "Integration Test",
Company: "Test Suite", Company: "Test Suite",
@@ -155,7 +155,7 @@ func TestEmailServiceSend(t *testing.T) {
// TestEmailServiceValidation tests that the email service properly validates input // TestEmailServiceValidation tests that the email service properly validates input
func TestEmailServiceValidation(t *testing.T) { func TestEmailServiceValidation(t *testing.T) {
config := &services.EmailConfig{ config := &email.Config{
SMTPHost: "smtp.test.com", SMTPHost: "smtp.test.com",
SMTPPort: "465", SMTPPort: "465",
SMTPUser: "test@test.com", SMTPUser: "test@test.com",
@@ -164,17 +164,17 @@ func TestEmailServiceValidation(t *testing.T) {
ToEmail: "to@test.com", ToEmail: "to@test.com",
} }
emailService := services.NewEmailService(config) emailService := email.NewService(config)
tests := []struct { tests := []struct {
name string name string
data *services.ContactFormData data *email.ContactFormData
wantErr bool wantErr bool
errMsg string errMsg string
}{ }{
{ {
name: "valid data", name: "valid data",
data: &services.ContactFormData{ data: &email.ContactFormData{
Email: "valid@example.com", Email: "valid@example.com",
Name: "Valid User", Name: "Valid User",
Message: "This is a valid message with more than 10 characters.", Message: "This is a valid message with more than 10 characters.",
@@ -184,7 +184,7 @@ func TestEmailServiceValidation(t *testing.T) {
}, },
{ {
name: "missing email", name: "missing email",
data: &services.ContactFormData{ data: &email.ContactFormData{
Name: "No Email", Name: "No Email",
Message: "This is a valid message.", Message: "This is a valid message.",
Time: time.Now(), Time: time.Now(),
@@ -194,7 +194,7 @@ func TestEmailServiceValidation(t *testing.T) {
}, },
{ {
name: "invalid email format", name: "invalid email format",
data: &services.ContactFormData{ data: &email.ContactFormData{
Email: "notanemail", Email: "notanemail",
Name: "Bad Email", Name: "Bad Email",
Message: "This is a valid message.", Message: "This is a valid message.",
@@ -205,7 +205,7 @@ func TestEmailServiceValidation(t *testing.T) {
}, },
{ {
name: "missing message", name: "missing message",
data: &services.ContactFormData{ data: &email.ContactFormData{
Email: "valid@example.com", Email: "valid@example.com",
Name: "No Message", Name: "No Message",
Time: time.Now(), Time: time.Now(),
@@ -215,7 +215,7 @@ func TestEmailServiceValidation(t *testing.T) {
}, },
{ {
name: "message too short", name: "message too short",
data: &services.ContactFormData{ data: &email.ContactFormData{
Email: "valid@example.com", Email: "valid@example.com",
Name: "Short Msg", Name: "Short Msg",
Message: "Hi", Message: "Hi",
@@ -226,7 +226,7 @@ func TestEmailServiceValidation(t *testing.T) {
}, },
{ {
name: "email with newlines (header injection)", name: "email with newlines (header injection)",
data: &services.ContactFormData{ data: &email.ContactFormData{
Email: "test@example.com\nBcc: attacker@evil.com", Email: "test@example.com\nBcc: attacker@evil.com",
Name: "Attacker", Name: "Attacker",
Message: "Trying to inject headers.", Message: "Trying to inject headers.",
+2 -2
View File
@@ -11,14 +11,14 @@ import (
"github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/middleware"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
// mockEmailSender is a mock implementation of handlers.EmailSender for testing // mockEmailSender is a mock implementation of handlers.EmailSender for testing
type mockEmailSender struct{} type mockEmailSender struct{}
func (m *mockEmailSender) SendContactForm(data *services.ContactFormData) error { func (m *mockEmailSender) SendContactForm(data *email.ContactFormData) error {
// Validate like the real service would // Validate like the real service would
if err := data.Validate(); err != nil { if err := data.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err) return fmt.Errorf("validation failed: %w", err)