mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
1b49e133da
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
1126 lines
31 KiB
Go
1126 lines
31 KiB
Go
package storage
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/sessions"
|
|
)
|
|
|
|
// Mock logger for testing
|
|
type MockLogger struct {
|
|
logs []string
|
|
}
|
|
|
|
func (ml *MockLogger) Error(msg string) {
|
|
ml.logs = append(ml.logs, "ERROR: "+msg)
|
|
}
|
|
|
|
func (ml *MockLogger) Errorf(format string, args ...interface{}) {
|
|
ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+format, args...))
|
|
}
|
|
|
|
// Mock session manager for testing
|
|
type MockSessionManager struct {
|
|
logger Logger
|
|
}
|
|
|
|
func (msm *MockSessionManager) GetSessionOptions(isSecure bool) *sessions.Options {
|
|
return &sessions.Options{
|
|
Path: "/",
|
|
MaxAge: 3600,
|
|
Secure: isSecure,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
}
|
|
|
|
func (msm *MockSessionManager) EnhanceSessionSecurity(options *sessions.Options, r *http.Request) *sessions.Options {
|
|
if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil {
|
|
options.Secure = true
|
|
}
|
|
return options
|
|
}
|
|
|
|
func (msm *MockSessionManager) GetLogger() Logger {
|
|
return msm.logger
|
|
}
|
|
|
|
// TestNewSessionData tests session data creation
|
|
func TestNewSessionData(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
|
|
sd := NewSessionData(manager)
|
|
|
|
if sd == nil {
|
|
t.Fatal("NewSessionData should not return nil")
|
|
}
|
|
|
|
if sd.manager != manager {
|
|
t.Error("Manager should be set correctly")
|
|
}
|
|
|
|
if sd.accessTokenChunks == nil || len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Access token chunks map should be initialized and empty")
|
|
}
|
|
|
|
if sd.refreshTokenChunks == nil || len(sd.refreshTokenChunks) != 0 {
|
|
t.Error("Refresh token chunks map should be initialized and empty")
|
|
}
|
|
|
|
if sd.idTokenChunks == nil || len(sd.idTokenChunks) != 0 {
|
|
t.Error("ID token chunks map should be initialized and empty")
|
|
}
|
|
|
|
if sd.dirty {
|
|
t.Error("New session data should not be dirty")
|
|
}
|
|
|
|
if sd.inUse {
|
|
t.Error("New session data should not be in use")
|
|
}
|
|
}
|
|
|
|
// TestSessionDataDirtyFlag tests dirty flag management
|
|
func TestSessionDataDirtyFlag(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Test initial state
|
|
if sd.IsDirty() {
|
|
t.Error("New session should not be dirty")
|
|
}
|
|
|
|
// Test marking dirty
|
|
sd.MarkDirty()
|
|
if !sd.IsDirty() {
|
|
t.Error("Session should be dirty after MarkDirty()")
|
|
}
|
|
|
|
// Test that Save clears dirty flag (when successful)
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
// Create a simple main session to avoid nil session errors
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
session, _ := store.Get(req, "test-session")
|
|
sd.mainSession = session
|
|
|
|
err := sd.Save(req, w)
|
|
if err != nil {
|
|
t.Logf("Save returned error (may be expected): %v", err)
|
|
}
|
|
|
|
// Note: dirty flag is only cleared if Save is completely successful
|
|
// which might not happen with our mock setup
|
|
}
|
|
|
|
// TestSessionDataSave tests session saving functionality
|
|
func TestSessionDataSave(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
|
|
tests := []struct {
|
|
name string
|
|
setupSesion func(*SessionData)
|
|
expectError bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "Save with main session only",
|
|
setupSesion: func(sd *SessionData) {
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
session, _ := store.Get(req, "test-session")
|
|
sd.mainSession = session
|
|
},
|
|
expectError: true, // Will error because other sessions are nil
|
|
description: "Should handle nil subsidiary sessions",
|
|
},
|
|
{
|
|
name: "Save with all session types",
|
|
setupSesion: func(sd *SessionData) {
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
},
|
|
expectError: false,
|
|
description: "Should save all session types without error",
|
|
},
|
|
{
|
|
name: "Save with token chunks",
|
|
setupSesion: func(sd *SessionData) {
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
|
|
// Add some token chunks
|
|
chunk1, _ := store.Get(req, "access-chunk-0")
|
|
chunk2, _ := store.Get(req, "access-chunk-1")
|
|
sd.accessTokenChunks[0] = chunk1
|
|
sd.accessTokenChunks[1] = chunk2
|
|
|
|
refreshChunk, _ := store.Get(req, "refresh-chunk-0")
|
|
sd.refreshTokenChunks[0] = refreshChunk
|
|
},
|
|
expectError: false,
|
|
description: "Should save token chunks without error",
|
|
},
|
|
{
|
|
name: "Save with nil main session",
|
|
setupSesion: func(sd *SessionData) {
|
|
sd.mainSession = nil
|
|
},
|
|
expectError: true,
|
|
description: "Should handle nil main session gracefully",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sd := NewSessionData(manager)
|
|
tt.setupSesion(sd)
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
err := sd.Save(req, w)
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Errorf("Expected error for %s, got nil", tt.description)
|
|
} else if !tt.expectError && err != nil {
|
|
t.Errorf("Unexpected error for %s: %v", tt.description, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionDataSaveHTTPS tests HTTPS detection in Save
|
|
func TestSessionDataSaveHTTPS(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
|
|
tests := []struct {
|
|
name string
|
|
setupReq func() *http.Request
|
|
expectSecure bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "HTTPS via TLS",
|
|
setupReq: func() *http.Request {
|
|
req := httptest.NewRequest("GET", "https://example.com", nil)
|
|
// Simulate TLS connection
|
|
req.TLS = &tls.ConnectionState{}
|
|
return req
|
|
},
|
|
expectSecure: true,
|
|
description: "Should detect HTTPS via TLS",
|
|
},
|
|
{
|
|
name: "HTTPS via X-Forwarded-Proto header",
|
|
setupReq: func() *http.Request {
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
return req
|
|
},
|
|
expectSecure: true,
|
|
description: "Should detect HTTPS via X-Forwarded-Proto header",
|
|
},
|
|
{
|
|
name: "HTTP request",
|
|
setupReq: func() *http.Request {
|
|
return httptest.NewRequest("GET", "http://example.com", nil)
|
|
},
|
|
expectSecure: false,
|
|
description: "Should detect HTTP correctly",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := tt.setupReq()
|
|
w := httptest.NewRecorder()
|
|
|
|
session, _ := store.Get(req, "test-session")
|
|
sd.mainSession = session
|
|
// Set all other sessions to avoid nil session errors
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
|
|
err := sd.Save(req, w)
|
|
if err != nil {
|
|
t.Logf("Save returned error: %v", err)
|
|
}
|
|
|
|
// Check the session options were set correctly
|
|
if sd.mainSession.Options.Secure != tt.expectSecure {
|
|
t.Errorf("Expected Secure=%v for %s, got %v",
|
|
tt.expectSecure, tt.description, sd.mainSession.Options.Secure)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionDataChunkManagement tests token chunk management
|
|
func TestSessionDataChunkManagement(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Test adding chunks
|
|
chunk0, _ := store.Get(req, "access-chunk-0")
|
|
chunk1, _ := store.Get(req, "access-chunk-1")
|
|
chunk2, _ := store.Get(req, "access-chunk-2")
|
|
|
|
sd.accessTokenChunks[0] = chunk0
|
|
sd.accessTokenChunks[1] = chunk1
|
|
sd.accessTokenChunks[2] = chunk2
|
|
|
|
if len(sd.accessTokenChunks) != 3 {
|
|
t.Errorf("Expected 3 access token chunks, got %d", len(sd.accessTokenChunks))
|
|
}
|
|
|
|
// Test saving chunks
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
err := sd.Save(req, w)
|
|
if err != nil {
|
|
t.Logf("Save with chunks returned error: %v", err)
|
|
}
|
|
|
|
// Verify chunks have proper options set
|
|
for i, chunk := range sd.accessTokenChunks {
|
|
if chunk.Options == nil {
|
|
t.Errorf("Chunk %d should have options set", i)
|
|
} else if chunk.Options.HttpOnly != true {
|
|
t.Errorf("Chunk %d should have HttpOnly=true", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionDataErrorHandling tests error handling in Save
|
|
func TestSessionDataErrorHandling(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Test with nil sessions to trigger error paths
|
|
sd.mainSession = nil
|
|
sd.accessSession = nil
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
err := sd.Save(req, w)
|
|
|
|
// Should get an error for nil session
|
|
if err == nil {
|
|
t.Error("Expected error when saving nil sessions")
|
|
}
|
|
|
|
// Check that error was logged
|
|
if len(logger.logs) == 0 {
|
|
t.Error("Expected error to be logged")
|
|
}
|
|
|
|
// Check error message
|
|
foundNilSessionError := false
|
|
for _, log := range logger.logs {
|
|
if strings.Contains(log, "nil session") {
|
|
foundNilSessionError = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundNilSessionError {
|
|
t.Error("Expected nil session error to be logged")
|
|
}
|
|
}
|
|
|
|
// TestSessionDataConcurrency tests concurrent access to session data
|
|
func TestSessionDataConcurrency(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
|
|
// Test concurrent marking as dirty
|
|
done := make(chan bool, 2)
|
|
|
|
go func() {
|
|
for i := 0; i < 100; i++ {
|
|
sd.MarkDirty()
|
|
}
|
|
done <- true
|
|
}()
|
|
|
|
go func() {
|
|
for i := 0; i < 100; i++ {
|
|
_ = sd.IsDirty()
|
|
}
|
|
done <- true
|
|
}()
|
|
|
|
// Wait for both goroutines to complete
|
|
<-done
|
|
<-done
|
|
|
|
// Should not panic and dirty flag should be set
|
|
if !sd.IsDirty() {
|
|
t.Error("Expected session to be dirty after concurrent operations")
|
|
}
|
|
}
|
|
|
|
// TestSessionDataReset tests session data reset functionality
|
|
func TestSessionDataReset(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Set up session data with various values
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
|
|
// Add some chunks
|
|
chunk, _ := store.Get(req, "chunk-0")
|
|
sd.accessTokenChunks[0] = chunk
|
|
|
|
sd.MarkDirty()
|
|
sd.inUse = true
|
|
|
|
// Create a reset method if it exists in the actual implementation
|
|
// This is a placeholder test for reset functionality
|
|
t.Run("Manual reset", func(t *testing.T) {
|
|
// Simulate reset by clearing fields
|
|
sd.mainSession = nil
|
|
sd.accessSession = nil
|
|
sd.refreshSession = nil
|
|
sd.idTokenSession = nil
|
|
|
|
// Clear chunks
|
|
sd.accessTokenChunks = make(map[int]*sessions.Session)
|
|
sd.refreshTokenChunks = make(map[int]*sessions.Session)
|
|
sd.idTokenChunks = make(map[int]*sessions.Session)
|
|
|
|
sd.dirty = false
|
|
sd.inUse = false
|
|
|
|
// Verify reset
|
|
if sd.mainSession != nil {
|
|
t.Error("Main session should be nil after reset")
|
|
}
|
|
|
|
if len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Access token chunks should be empty after reset")
|
|
}
|
|
|
|
if sd.IsDirty() {
|
|
t.Error("Session should not be dirty after reset")
|
|
}
|
|
|
|
if sd.inUse {
|
|
t.Error("Session should not be in use after reset")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSessionDataValidation tests session data validation
|
|
func TestSessionDataValidation(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
|
|
tests := []struct {
|
|
name string
|
|
setupFunc func() *SessionData
|
|
expectValid bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "Valid session data",
|
|
setupFunc: func() *SessionData {
|
|
sd := NewSessionData(manager)
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
return sd
|
|
},
|
|
expectValid: true,
|
|
description: "Should validate correct session data",
|
|
},
|
|
{
|
|
name: "Invalid session data - nil manager",
|
|
setupFunc: func() *SessionData {
|
|
sd := &SessionData{
|
|
manager: nil,
|
|
accessTokenChunks: make(map[int]*sessions.Session),
|
|
refreshTokenChunks: make(map[int]*sessions.Session),
|
|
idTokenChunks: make(map[int]*sessions.Session),
|
|
}
|
|
return sd
|
|
},
|
|
expectValid: false,
|
|
description: "Should reject session data with nil manager",
|
|
},
|
|
{
|
|
name: "Invalid session data - nil chunks map",
|
|
setupFunc: func() *SessionData {
|
|
sd := NewSessionData(manager)
|
|
sd.accessTokenChunks = nil
|
|
return sd
|
|
},
|
|
expectValid: false,
|
|
description: "Should reject session data with nil chunks map",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sd := tt.setupFunc()
|
|
|
|
// Basic validation checks
|
|
isValid := true
|
|
|
|
if sd.manager == nil {
|
|
isValid = false
|
|
}
|
|
|
|
if sd.accessTokenChunks == nil || sd.refreshTokenChunks == nil || sd.idTokenChunks == nil {
|
|
isValid = false
|
|
}
|
|
|
|
if isValid != tt.expectValid {
|
|
t.Errorf("Validation mismatch for %s: expected valid=%v, got valid=%v",
|
|
tt.description, tt.expectValid, isValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// BenchmarkSessionDataSave benchmarks session save operations
|
|
func BenchmarkSessionDataSave(b *testing.B) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
|
|
b.ResetTimer()
|
|
|
|
for i := 0; i < b.N; i++ {
|
|
w := httptest.NewRecorder()
|
|
_ = sd.Save(req, w)
|
|
}
|
|
}
|
|
|
|
// TestClear tests complete session clearing
|
|
func TestClear(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
// Set up session data
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
|
|
// Add some chunks
|
|
chunk1, _ := store.Get(req, "access-chunk-0")
|
|
chunk2, _ := store.Get(req, "refresh-chunk-0")
|
|
chunk3, _ := store.Get(req, "id-chunk-0")
|
|
sd.accessTokenChunks[0] = chunk1
|
|
sd.refreshTokenChunks[0] = chunk2
|
|
sd.idTokenChunks[0] = chunk3
|
|
|
|
// Add some data to sessions
|
|
sd.mainSession.Values["user_id"] = "123"
|
|
sd.accessSession.Values["token"] = "access-token"
|
|
sd.refreshSession.Values["token"] = "refresh-token"
|
|
sd.idTokenSession.Values["token"] = "id-token"
|
|
|
|
sd.MarkDirty()
|
|
sd.SetInUse(true)
|
|
|
|
// Clear the session
|
|
err := sd.Clear(req, w)
|
|
if err != nil {
|
|
t.Logf("Clear returned error (may be expected): %v", err)
|
|
}
|
|
|
|
// Verify main session values are cleared
|
|
if sd.mainSession != nil && len(sd.mainSession.Values) > 0 {
|
|
t.Error("Main session values should be cleared")
|
|
}
|
|
|
|
// Verify session expires
|
|
if sd.mainSession != nil && sd.mainSession.Options.MaxAge != -1 {
|
|
t.Error("Main session should be expired (MaxAge = -1)")
|
|
}
|
|
|
|
// Verify chunks are cleared
|
|
if len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Access token chunks should be cleared")
|
|
}
|
|
if len(sd.refreshTokenChunks) != 0 {
|
|
t.Error("Refresh token chunks should be cleared")
|
|
}
|
|
if len(sd.idTokenChunks) != 0 {
|
|
t.Error("ID token chunks should be cleared")
|
|
}
|
|
|
|
// Verify request is cleared
|
|
if sd.request != nil {
|
|
t.Error("Request should be cleared")
|
|
}
|
|
|
|
// Verify usage status is reset
|
|
if sd.IsInUse() {
|
|
t.Error("Session should not be in use after clear")
|
|
}
|
|
}
|
|
|
|
// TestClearWithNilResponseWriter tests clearing with nil response writer
|
|
func TestClearWithNilResponseWriter(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.mainSession.Values["test"] = "value"
|
|
|
|
// Clear with nil response writer
|
|
err := sd.Clear(req, nil)
|
|
if err != nil {
|
|
t.Logf("Clear with nil writer returned error (expected): %v", err)
|
|
}
|
|
|
|
// Should still clear session data
|
|
if sd.mainSession != nil && len(sd.mainSession.Values) > 0 {
|
|
t.Error("Session values should be cleared even with nil writer")
|
|
}
|
|
}
|
|
|
|
// TestClearWithErrorTrigger tests error handling in Clear
|
|
func TestClearWithErrorTrigger(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
req.Header.Set("X-Test-Error", "true") // Trigger error condition
|
|
w := httptest.NewRecorder()
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
|
|
err := sd.Clear(req, w)
|
|
// May return error due to test trigger
|
|
t.Logf("Clear with error trigger returned: %v", err)
|
|
|
|
// Should still clear the data despite error
|
|
if sd.request != nil {
|
|
t.Error("Request should be cleared even after error")
|
|
}
|
|
}
|
|
|
|
// TestReset tests session reset functionality
|
|
func TestReset(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Set up session data
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
sd.accessSession, _ = store.Get(req, "access-session")
|
|
sd.refreshSession, _ = store.Get(req, "refresh-session")
|
|
sd.idTokenSession, _ = store.Get(req, "id-session")
|
|
sd.request = req
|
|
|
|
// Add chunks
|
|
chunk1, _ := store.Get(req, "access-chunk-0")
|
|
chunk2, _ := store.Get(req, "refresh-chunk-0")
|
|
chunk3, _ := store.Get(req, "id-chunk-0")
|
|
sd.accessTokenChunks[0] = chunk1
|
|
sd.refreshTokenChunks[0] = chunk2
|
|
sd.idTokenChunks[0] = chunk3
|
|
|
|
sd.MarkDirty()
|
|
sd.SetInUse(true)
|
|
|
|
// Reset the session
|
|
sd.Reset()
|
|
|
|
// Verify all sessions are nil
|
|
if sd.mainSession != nil {
|
|
t.Error("Main session should be nil after reset")
|
|
}
|
|
if sd.accessSession != nil {
|
|
t.Error("Access session should be nil after reset")
|
|
}
|
|
if sd.refreshSession != nil {
|
|
t.Error("Refresh session should be nil after reset")
|
|
}
|
|
if sd.idTokenSession != nil {
|
|
t.Error("ID token session should be nil after reset")
|
|
}
|
|
|
|
// Verify chunks are cleared
|
|
if len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Access token chunks should be empty after reset")
|
|
}
|
|
if len(sd.refreshTokenChunks) != 0 {
|
|
t.Error("Refresh token chunks should be empty after reset")
|
|
}
|
|
if len(sd.idTokenChunks) != 0 {
|
|
t.Error("ID token chunks should be empty after reset")
|
|
}
|
|
|
|
// Verify state is reset
|
|
if sd.IsDirty() {
|
|
t.Error("Session should not be dirty after reset")
|
|
}
|
|
if sd.IsInUse() {
|
|
t.Error("Session should not be in use after reset")
|
|
}
|
|
if sd.request != nil {
|
|
t.Error("Request should be nil after reset")
|
|
}
|
|
}
|
|
|
|
// TestSetSessions tests session setting
|
|
func TestSetSessions(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
main, _ := store.Get(req, "main")
|
|
access, _ := store.Get(req, "access")
|
|
refresh, _ := store.Get(req, "refresh")
|
|
idToken, _ := store.Get(req, "id")
|
|
|
|
// Set all sessions at once
|
|
sd.SetSessions(main, access, refresh, idToken)
|
|
|
|
// Verify sessions are set correctly
|
|
if sd.GetMainSession() != main {
|
|
t.Error("Main session not set correctly")
|
|
}
|
|
if sd.GetAccessSession() != access {
|
|
t.Error("Access session not set correctly")
|
|
}
|
|
if sd.GetRefreshSession() != refresh {
|
|
t.Error("Refresh session not set correctly")
|
|
}
|
|
if sd.GetIDTokenSession() != idToken {
|
|
t.Error("ID token session not set correctly")
|
|
}
|
|
}
|
|
|
|
// TestSetSessionsWithNil tests setting sessions with nil values
|
|
func TestSetSessionsWithNil(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Set sessions with nil values
|
|
sd.SetSessions(nil, nil, nil, nil)
|
|
|
|
// Verify sessions are nil
|
|
if sd.GetMainSession() != nil {
|
|
t.Error("Main session should be nil")
|
|
}
|
|
if sd.GetAccessSession() != nil {
|
|
t.Error("Access session should be nil")
|
|
}
|
|
if sd.GetRefreshSession() != nil {
|
|
t.Error("Refresh session should be nil")
|
|
}
|
|
if sd.GetIDTokenSession() != nil {
|
|
t.Error("ID token session should be nil")
|
|
}
|
|
}
|
|
|
|
// TestGetTokenChunks tests token chunk retrieval
|
|
func TestGetTokenChunks(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Add chunks to each map
|
|
accessChunk, _ := store.Get(req, "access-chunk-0")
|
|
refreshChunk, _ := store.Get(req, "refresh-chunk-0")
|
|
idChunk, _ := store.Get(req, "id-chunk-0")
|
|
|
|
sd.accessTokenChunks[0] = accessChunk
|
|
sd.refreshTokenChunks[0] = refreshChunk
|
|
sd.idTokenChunks[0] = idChunk
|
|
|
|
// Get chunks
|
|
access, refresh, id := sd.GetTokenChunks()
|
|
|
|
// Verify chunks are returned correctly
|
|
if len(access) != 1 || access[0] != accessChunk {
|
|
t.Error("Access token chunks not returned correctly")
|
|
}
|
|
if len(refresh) != 1 || refresh[0] != refreshChunk {
|
|
t.Error("Refresh token chunks not returned correctly")
|
|
}
|
|
if len(id) != 1 || id[0] != idChunk {
|
|
t.Error("ID token chunks not returned correctly")
|
|
}
|
|
}
|
|
|
|
// TestSetInUseAndIsInUse tests usage tracking
|
|
func TestSetInUseAndIsInUse(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Initially should not be in use
|
|
if sd.IsInUse() {
|
|
t.Error("New session should not be in use")
|
|
}
|
|
|
|
// Set in use
|
|
sd.SetInUse(true)
|
|
if !sd.IsInUse() {
|
|
t.Error("Session should be in use after SetInUse(true)")
|
|
}
|
|
|
|
// Set not in use
|
|
sd.SetInUse(false)
|
|
if sd.IsInUse() {
|
|
t.Error("Session should not be in use after SetInUse(false)")
|
|
}
|
|
}
|
|
|
|
// TestReturnToPoolSafely tests safe pool return
|
|
func TestReturnToPoolSafely(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
// Set session as in use
|
|
sd.SetInUse(true)
|
|
sd.MarkDirty()
|
|
|
|
// Set up some session data
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
sd.mainSession, _ = store.Get(req, "main")
|
|
sd.request = req
|
|
|
|
// Call returnToPoolSafely directly
|
|
sd.returnToPoolSafely()
|
|
|
|
// Verify session was reset and marked not in use
|
|
if sd.IsInUse() {
|
|
t.Error("Session should not be in use after pool return")
|
|
}
|
|
if sd.mainSession != nil {
|
|
t.Error("Session should be reset after pool return")
|
|
}
|
|
if sd.IsDirty() {
|
|
t.Error("Session should not be dirty after pool return")
|
|
}
|
|
}
|
|
|
|
// TestClearAllSessionData tests the internal clear function
|
|
func TestClearAllSessionData(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Set up session data with values
|
|
sd.mainSession, _ = store.Get(req, "main")
|
|
sd.accessSession, _ = store.Get(req, "access")
|
|
sd.refreshSession, _ = store.Get(req, "refresh")
|
|
sd.idTokenSession, _ = store.Get(req, "id")
|
|
|
|
// Add values to sessions
|
|
sd.mainSession.Values["user"] = "test"
|
|
sd.accessSession.Values["token"] = "access"
|
|
sd.refreshSession.Values["token"] = "refresh"
|
|
sd.idTokenSession.Values["token"] = "id"
|
|
|
|
// Add chunks
|
|
chunk1, _ := store.Get(req, "access-chunk-0")
|
|
chunk2, _ := store.Get(req, "refresh-chunk-0")
|
|
chunk3, _ := store.Get(req, "id-chunk-0")
|
|
sd.accessTokenChunks[0] = chunk1
|
|
sd.refreshTokenChunks[0] = chunk2
|
|
sd.idTokenChunks[0] = chunk3
|
|
|
|
// Test clearing with expire = true
|
|
sd.clearAllSessionData(req, true)
|
|
|
|
// Verify all sessions are cleared and expired
|
|
if sd.mainSession != nil && len(sd.mainSession.Values) != 0 {
|
|
t.Error("Main session values should be cleared")
|
|
}
|
|
if sd.mainSession != nil && sd.mainSession.Options.MaxAge != -1 {
|
|
t.Error("Main session should be expired")
|
|
}
|
|
|
|
// Verify chunks are cleared
|
|
if len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Access chunks should be cleared")
|
|
}
|
|
if len(sd.refreshTokenChunks) != 0 {
|
|
t.Error("Refresh chunks should be cleared")
|
|
}
|
|
if len(sd.idTokenChunks) != 0 {
|
|
t.Error("ID chunks should be cleared")
|
|
}
|
|
|
|
// Verify dirty flag is set when expiring
|
|
if !sd.IsDirty() {
|
|
t.Error("Session should be dirty after clearing with expire=true")
|
|
}
|
|
}
|
|
|
|
// TestClearAllSessionDataWithoutExpire tests clearing without expiring
|
|
func TestClearAllSessionDataWithoutExpire(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Set up session data
|
|
sd.mainSession, _ = store.Get(req, "main")
|
|
sd.mainSession.Values["user"] = "test"
|
|
|
|
// Add chunks
|
|
chunk1, _ := store.Get(req, "access-chunk-0")
|
|
sd.accessTokenChunks[0] = chunk1
|
|
|
|
// Clear without expiring
|
|
sd.clearAllSessionData(req, false)
|
|
|
|
// Verify values are cleared but not expired
|
|
if sd.mainSession != nil && len(sd.mainSession.Values) != 0 {
|
|
t.Error("Session values should be cleared")
|
|
}
|
|
if sd.mainSession != nil && sd.mainSession.Options.MaxAge == -1 {
|
|
t.Error("Session should not be expired when expire=false")
|
|
}
|
|
|
|
// Verify chunks are cleared
|
|
if len(sd.accessTokenChunks) != 0 {
|
|
t.Error("Chunks should be cleared")
|
|
}
|
|
|
|
// Verify dirty flag is not set when not expiring
|
|
if sd.IsDirty() {
|
|
t.Error("Session should not be dirty when expire=false")
|
|
}
|
|
}
|
|
|
|
// TestClearSessionValues tests the clearSessionValues helper
|
|
func TestClearSessionValues(t *testing.T) {
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
session, _ := store.Get(req, "test")
|
|
session.Values["key1"] = "value1"
|
|
session.Values["key2"] = "value2"
|
|
|
|
// Test clearing with expire
|
|
clearSessionValues(session, true)
|
|
|
|
if len(session.Values) != 0 {
|
|
t.Error("Session values should be cleared")
|
|
}
|
|
if session.Options.MaxAge != -1 {
|
|
t.Error("Session should be expired")
|
|
}
|
|
|
|
// Test clearing without expire
|
|
session.Values["key3"] = "value3"
|
|
session.Options.MaxAge = 3600 // Reset
|
|
|
|
clearSessionValues(session, false)
|
|
|
|
if len(session.Values) != 0 {
|
|
t.Error("Session values should be cleared")
|
|
}
|
|
if session.Options.MaxAge == -1 {
|
|
t.Error("Session should not be expired when expire=false")
|
|
}
|
|
|
|
// Test with nil session
|
|
clearSessionValues(nil, true)
|
|
// Should not panic
|
|
}
|
|
|
|
// TestClearTokenChunks tests token chunk clearing
|
|
func TestClearTokenChunks(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
// Create chunks with values
|
|
chunk1, _ := store.Get(req, "chunk-0")
|
|
chunk2, _ := store.Get(req, "chunk-1")
|
|
chunk1.Values["data"] = "test1"
|
|
chunk2.Values["data"] = "test2"
|
|
|
|
chunks := make(map[int]*sessions.Session)
|
|
chunks[0] = chunk1
|
|
chunks[1] = chunk2
|
|
|
|
// Clear chunks
|
|
sd.clearTokenChunks(req, chunks)
|
|
|
|
// Verify chunks are cleared and expired
|
|
if len(chunk1.Values) != 0 {
|
|
t.Error("Chunk 1 values should be cleared")
|
|
}
|
|
if chunk1.Options.MaxAge != -1 {
|
|
t.Error("Chunk 1 should be expired")
|
|
}
|
|
|
|
// Verify map is empty
|
|
if len(chunks) != 0 {
|
|
t.Error("Chunks map should be empty")
|
|
}
|
|
}
|
|
|
|
// TestClearTokenChunksWithNilChunk tests clearing with nil chunk
|
|
func TestClearTokenChunksWithNilChunk(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
chunks := make(map[int]*sessions.Session)
|
|
chunks[0] = nil // nil chunk
|
|
|
|
// Should not panic
|
|
sd.clearTokenChunks(req, chunks)
|
|
|
|
// Verify map is empty
|
|
if len(chunks) != 0 {
|
|
t.Error("Chunks map should be empty")
|
|
}
|
|
}
|
|
|
|
// TestSessionDataEdgeCases tests various edge cases
|
|
func TestSessionDataEdgeCases(t *testing.T) {
|
|
t.Run("Save with nil logger", func(t *testing.T) {
|
|
manager := &MockSessionManager{logger: nil}
|
|
sd := NewSessionData(manager)
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
// Should not panic with nil logger
|
|
err := sd.Save(req, w)
|
|
if err == nil {
|
|
t.Log("Save with nil logger succeeded (may be expected)")
|
|
}
|
|
})
|
|
|
|
t.Run("returnToPoolSafely with panic recovery", func(t *testing.T) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
sd.SetInUse(true)
|
|
|
|
// Should not panic
|
|
sd.returnToPoolSafely()
|
|
|
|
// Check if panic was logged (would require triggering actual panic)
|
|
t.Log("returnToPoolSafely completed without panic")
|
|
})
|
|
}
|
|
|
|
// BenchmarkSessionDataSaveWithChunks benchmarks session save with token chunks
|
|
func BenchmarkSessionDataSaveWithChunks(b *testing.B) {
|
|
logger := &MockLogger{}
|
|
manager := &MockSessionManager{logger: logger}
|
|
sd := NewSessionData(manager)
|
|
|
|
store := sessions.NewCookieStore([]byte("test-key-32-characters-long-1234"))
|
|
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
|
|
sd.mainSession, _ = store.Get(req, "main-session")
|
|
|
|
// Add multiple chunks
|
|
for i := 0; i < 5; i++ {
|
|
chunk, _ := store.Get(req, fmt.Sprintf("access-chunk-%d", i))
|
|
sd.accessTokenChunks[i] = chunk
|
|
|
|
refreshChunk, _ := store.Get(req, fmt.Sprintf("refresh-chunk-%d", i))
|
|
sd.refreshTokenChunks[i] = refreshChunk
|
|
}
|
|
|
|
b.ResetTimer()
|
|
|
|
for i := 0; i < b.N; i++ {
|
|
w := httptest.NewRecorder()
|
|
_ = sd.Save(req, w)
|
|
}
|
|
}
|