package middleware import ( "encoding/base64" "log/slog" "net/http" "net/http/httptest" "os" "testing" "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/session" ) // testMiddleware creates a Middleware with minimal dependencies for testing. // It uses a real session.Session backed by an in-memory cookie store. func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) { t.Helper() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) cfg := &config.Config{ Environment: env, } // Create a real session manager with a known key key := make([]byte, 32) for i := range key { key[i] = byte(i) } store := sessions.NewCookieStore(key) store.Options = &sessions.Options{ Path: "/", MaxAge: 86400 * 7, HttpOnly: true, Secure: false, SameSite: http.SameSiteLaxMode, } sessManager := newTestSession(t, store, cfg, log) m := &Middleware{ log: log, params: &MiddlewareParams{ Config: cfg, }, session: sessManager, } return m, sessManager } // newTestSession creates a session.Session with a pre-configured cookie store // for testing. This avoids needing the fx lifecycle and database. func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *session.Session { t.Helper() return session.NewForTest(store, cfg, log) } // --- Logging Middleware Tests --- func TestLogging_SetsStatusCode(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) if _, err := w.Write([]byte("created")); err != nil { return } })) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code) assert.Equal(t, "created", w.Body.String()) } func TestLogging_DefaultStatusOK(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if _, err := w.Write([]byte("ok")); err != nil { return } })) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) // When no explicit WriteHeader is called, default is 200 assert.Equal(t, http.StatusOK, w.Code) } func TestLogging_PassesThroughToNext(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) var called bool handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/api/webhook", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.True(t, called, "logging middleware should call the next handler") } // --- LoggingResponseWriter Tests --- func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) { t.Parallel() w := httptest.NewRecorder() lrw := NewLoggingResponseWriter(w) // Default should be 200 assert.Equal(t, http.StatusOK, lrw.statusCode) // WriteHeader should capture the status code lrw.WriteHeader(http.StatusNotFound) assert.Equal(t, http.StatusNotFound, lrw.statusCode) // Underlying writer should also get the status code assert.Equal(t, http.StatusNotFound, w.Code) } func TestLoggingResponseWriter_WriteDelegatesToUnderlying(t *testing.T) { t.Parallel() w := httptest.NewRecorder() lrw := NewLoggingResponseWriter(w) n, err := lrw.Write([]byte("hello world")) require.NoError(t, err) assert.Equal(t, 11, n) assert.Equal(t, "hello world", w.Body.String()) } // --- CORS Middleware Tests --- func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) // Preflight request req := httptest.NewRequest(http.MethodOptions, "/api/test", nil) req.Header.Set("Origin", "http://localhost:3000") req.Header.Set("Access-Control-Request-Method", "POST") w := httptest.NewRecorder() handler.ServeHTTP(w, req) // In dev mode, CORS should allow any origin assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) } func TestCORS_ProdMode_NoOp(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentProd) var called bool handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/api/test", nil) req.Header.Set("Origin", "http://evil.com") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.True(t, called, "prod CORS middleware should pass through to handler") // In prod, no CORS headers should be set (no-op middleware) assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"), "prod mode should not set CORS headers") } // --- RequireAuth Middleware Tests --- func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) var called bool handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { called = true })) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.False(t, called, "handler should not be called for unauthenticated request") assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, "/pages/login", w.Header().Get("Location")) } func TestRequireAuth_AuthenticatedSession_PassesThrough(t *testing.T) { t.Parallel() m, sessManager := testMiddleware(t, config.EnvironmentDev) var called bool handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { called = true })) // Create an authenticated session by making a request, setting session data, // and saving the session cookie setupReq := httptest.NewRequest(http.MethodGet, "/setup", nil) setupW := httptest.NewRecorder() sess, err := sessManager.Get(setupReq) require.NoError(t, err) sessManager.SetUser(sess, "user-123", "testuser") require.NoError(t, sessManager.Save(setupReq, setupW, sess)) // Extract the cookie from the setup response cookies := setupW.Result().Cookies() require.NotEmpty(t, cookies, "session cookie should be set") // Make the actual request with the session cookie req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) for _, c := range cookies { req.AddCookie(c) } w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.True(t, called, "handler should be called for authenticated request") } func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(t *testing.T) { t.Parallel() m, sessManager := testMiddleware(t, config.EnvironmentDev) var called bool handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { called = true })) // Create a session but don't authenticate it setupReq := httptest.NewRequest(http.MethodGet, "/setup", nil) setupW := httptest.NewRecorder() sess, err := sessManager.Get(setupReq) require.NoError(t, err) // Don't call SetUser — session exists but is not authenticated require.NoError(t, sessManager.Save(setupReq, setupW, sess)) cookies := setupW.Result().Cookies() require.NotEmpty(t, cookies) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) for _, c := range cookies { req.AddCookie(c) } w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.False(t, called, "handler should not be called for unauthenticated session") assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, "/pages/login", w.Header().Get("Location")) } // --- Helper Tests --- func TestIpFromHostPort(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ {"ipv4 with port", "192.168.1.1:8080", "192.168.1.1"}, {"ipv6 with port", "[::1]:8080", "::1"}, {"invalid format", "not-a-host-port", ""}, {"empty string", "", ""}, {"localhost", "127.0.0.1:80", "127.0.0.1"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := ipFromHostPort(tt.input) assert.Equal(t, tt.expected, result) }) } } // --- MetricsAuth Tests --- func TestMetricsAuth_ValidCredentials(t *testing.T) { t.Parallel() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) cfg := &config.Config{ Environment: config.EnvironmentDev, MetricsUsername: "admin", MetricsPassword: "secret", } key := make([]byte, 32) store := sessions.NewCookieStore(key) store.Options = &sessions.Options{Path: "/", MaxAge: 86400} sessManager := session.NewForTest(store, cfg, log) m := &Middleware{ log: log, params: &MiddlewareParams{ Config: cfg, }, session: sessManager, } var called bool handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) req.SetBasicAuth("admin", "secret") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.True(t, called, "handler should be called with valid basic auth") assert.Equal(t, http.StatusOK, w.Code) } func TestMetricsAuth_InvalidCredentials(t *testing.T) { t.Parallel() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) cfg := &config.Config{ Environment: config.EnvironmentDev, MetricsUsername: "admin", MetricsPassword: "secret", } key := make([]byte, 32) store := sessions.NewCookieStore(key) store.Options = &sessions.Options{Path: "/", MaxAge: 86400} sessManager := session.NewForTest(store, cfg, log) m := &Middleware{ log: log, params: &MiddlewareParams{ Config: cfg, }, session: sessManager, } var called bool handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) req.SetBasicAuth("admin", "wrong-password") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.False(t, called, "handler should not be called with invalid basic auth") assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestMetricsAuth_NoCredentials(t *testing.T) { t.Parallel() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) cfg := &config.Config{ Environment: config.EnvironmentDev, MetricsUsername: "admin", MetricsPassword: "secret", } key := make([]byte, 32) store := sessions.NewCookieStore(key) store.Options = &sessions.Options{Path: "/", MaxAge: 86400} sessManager := session.NewForTest(store, cfg, log) m := &Middleware{ log: log, params: &MiddlewareParams{ Config: cfg, }, session: sessManager, } var called bool handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true })) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) // No basic auth header w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.False(t, called, "handler should not be called without credentials") assert.Equal(t, http.StatusUnauthorized, w.Code) } // --- CORS Dev Mode Detailed Tests --- func TestCORS_DevMode_AllowsMethods(t *testing.T) { t.Parallel() m, _ := testMiddleware(t, config.EnvironmentDev) handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) // Preflight for POST req := httptest.NewRequest(http.MethodOptions, "/api/webhooks", nil) req.Header.Set("Origin", "http://localhost:5173") req.Header.Set("Access-Control-Request-Method", "POST") w := httptest.NewRecorder() handler.ServeHTTP(w, req) allowMethods := w.Header().Get("Access-Control-Allow-Methods") assert.Contains(t, allowMethods, "POST") } // --- Base64 key validation for completeness --- func TestSessionKeyFormat(t *testing.T) { t.Parallel() // Verify that the session initialization correctly validates key format. // A proper 32-byte key encoded as base64 should work. key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } encoded := base64.StdEncoding.EncodeToString(key) decoded, err := base64.StdEncoding.DecodeString(encoded) require.NoError(t, err) assert.Len(t, decoded, 32) }