fix: Mobile hamburger menu and iPad sidebar visibility
Mobile fixes: - Add click toggle handler for hamburger menu (was hover-only) - Menu now opens/closes on tap and closes when clicking outside - Keep hover support for desktop iPad fixes: - Sidebar content now visible on touch devices (901-1280px) - Added (hover: hover) media query to prevent hide-on-hover on tablets Security improvements: - Replace exec.CommandContext with go-git library for git operations - Add path traversal and command injection prevention - Fix race condition in template hot reload - Add environment-based cookie Secure flag Code quality: - Add constants.go for magic numbers - Remove unused code (ParsePreferenceToggleRequest, DomainError) - Add FOUC prevention with inline critical CSS - Add Makefile dev/run/clean targets - Fix README git clone URL - Add doc/DECISIONS.md for architectural decisions Tests: - Add hamburger menu click toggle tests - Add iPad sidebar visibility tests - Update security tests for go-git implementation - Add cookie Secure flag tests
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import "time"
|
||||
|
||||
// ==============================================================================
|
||||
// HTTP CONTENT TYPES
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// ContentTypePDF is the MIME type for PDF documents
|
||||
ContentTypePDF = "application/pdf"
|
||||
|
||||
// ContentTypeHTML is the MIME type for HTML documents
|
||||
ContentTypeHTML = "text/html; charset=utf-8"
|
||||
|
||||
// ContentTypeJSON is the MIME type for JSON documents
|
||||
ContentTypeJSON = "application/json"
|
||||
|
||||
// ContentTypePlainText is the MIME type for plain text
|
||||
ContentTypePlainText = "text/plain; charset=utf-8"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// RATE LIMITING
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// PDFRateLimitRequests is the maximum number of PDF requests per window
|
||||
PDFRateLimitRequests = 3
|
||||
|
||||
// PDFRateLimitWindow is the time window for PDF rate limiting
|
||||
PDFRateLimitWindow = 1 * time.Minute
|
||||
|
||||
// GeneralRateLimitRequests is the default rate limit for general requests
|
||||
GeneralRateLimitRequests = 100
|
||||
|
||||
// GeneralRateLimitWindow is the time window for general rate limiting
|
||||
GeneralRateLimitWindow = 1 * time.Minute
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// PDF GENERATION
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// A4WidthInches is the width of A4 paper in inches
|
||||
A4WidthInches = 8.27
|
||||
|
||||
// A4HeightInches is the height of A4 paper in inches
|
||||
A4HeightInches = 11.69
|
||||
|
||||
// PDFGenerationTimeout is the maximum time allowed for PDF generation
|
||||
PDFGenerationTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// COOKIE SETTINGS
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// CookieMaxAge is the default cookie expiration (1 year in seconds)
|
||||
CookieMaxAge = 365 * 24 * 60 * 60
|
||||
|
||||
// CookiePath is the default cookie path
|
||||
CookiePath = "/"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// LANGUAGE CODES
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// LangEnglish is the English language code
|
||||
LangEnglish = "en"
|
||||
|
||||
// LangSpanish is the Spanish language code
|
||||
LangSpanish = "es"
|
||||
|
||||
// DefaultLanguage is the default language for the application
|
||||
DefaultLanguage = LangEnglish
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// CV PREFERENCES
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// CVLengthShort represents the short CV format
|
||||
CVLengthShort = "short"
|
||||
|
||||
// CVLengthLong represents the long CV format
|
||||
CVLengthLong = "long"
|
||||
|
||||
// CVIconsShow indicates icons should be visible
|
||||
CVIconsShow = "show"
|
||||
|
||||
// CVIconsHide indicates icons should be hidden
|
||||
CVIconsHide = "hide"
|
||||
|
||||
// CVThemeDefault is the default CV theme
|
||||
CVThemeDefault = "default"
|
||||
|
||||
// CVThemeClean is the clean CV theme
|
||||
CVThemeClean = "clean"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// HTTP HEADERS
|
||||
// ==============================================================================
|
||||
|
||||
const (
|
||||
// HeaderContentType is the Content-Type header key
|
||||
HeaderContentType = "Content-Type"
|
||||
|
||||
// HeaderContentDisposition is the Content-Disposition header key
|
||||
HeaderContentDisposition = "Content-Disposition"
|
||||
|
||||
// HeaderCacheControl is the Cache-Control header key
|
||||
HeaderCacheControl = "Cache-Control"
|
||||
|
||||
// HeaderHXRequest is the HTMX request header
|
||||
HeaderHXRequest = "HX-Request"
|
||||
)
|
||||
@@ -1,15 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
|
||||
)
|
||||
@@ -151,7 +152,7 @@ func calculateDuration(startDate, endDate string, current bool, lang string) str
|
||||
}
|
||||
|
||||
// processProjectDates calculates dynamic dates for projects
|
||||
// If a project has a gitRepoUrl, it fetches the first commit date
|
||||
// If a project has a gitRepoUrl, it fetches the first commit date using go-git
|
||||
// For current projects, it sets the current system date
|
||||
func processProjectDates(project *cvmodel.Project, lang string) {
|
||||
now := time.Now()
|
||||
@@ -165,7 +166,7 @@ func processProjectDates(project *cvmodel.Project, lang string) {
|
||||
}
|
||||
}
|
||||
|
||||
// If project has a git repository URL, fetch the first commit date
|
||||
// If project has a git repository path, fetch the first commit date
|
||||
if project.GitRepoUrl != "" {
|
||||
commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl)
|
||||
if commitDate != "" {
|
||||
@@ -185,32 +186,25 @@ func processProjectDates(project *cvmodel.Project, lang string) {
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// GIT HELPERS
|
||||
// GIT HELPERS (using go-git - pure Go implementation, no shell commands)
|
||||
// ==============================================================================
|
||||
|
||||
// findProjectRoot finds the project root directory
|
||||
// It looks for .git directory walking up the directory tree
|
||||
// findProjectRoot finds the project root directory by looking for .git directory
|
||||
func findProjectRoot() (string, error) {
|
||||
// Start from current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Walk up the directory tree looking for .git
|
||||
dir := cwd
|
||||
for {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
if info, err := os.Stat(gitPath); err == nil && info.IsDir() {
|
||||
// Found .git directory - this is the project root
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Reached root directory without finding .git
|
||||
// Fall back to current working directory
|
||||
return cwd, nil
|
||||
}
|
||||
dir = parent
|
||||
@@ -218,29 +212,23 @@ func findProjectRoot() (string, error) {
|
||||
}
|
||||
|
||||
// validateRepoPath validates that a repository path is safe to use
|
||||
// Security: Prevents path traversal and command injection attacks
|
||||
// Only allows paths within the project directory
|
||||
// Security: Prevents path traversal attacks by ensuring path is within project directory
|
||||
func validateRepoPath(path string) error {
|
||||
// Resolve to absolute path to prevent path traversal
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Get project root directory - find the git repo root
|
||||
// This ensures the validation works regardless of where code runs from
|
||||
projectRoot, err := findProjectRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine project root: %w", err)
|
||||
}
|
||||
|
||||
// Security check: Only allow paths within project directory
|
||||
// This prevents malicious paths like "../../../etc/passwd"
|
||||
// Security: Only allow paths within project directory
|
||||
if !strings.HasPrefix(absPath, projectRoot) {
|
||||
return fmt.Errorf("repository path outside project directory: %s", path)
|
||||
}
|
||||
|
||||
// Verify path exists and is a directory
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path does not exist: %w", err)
|
||||
@@ -253,49 +241,51 @@ func validateRepoPath(path string) error {
|
||||
}
|
||||
|
||||
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
|
||||
// Supports local git repository paths
|
||||
// Security: Validates path and uses timeout to prevent hanging
|
||||
// Uses go-git (pure Go) - no shell command execution, eliminating injection risks
|
||||
func getGitRepoFirstCommitDate(repoPath string) string {
|
||||
// Security: Validate repository path before executing git command
|
||||
// Security: Validate repository path
|
||||
if err := validateRepoPath(repoPath); err != nil {
|
||||
log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Security: Add timeout context to prevent hanging
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Execute git command with timeout protection
|
||||
// Using CommandContext for automatic cancellation on timeout
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m")
|
||||
|
||||
output, err := cmd.Output()
|
||||
// Open the repository using go-git
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
// Log error but don't expose details to prevent information disclosure
|
||||
log.Printf("Git command failed for path %s: %v", repoPath, err)
|
||||
log.Printf("Failed to open git repository at %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse the output to get the first commit date
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) == 0 {
|
||||
// Get the commit history
|
||||
commitIter, err := repo.Log(&git.LogOptions{
|
||||
Order: git.LogOrderCommitterTime,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get commit log for %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
defer commitIter.Close()
|
||||
|
||||
// Extract YYYY-MM from the first commit timestamp
|
||||
// Format of output: "2024-06-15 10:30:45 +0200"
|
||||
firstLine := lines[0]
|
||||
parts := strings.Fields(firstLine)
|
||||
if len(parts) > 0 {
|
||||
datePart := parts[0] // "2024-06-15"
|
||||
dateParts := strings.Split(datePart, "-")
|
||||
if len(dateParts) >= 2 {
|
||||
return dateParts[0] + "-" + dateParts[1] // "2024-06"
|
||||
// Find the oldest commit by iterating through all commits
|
||||
var oldestCommit *object.Commit
|
||||
err = commitIter.ForEach(func(c *object.Commit) error {
|
||||
if oldestCommit == nil || c.Committer.When.Before(oldestCommit.Committer.When) {
|
||||
oldestCommit = c
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error iterating commits for %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return ""
|
||||
if oldestCommit == nil {
|
||||
log.Printf("No commits found in repository %s", repoPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return date in YYYY-MM format
|
||||
return oldestCommit.Committer.When.Format("2006-01")
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// SECURITY TESTS for go-git Implementation
|
||||
// ==============================================================================
|
||||
// These tests verify that the path validation and git operations are secure.
|
||||
// The implementation uses go-git (pure Go) instead of exec.CommandContext
|
||||
// to eliminate shell command injection risks.
|
||||
// ==============================================================================
|
||||
|
||||
// TestValidateRepoPath tests the security validation for repository paths
|
||||
func TestValidateRepoPath(t *testing.T) {
|
||||
// Get project root (two levels up from handlers directory)
|
||||
@@ -116,12 +124,12 @@ func TestGetGitRepoFirstCommitDate_SecurityValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetGitRepoFirstCommitDate_Timeout tests that git commands timeout appropriately
|
||||
func TestGetGitRepoFirstCommitDate_Timeout(t *testing.T) {
|
||||
// TestGetGitRepoFirstCommitDate_NonGitRepo tests that non-git directories return empty
|
||||
func TestGetGitRepoFirstCommitDate_NonGitRepo(t *testing.T) {
|
||||
// Create a temporary directory that exists but is not a git repo
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// This should timeout/fail gracefully (not hang)
|
||||
// This should fail gracefully (not panic)
|
||||
result := getGitRepoFirstCommitDate(tempDir)
|
||||
|
||||
// Should return empty string for non-git repos
|
||||
@@ -130,6 +138,58 @@ func TestGetGitRepoFirstCommitDate_Timeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetGitRepoFirstCommitDate_ValidRepo tests the happy path with the current repo
|
||||
func TestGetGitRepoFirstCommitDate_ValidRepo(t *testing.T) {
|
||||
// Get project root (should be a valid git repo)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
projectRoot := filepath.Join(cwd, "..", "..")
|
||||
|
||||
// This should return a valid date
|
||||
result := getGitRepoFirstCommitDate(projectRoot)
|
||||
|
||||
// Should return a date in YYYY-MM format
|
||||
if result == "" {
|
||||
t.Log("Warning: No commit date returned (repo might not have commits)")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate format: YYYY-MM
|
||||
if len(result) != 7 || result[4] != '-' {
|
||||
t.Errorf("Expected date in YYYY-MM format, got %q", result)
|
||||
}
|
||||
|
||||
// Year should be between 2020 and 2030 (reasonable range)
|
||||
year := result[:4]
|
||||
if year < "2020" || year > "2030" {
|
||||
t.Errorf("Year %s seems unreasonable for project start date", year)
|
||||
}
|
||||
|
||||
t.Logf("First commit date: %s", result)
|
||||
}
|
||||
|
||||
// TestFindProjectRoot tests the project root detection
|
||||
func TestFindProjectRoot(t *testing.T) {
|
||||
root, err := findProjectRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to find project root: %v", err)
|
||||
}
|
||||
|
||||
// Verify .git directory exists
|
||||
gitPath := filepath.Join(root, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
t.Errorf("Expected .git directory at %s, got error: %v", gitPath, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("Expected .git to be a directory at %s", gitPath)
|
||||
}
|
||||
|
||||
t.Logf("Project root: %s", root)
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
|
||||
@@ -210,68 +210,6 @@ func (e *DomainError) WithField(field string) *DomainError {
|
||||
return e
|
||||
}
|
||||
|
||||
// Common domain error constructors
|
||||
|
||||
func InvalidLanguageError(lang string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeInvalidLanguage,
|
||||
fmt.Sprintf("Unsupported language: %s (use 'en' or 'es')", lang),
|
||||
http.StatusBadRequest,
|
||||
).WithField("lang")
|
||||
}
|
||||
|
||||
func InvalidLengthError(length string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeInvalidLength,
|
||||
fmt.Sprintf("Unsupported length: %s (use 'short' or 'long')", length),
|
||||
http.StatusBadRequest,
|
||||
).WithField("length")
|
||||
}
|
||||
|
||||
func InvalidIconsError(icons string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeInvalidIcons,
|
||||
fmt.Sprintf("Unsupported icons option: %s (use 'show' or 'hide')", icons),
|
||||
http.StatusBadRequest,
|
||||
).WithField("icons")
|
||||
}
|
||||
|
||||
func InvalidThemeError(theme string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeInvalidTheme,
|
||||
fmt.Sprintf("Unsupported theme: %s (use 'default' or 'clean')", theme),
|
||||
http.StatusBadRequest,
|
||||
).WithField("theme")
|
||||
}
|
||||
|
||||
func InvalidVersionError(version string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeInvalidVersion,
|
||||
fmt.Sprintf("Unsupported version: %s (use 'with_skills' or 'clean')", version),
|
||||
http.StatusBadRequest,
|
||||
).WithField("version")
|
||||
}
|
||||
|
||||
func PDFGenerationError(err error) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodePDFGeneration,
|
||||
"Failed to generate PDF",
|
||||
http.StatusInternalServerError,
|
||||
).WithError(err)
|
||||
}
|
||||
|
||||
func MethodNotAllowedError(method string) *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeMethodNotAllowed,
|
||||
fmt.Sprintf("Method %s not allowed", method),
|
||||
http.StatusMethodNotAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
func RateLimitError() *DomainError {
|
||||
return NewDomainError(
|
||||
ErrCodeRateLimitExceeded,
|
||||
"Rate limit exceeded. Please try again later.",
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
}
|
||||
// NOTE: Domain error constructors were removed as they were unused.
|
||||
// If needed in the future, they can be re-added following the DomainError pattern above.
|
||||
// See git history for the previous implementation.
|
||||
|
||||
@@ -84,21 +84,6 @@ func ParsePDFExportRequest(r *http.Request) (*PDFExportRequest, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// PreferenceToggleRequest represents a toggle request with language context
|
||||
type PreferenceToggleRequest struct {
|
||||
Lang string // Current language from query or cookie
|
||||
}
|
||||
|
||||
// ParsePreferenceToggleRequest parses toggle request parameters
|
||||
func ParsePreferenceToggleRequest(r *http.Request, defaultLang string) *PreferenceToggleRequest {
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = defaultLang
|
||||
}
|
||||
|
||||
return &PreferenceToggleRequest{Lang: lang}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// RESPONSE TYPES
|
||||
// Structured response types for consistent API responses
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// contextKey is a private type for context keys to avoid collisions
|
||||
@@ -146,10 +147,16 @@ func SetPreferenceCookie(w http.ResponseWriter, name string, value string) {
|
||||
MaxAge: 365 * 24 * 60 * 60, // 1 year
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
Secure: isProductionMode(), // Secure in production with HTTPS
|
||||
})
|
||||
}
|
||||
|
||||
// isProductionMode checks if the application is running in production
|
||||
func isProductionMode() bool {
|
||||
env := os.Getenv("GO_ENV")
|
||||
return env == "production" || env == "prod"
|
||||
}
|
||||
|
||||
// getPreferenceCookie gets a preference cookie value, returns default if not found
|
||||
func getPreferenceCookie(r *http.Request, name string, defaultValue string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -342,3 +343,110 @@ func TestMultipleMigrations(t *testing.T) {
|
||||
t.Errorf("CVIcons: expected 'show' (migrated from 'true'), got %q", capturedPrefs.CVIcons)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsProductionMode tests the production mode detection function
|
||||
func TestIsProductionMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Production environment",
|
||||
envValue: "production",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Prod shorthand",
|
||||
envValue: "prod",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Development environment",
|
||||
envValue: "development",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty environment",
|
||||
envValue: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Staging environment",
|
||||
envValue: "staging",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Case sensitivity - PRODUCTION",
|
||||
envValue: "PRODUCTION",
|
||||
expected: false, // Case sensitive
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save original environment
|
||||
originalEnv := os.Getenv("GO_ENV")
|
||||
defer os.Setenv("GO_ENV", originalEnv)
|
||||
|
||||
// Set test environment
|
||||
os.Setenv("GO_ENV", tt.envValue)
|
||||
|
||||
result := isProductionMode()
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("isProductionMode() with GO_ENV=%q: got %v, want %v",
|
||||
tt.envValue, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetPreferenceCookieSecureFlag tests that Secure flag is set correctly based on environment
|
||||
func TestSetPreferenceCookieSecureFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
expectedSecure bool
|
||||
}{
|
||||
{
|
||||
name: "Production mode sets Secure=true",
|
||||
envValue: "production",
|
||||
expectedSecure: true,
|
||||
},
|
||||
{
|
||||
name: "Development mode sets Secure=false",
|
||||
envValue: "development",
|
||||
expectedSecure: false,
|
||||
},
|
||||
{
|
||||
name: "Empty env sets Secure=false",
|
||||
envValue: "",
|
||||
expectedSecure: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save original environment
|
||||
originalEnv := os.Getenv("GO_ENV")
|
||||
defer os.Setenv("GO_ENV", originalEnv)
|
||||
|
||||
// Set test environment
|
||||
os.Setenv("GO_ENV", tt.envValue)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SetPreferenceCookie(w, "test-cookie", "test-value")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("Expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
|
||||
if cookies[0].Secure != tt.expectedSecure {
|
||||
t.Errorf("Cookie Secure flag with GO_ENV=%q: got %v, want %v",
|
||||
tt.envValue, cookies[0].Secure, tt.expectedSecure)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ func NewManager(cfg *config.TemplateConfig) (*Manager, error) {
|
||||
func (m *Manager) loadTemplates() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.loadTemplatesLocked()
|
||||
}
|
||||
|
||||
// loadTemplatesLocked parses templates without acquiring lock (caller must hold lock)
|
||||
func (m *Manager) loadTemplatesLocked() error {
|
||||
// Create template with custom functions
|
||||
funcMap := template.FuncMap{
|
||||
"iterate": func(count int) []int {
|
||||
@@ -109,15 +113,33 @@ func (m *Manager) Reload() error {
|
||||
}
|
||||
|
||||
// Render executes a template with the given data
|
||||
// Note: This method is thread-safe. Hot reload acquires full lock to prevent race conditions.
|
||||
func (m *Manager) Render(name string) (*template.Template, error) {
|
||||
// Hot reload in development mode
|
||||
// Use full lock to prevent race condition between reload and lookup
|
||||
if m.config.HotReload {
|
||||
if err := m.Reload(); err != nil {
|
||||
m.mu.Lock()
|
||||
if err := m.loadTemplatesLocked(); err != nil {
|
||||
m.mu.Unlock()
|
||||
log.Printf("Warning: template reload failed: %v", err)
|
||||
// Continue with cached templates
|
||||
// Fall back to read lock for cached templates
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
tmpl := m.templates.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
tmpl := m.templates.Lookup(name)
|
||||
m.mu.Unlock()
|
||||
if tmpl == nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// Production mode: just read
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user