From df822a96a377f5a081cb97640721b57ebfd99f2c Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sat, 1 Nov 2025 21:14:18 +0000 Subject: [PATCH] Add configurable session and cookie settings --- README.md | 17 ++ internal/app/app.go | 56 ++++ internal/app/app_test.go | 357 ++++++++++++++++++++++ internal/app/controller/web/edit_test.go | 1 + internal/app/controller/web/index_test.go | 1 + internal/app/controller/web/new_test.go | 1 + pkg/controller/controller.go | 30 +- pkg/controller/controller_test.go | 61 +++- pkg/session/store.go | 49 ++- pkg/session/store_test.go | 334 ++++++++++++++++++++ 10 files changed, 883 insertions(+), 24 deletions(-) create mode 100644 internal/app/app_test.go create mode 100644 pkg/session/store_test.go diff --git a/README.md b/README.md index c52f8e8..88ae8b5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ _Please note: you will need Docker installed on your local machine._ The application uses environment variables to configure all aspects. +### General Configuration + * `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` * `J_CREATE` - Set to `0` to disable article creation * `J_DB_PATH` - Path to SQLite DB - default is `$GOPATH/data/journal.db` @@ -59,6 +61,21 @@ The application uses environment variables to configure all aspects. * `J_THEME` - Theme to use from within the _web/themes_ folder, defaults to `default` * `J_TITLE` - Set the title of the Journal +### SSL/TLS Configuration + +* `J_SSL_CERT` - Path to SSL certificate file for HTTPS (enables SSL when set) +* `J_SSL_KEY` - Path to SSL private key file for HTTPS + +### Session and Cookie Security + +* `J_SESSION_KEY` - 32-byte encryption key for session data (AES-256). Must be exactly 32 printable ASCII characters. If not set, a random key is generated on startup (sessions won't persist across restarts). +* `J_SESSION_NAME` - Cookie name for sessions, default `journal-session` +* `J_COOKIE_DOMAIN` - Domain restriction for cookies, default is current domain only +* `J_COOKIE_MAX_AGE` - Cookie expiry time in seconds, default `2592000` (30 days) +* `J_COOKIE_HTTPONLY` - Set to `0` or `false` to allow JavaScript access to cookies (not recommended). Default is `true` for XSS protection. + +**Note:** When `J_SSL_CERT` is configured, session cookies automatically use the `Secure` flag to prevent transmission over unencrypted connections. + ## Layout The project layout follows the standard set out in the following document: diff --git a/internal/app/app.go b/internal/app/app.go index 6fabe83..afc543d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,7 +1,10 @@ package app import ( + "crypto/rand" "database/sql" + "encoding/hex" + "log" "os" "strconv" @@ -46,6 +49,12 @@ type Configuration struct { Theme string ThemePath string Title string + SessionKey string + SessionName string + CookieDomain string + CookieMaxAge int + CookieSecure bool + CookieHTTPOnly bool } // DefaultConfiguration returns the default settings for the app @@ -65,6 +74,12 @@ func DefaultConfiguration() Configuration { Theme: "default", ThemePath: "web/themes", Title: "Jamie's Journal", + SessionKey: "", + SessionName: "journal-session", + CookieDomain: "", + CookieMaxAge: 2592000, + CookieSecure: false, + CookieHTTPOnly: true, } } @@ -101,6 +116,47 @@ func ApplyEnvConfiguration(config *Configuration) { } config.SSLCertificate = os.Getenv("J_SSL_CERT") config.SSLKey = os.Getenv("J_SSL_KEY") + + sessionKey := os.Getenv("J_SESSION_KEY") + if sessionKey != "" { + if len(sessionKey) != 32 { + log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") + sessionKey = "" + } + } + if sessionKey == "" { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err == nil { + sessionKey = hex.EncodeToString(bytes) + log.Println("WARNING: J_SESSION_KEY not set or invalid. Using auto-generated key. Sessions will not persist across restarts.") + } + } + config.SessionKey = sessionKey + + sessionName := os.Getenv("J_SESSION_NAME") + if sessionName != "" { + config.SessionName = sessionName + } + + cookieDomain := os.Getenv("J_COOKIE_DOMAIN") + if cookieDomain != "" { + config.CookieDomain = cookieDomain + } + + cookieMaxAge, _ := strconv.Atoi(os.Getenv("J_COOKIE_MAX_AGE")) + if cookieMaxAge > 0 { + config.CookieMaxAge = cookieMaxAge + } + + cookieHTTPOnly := os.Getenv("J_COOKIE_HTTPONLY") + if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { + config.CookieHTTPOnly = false + } + + if config.SSLCertificate != "" { + config.CookieSecure = true + } + staticPath := os.Getenv("J_STATIC_PATH") if staticPath != "" { config.StaticPath = staticPath diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..bccce5f --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,357 @@ +package app + +import ( + "os" + "testing" +) + +func TestDefaultConfiguration(t *testing.T) { + config := DefaultConfiguration() + + if config.ArticlesPerPage != 20 { + t.Errorf("Expected ArticlesPerPage 20, got %d", config.ArticlesPerPage) + } + if config.Port != "3000" { + t.Errorf("Expected Port '3000', got %q", config.Port) + } + if config.SessionName != "journal-session" { + t.Errorf("Expected SessionName 'journal-session', got %q", config.SessionName) + } + if config.CookieMaxAge != 2592000 { + t.Errorf("Expected CookieMaxAge 2592000, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != true { + t.Errorf("Expected CookieHTTPOnly true, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != false { + t.Errorf("Expected CookieSecure false, got %v", config.CookieSecure) + } + if config.SessionKey != "" { + t.Errorf("Expected SessionKey to be empty by default, got %q", config.SessionKey) + } +} + +func TestApplyEnvConfiguration_SessionKey(t *testing.T) { + tests := []struct { + name string + envValue string + expectWarning bool + expectKey bool + }{ + { + name: "Valid 32-byte key", + envValue: "12345678901234567890123456789012", + expectWarning: false, + expectKey: true, + }, + { + name: "Key too short generates auto key", + envValue: "tooshort", + expectWarning: true, + expectKey: true, + }, + { + name: "Key too long generates auto key", + envValue: "123456789012345678901234567890123", + expectWarning: true, + expectKey: true, + }, + { + name: "Empty key generates auto key", + envValue: "", + expectWarning: true, + expectKey: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + os.Setenv("J_SESSION_KEY", test.envValue) + defer os.Unsetenv("J_SESSION_KEY") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if test.expectKey && config.SessionKey == "" { + t.Errorf("Expected session key to be set") + } + if test.expectKey && len(config.SessionKey) != 32 { + t.Errorf("Expected session key length 32, got %d", len(config.SessionKey)) + } + if test.envValue != "" && len(test.envValue) == 32 && config.SessionKey != test.envValue { + t.Errorf("Expected session key %q, got %q", test.envValue, config.SessionKey) + } + }) + } +} + +func TestApplyEnvConfiguration_SessionName(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom session name", + envValue: "custom-session", + expected: "custom-session", + }, + { + name: "Empty uses default", + envValue: "", + expected: "journal-session", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_SESSION_NAME", test.envValue) + defer os.Unsetenv("J_SESSION_NAME") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionName != test.expected { + t.Errorf("Expected SessionName %q, got %q", test.expected, config.SessionName) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieDomain(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom domain", + envValue: ".example.com", + expected: ".example.com", + }, + { + name: "Specific subdomain", + envValue: "app.example.com", + expected: "app.example.com", + }, + { + name: "Empty uses default", + envValue: "", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_DOMAIN", test.envValue) + defer os.Unsetenv("J_COOKIE_DOMAIN") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieDomain != test.expected { + t.Errorf("Expected CookieDomain %q, got %q", test.expected, config.CookieDomain) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieMaxAge(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + { + name: "Custom max age", + envValue: "7200", + expected: 7200, + }, + { + name: "One week", + envValue: "604800", + expected: 604800, + }, + { + name: "Invalid uses default", + envValue: "invalid", + expected: 2592000, + }, + { + name: "Empty uses default", + envValue: "", + expected: 2592000, + }, + { + name: "Zero uses default", + envValue: "0", + expected: 2592000, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_MAX_AGE", test.envValue) + defer os.Unsetenv("J_COOKIE_MAX_AGE") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieMaxAge != test.expected { + t.Errorf("Expected CookieMaxAge %d, got %d", test.expected, config.CookieMaxAge) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieHTTPOnly(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "Disabled with 0", + envValue: "0", + expected: false, + }, + { + name: "Disabled with false", + envValue: "false", + expected: false, + }, + { + name: "Enabled with 1", + envValue: "1", + expected: true, + }, + { + name: "Enabled with true", + envValue: "true", + expected: true, + }, + { + name: "Default is enabled", + envValue: "", + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_HTTPONLY", test.envValue) + defer os.Unsetenv("J_COOKIE_HTTPONLY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieHTTPOnly != test.expected { + t.Errorf("Expected CookieHTTPOnly %v, got %v", test.expected, config.CookieHTTPOnly) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieSecure(t *testing.T) { + tests := []struct { + name string + sslCert string + sslKey string + expected bool + description string + }{ + { + name: "Secure when SSL cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "/path/to/key.pem", + expected: true, + description: "Cookie should be secure when SSL is enabled", + }, + { + name: "Not secure when SSL cert is empty", + sslCert: "", + sslKey: "", + expected: false, + description: "Cookie should not be secure when SSL is not enabled", + }, + { + name: "Secure even without key if cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "", + expected: true, + description: "Cookie secure flag follows cert presence", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.sslCert != "" { + os.Setenv("J_SSL_CERT", test.sslCert) + defer os.Unsetenv("J_SSL_CERT") + } + if test.sslKey != "" { + os.Setenv("J_SSL_KEY", test.sslKey) + defer os.Unsetenv("J_SSL_KEY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieSecure != test.expected { + t.Errorf("%s: Expected CookieSecure %v, got %v", test.description, test.expected, config.CookieSecure) + } + }) + } +} + +func TestApplyEnvConfiguration_Combined(t *testing.T) { + os.Setenv("J_SESSION_KEY", "abcdefghijklmnopqrstuvwxyz123456") + os.Setenv("J_SESSION_NAME", "my-app-session") + os.Setenv("J_COOKIE_DOMAIN", ".myapp.com") + os.Setenv("J_COOKIE_MAX_AGE", "1800") + os.Setenv("J_COOKIE_HTTPONLY", "0") + os.Setenv("J_SSL_CERT", "/path/to/cert.pem") + os.Setenv("J_PORT", "8080") + defer func() { + os.Unsetenv("J_SESSION_KEY") + os.Unsetenv("J_SESSION_NAME") + os.Unsetenv("J_COOKIE_DOMAIN") + os.Unsetenv("J_COOKIE_MAX_AGE") + os.Unsetenv("J_COOKIE_HTTPONLY") + os.Unsetenv("J_SSL_CERT") + os.Unsetenv("J_PORT") + }() + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionKey != "abcdefghijklmnopqrstuvwxyz123456" { + t.Errorf("Expected SessionKey 'abcdefghijklmnopqrstuvwxyz123456', got %q", config.SessionKey) + } + if config.SessionName != "my-app-session" { + t.Errorf("Expected SessionName 'my-app-session', got %q", config.SessionName) + } + if config.CookieDomain != ".myapp.com" { + t.Errorf("Expected CookieDomain '.myapp.com', got %q", config.CookieDomain) + } + if config.CookieMaxAge != 1800 { + t.Errorf("Expected CookieMaxAge 1800, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != false { + t.Errorf("Expected CookieHTTPOnly false, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != true { + t.Errorf("Expected CookieSecure true (SSL enabled), got %v", config.CookieSecure) + } + if config.Port != "8080" { + t.Errorf("Expected Port '8080', got %q", config.Port) + } +} diff --git a/internal/app/controller/web/edit_test.go b/internal/app/controller/web/edit_test.go index 822a442..43f2ee5 100644 --- a/internal/app/controller/web/edit_test.go +++ b/internal/app/controller/web/edit_test.go @@ -26,6 +26,7 @@ func TestEdit_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() configuration.EnableEdit = true + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Edit{} diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index a9fe16b..e9e2888 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -26,6 +26,7 @@ func TestIndex_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() configuration.ArticlesPerPage = 2 + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Index{} diff --git a/internal/app/controller/web/new_test.go b/internal/app/controller/web/new_test.go index 888a1bb..28e670f 100644 --- a/internal/app/controller/web/new_test.go +++ b/internal/app/controller/web/new_test.go @@ -28,6 +28,7 @@ func TestNew_Run(t *testing.T) { db.Rows = &database.MockRowsEmpty{} configuration := app.DefaultConfiguration() configuration.EnableCreate = true + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &New{} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index c7882a6..a0085f1 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -3,7 +3,7 @@ package controller import ( "net/http" - "github.com/jamiefdhurst/journal/internal/app" + internalApp "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/pkg/session" ) @@ -35,8 +35,26 @@ func (c *Super) Init(app interface{}, params []string, request *http.Request) { c.container = app c.host = request.Host c.params = params - c.sessionStore = session.NewDefaultStore("defaultdefaultdefaultdefault1234") - c.session, _ = c.sessionStore.Get(request) + + appContainer, ok := app.(*internalApp.Container) + if ok && appContainer != nil { + store, err := session.NewDefaultStore(appContainer.Configuration.SessionKey, session.CookieConfig{ + Name: appContainer.Configuration.SessionName, + Domain: appContainer.Configuration.CookieDomain, + MaxAge: appContainer.Configuration.CookieMaxAge, + Secure: appContainer.Configuration.CookieSecure, + HTTPOnly: appContainer.Configuration.CookieHTTPOnly, + }) + if err == nil { + c.sessionStore = store + } + } + + if c.sessionStore != nil { + c.session, _ = c.sessionStore.Get(request) + } else { + c.session = session.NewSession() + } c.trackVisit(request) } @@ -59,7 +77,9 @@ func (c *Super) Params() []string { // SaveSession saves the session with the current response func (c *Super) SaveSession(w http.ResponseWriter) { - c.sessionStore.Save(w) + if c.sessionStore != nil { + c.sessionStore.Save(w) + } } // Session gets the private session value @@ -76,7 +96,7 @@ func (c *Super) trackVisit(request *http.Request) { return } - appContainer, ok := c.container.(*app.Container) + appContainer, ok := c.container.(*internalApp.Container) if !ok || appContainer.Db == nil { return } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index adcd9dd..3a24069 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -4,20 +4,59 @@ import ( "net/http" "strings" "testing" + + "github.com/jamiefdhurst/journal/internal/app" ) type BlankInterface struct{} func TestInit(t *testing.T) { - container := BlankInterface{} - params := []string{ - "param1", "param2", "param3", "param4", - } - controller := Super{} - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - request.Host = "foobar.com" - controller.Init(container, params, request) - if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { - t.Error("Expected values were not passed into struct") - } + t.Run("Init with blank interface", func(t *testing.T) { + container := BlankInterface{} + params := []string{ + "param1", "param2", "param3", "param4", + } + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "foobar.com" + controller.Init(container, params, request) + if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { + t.Error("Expected values were not passed into struct") + } + }) + + t.Run("Init with app container and session config", func(t *testing.T) { + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + CookieDomain: "example.com", + CookieMaxAge: 3600, + CookieSecure: true, + CookieHTTPOnly: true, + }, + } + params := []string{"param1", "param2"} + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "test.com" + + controller.Init(container, params, request) + + if controller.Container() != container { + t.Error("Expected container to be set") + } + if controller.Host() != "test.com" { + t.Error("Expected host to be set") + } + if len(controller.Params()) != 2 { + t.Error("Expected params to be set") + } + if controller.sessionStore == nil { + t.Error("Expected session store to be initialized") + } + if controller.session == nil { + t.Error("Expected session to be initialized") + } + }) } diff --git a/pkg/session/store.go b/pkg/session/store.go index c679ac1..f9d9460 100644 --- a/pkg/session/store.go +++ b/pkg/session/store.go @@ -12,6 +12,7 @@ import ( "net/http" ) +// Store defines the interface for session storage implementations type Store interface { Get(r *http.Request) (*Session, error) Save(w http.ResponseWriter) error @@ -19,19 +20,50 @@ type Store interface { const defaultName string = "journal-session" +// CookieConfig defines the configuration for session cookies +type CookieConfig struct { + Name string + Domain string + MaxAge int + Secure bool + HTTPOnly bool +} + +// DefaultStore implements Store using encrypted cookies for session storage type DefaultStore struct { cachedSession *Session key []byte name string + config CookieConfig } -func NewDefaultStore(key string) *DefaultStore { - return &DefaultStore{ - key: []byte(key), - name: defaultName, +// NewDefaultStore creates a new DefaultStore with the given encryption key and cookie configuration. +// The key must be exactly 32 bytes (for AES-256) and contain only printable ASCII characters. +func NewDefaultStore(key string, config CookieConfig) (*DefaultStore, error) { + if len(key) != 32 { + return nil, errors.New("session key must be exactly 32 bytes") + } + + for i := 0; i < len(key); i++ { + if key[i] < 32 || key[i] > 126 { + return nil, errors.New("session key must contain only printable ASCII characters") + } + } + + name := config.Name + if name == "" { + name = defaultName } + + return &DefaultStore{ + key: []byte(key), + name: name, + config: config, + }, nil } +// Get retrieves the session from the request cookie, decrypting and deserializing it. +// If no session exists, a new empty session is created. func (s *DefaultStore) Get(r *http.Request) (*Session, error) { var err error if s.cachedSession == nil { @@ -50,6 +82,7 @@ func (s *DefaultStore) Get(r *http.Request) (*Session, error) { return s.cachedSession, err } +// Save encrypts and serializes the session, writing it to a cookie in the response. func (s *DefaultStore) Save(w http.ResponseWriter) error { encrypted, err := s.encrypt(s.cachedSession.Values) if err != nil { @@ -60,11 +93,11 @@ func (s *DefaultStore) Save(w http.ResponseWriter) error { Name: s.name, Value: encrypted, Path: "/", - Domain: "", - MaxAge: 86400 * 30, - Secure: false, + Domain: s.config.Domain, + MaxAge: s.config.MaxAge, + Secure: s.config.Secure, SameSite: http.SameSiteStrictMode, - HttpOnly: false, + HttpOnly: s.config.HTTPOnly, }) return nil diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go new file mode 100644 index 0000000..6c5c227 --- /dev/null +++ b/pkg/session/store_test.go @@ -0,0 +1,334 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewDefaultStore(t *testing.T) { + tests := []struct { + name string + key string + config CookieConfig + expectError bool + errorMsg string + }{ + { + name: "Valid 32-byte key", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "test-session", + Domain: "example.com", + MaxAge: 3600, + Secure: true, + HTTPOnly: true, + }, + expectError: false, + }, + { + name: "Key too short", + key: "tooshort", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Key too long", + key: "123456789012345678901234567890123", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Invalid characters in key", + key: "123456789012345678901234\x00\x01\x02\x03\x04\x05\x06\x07", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must contain only printable ASCII characters", + }, + { + name: "Default cookie name when empty", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "", + }, + expectError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(test.key, test.config) + + if test.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if err.Error() != test.errorMsg { + t.Errorf("Expected error %q, got %q", test.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + if store == nil { + t.Errorf("Expected store to be created but got nil") + } + if test.config.Name == "" && store.name != "journal-session" { + t.Errorf("Expected default name 'journal-session', got %q", store.name) + } + if test.config.Name != "" && store.name != test.config.Name { + t.Errorf("Expected name %q, got %q", test.config.Name, store.name) + } + } + }) + } +} + +func TestEncryptDecryptCycle(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + testData := map[string]interface{}{ + "user_id": "12345", + "name": "Test User", + "count": 42, + "active": true, + } + + encrypted, err := store.encrypt(testData) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + if encrypted == "" { + t.Errorf("Encrypted string should not be empty") + } + + var decrypted map[string]interface{} + err = store.decrypt(encrypted, &decrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + + if decrypted["user_id"] != testData["user_id"] { + t.Errorf("Expected user_id %v, got %v", testData["user_id"], decrypted["user_id"]) + } + if decrypted["name"] != testData["name"] { + t.Errorf("Expected name %v, got %v", testData["name"], decrypted["name"]) + } +} + +func TestCookieConfiguration(t *testing.T) { + tests := []struct { + name string + config CookieConfig + }{ + { + name: "Secure cookie with HTTPOnly", + config: CookieConfig{ + Name: "secure-session", + Domain: "example.com", + MaxAge: 7200, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Non-secure cookie without HTTPOnly", + config: CookieConfig{ + Name: "insecure-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: false, + }, + }, + { + name: "Custom domain cookie", + config: CookieConfig{ + Name: "domain-session", + Domain: "example.com", + MaxAge: 1800, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Long expiry cookie", + config: CookieConfig{ + Name: "long-session", + Domain: "", + MaxAge: 2592000, + Secure: false, + HTTPOnly: true, + }, + }, + } + + key := "12345678901234567890123456789012" + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(key, test.config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + session := NewSession() + session.Set("test", "value") + store.cachedSession = session + + w := httptest.NewRecorder() + err = store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + cookie := cookies[0] + + if cookie.Name != test.config.Name { + t.Errorf("Expected cookie name %q, got %q", test.config.Name, cookie.Name) + } + if cookie.Domain != test.config.Domain { + t.Errorf("Expected cookie domain %q, got %q", test.config.Domain, cookie.Domain) + } + if cookie.MaxAge != test.config.MaxAge { + t.Errorf("Expected cookie MaxAge %d, got %d", test.config.MaxAge, cookie.MaxAge) + } + if cookie.Secure != test.config.Secure { + t.Errorf("Expected cookie Secure %v, got %v", test.config.Secure, cookie.Secure) + } + if cookie.HttpOnly != test.config.HTTPOnly { + t.Errorf("Expected cookie HttpOnly %v, got %v", test.config.HTTPOnly, cookie.HttpOnly) + } + if cookie.Path != "/" { + t.Errorf("Expected cookie Path '/', got %q", cookie.Path) + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Errorf("Expected cookie SameSite Strict, got %v", cookie.SameSite) + } + }) + } +} + +func TestGetSession(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + t.Run("Get session without cookie", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + session, err := store.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if session == nil { + t.Errorf("Expected session to be created") + } + }) + + t.Run("Get session with valid cookie", func(t *testing.T) { + session := NewSession() + session.Set("user", "testuser") + store.cachedSession = session + + w := httptest.NewRecorder() + err := store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + newStore, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create new store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(cookies[0]) + + retrievedSession, err := newStore.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if retrievedSession == nil { + t.Fatalf("Expected session to be retrieved") + } + + user := retrievedSession.Get("user") + if user == nil { + t.Errorf("Expected 'user' key to exist in session") + } + if user != "testuser" { + t.Errorf("Expected user 'testuser', got %v", user) + } + }) +} + +func TestSessionCaching(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + session1, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + + session2, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session second time: %v", err) + } + + if session1 != session2 { + t.Errorf("Expected same session instance to be returned (cached)") + } +}