package e2e import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/middleware" ) // MiddlewareE2ETestSuite tests CORS, health, and auth middleware integration. // Unlike other E2E suites that rely on the shared E2ETestSuite (which uses SQLite + full server), // this suite constructs its own lightweight Gin router + httptest.Server to test middleware // behavior in isolation — no database, no real service dependencies. // // Reference: Chatwoot's rack-cors configuration and middleware integration specs. type MiddlewareE2ETestSuite struct { suite.Suite server *httptest.Server router *gin.Engine jwtCfg *config.JWTConfig } // SetupSuite builds a minimal Gin router with CORS + Auth middleware and a health endpoint. func (s *MiddlewareE2ETestSuite) SetupSuite() { // E2E tests require PostgreSQL; skip in SQLite test mode. s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode") s.jwtCfg = &config.JWTConfig{ Secret: "middleware-e2e-test-secret", ExpiryHours: 1, RefreshExpiryHours: 24, } r := gin.New() r.Use(gin.Recovery()) // Apply CORS middleware with production-like config corsCfg := middleware.CORSConfig{ AllowedOrigins: []string{"https://app.gochat.io", "https://admin.gochat.io", "*.gochat.dev"}, AllowCredentials: true, } r.Use(middleware.CORS(corsCfg)) // Health endpoint — mirrors the production healthCheck handler r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "service": "gochat", "version": config.Version, "uptimeSeconds": 0, }) }) // Protected API routes with AuthMiddleware authMiddleware := middleware.AuthMiddleware(s.jwtCfg) protected := r.Group("/api/v1") protected.Use(authMiddleware) { protected.GET("/me", func(c *gin.Context) { userID, _ := c.Get("user_id") accountID, _ := c.Get("account_id") role, _ := c.Get("role") c.JSON(200, gin.H{ "user_id": userID, "account_id": accountID, "role": role, }) }) } s.router = r s.server = httptest.NewServer(r) } // TearDownSuite shuts down the httptest server. func (s *MiddlewareE2ETestSuite) TearDownSuite() { if s.server != nil { s.server.Close() } } // ============================================================================ // CORS Tests // ============================================================================ func (s *MiddlewareE2ETestSuite) TestCORS_PreflightOPTIONS() { // CORS preflight (OPTIONS) should return 204 No Content with proper headers. req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://app.gochat.io") req.Header.Set("Access-Control-Request-Method", "POST") req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() // Preflight should return 204 No Content assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode, "CORS preflight should return 204 No Content") // Verify CORS response headers assert.Equal(s.T(), "https://app.gochat.io", resp.Header.Get("Access-Control-Allow-Origin"), "Allow-Origin should echo the request origin when it matches the whitelist") assert.Equal(s.T(), "true", resp.Header.Get("Access-Control-Allow-Credentials"), "Allow-Credentials should be 'true' when configured") assert.Contains(s.T(), resp.Header.Get("Access-Control-Allow-Methods"), "POST", "Allow-Methods should include POST") assert.Contains(s.T(), resp.Header.Get("Access-Control-Allow-Headers"), "Authorization", "Allow-Headers should include Authorization") assert.NotEmpty(s.T(), resp.Header.Get("Access-Control-Max-Age"), "Max-Age should be set (default 86400)") } func (s *MiddlewareE2ETestSuite) TestCORS_AllowedOriginExactMatch() { // Exact origin match should receive Allow-Origin = request origin. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://app.gochat.io") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "https://app.gochat.io", resp.Header.Get("Access-Control-Allow-Origin"), "Exact whitelisted origin should be echoed back") assert.Equal(s.T(), "Origin", resp.Header.Get("Vary"), "Vary header should be set to Origin for dynamic origin matching") } func (s *MiddlewareE2ETestSuite) TestCORS_AllowedOriginSecondWhitelistEntry() { // The second whitelist entry should also match. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://admin.gochat.io") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "https://admin.gochat.io", resp.Header.Get("Access-Control-Allow-Origin"), "Second whitelisted origin should be echoed back") } func (s *MiddlewareE2ETestSuite) TestCORS_WildcardSubdomainMatch() { // Wildcard pattern "*.gochat.dev" should match any subdomain. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://staging.gochat.dev") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "https://staging.gochat.dev", resp.Header.Get("Access-Control-Allow-Origin"), "Wildcard *.gochat.dev should match subdomain staging.gochat.dev") } func (s *MiddlewareE2ETestSuite) TestCORS_WildcardSubdomainDeepSubdomain() { // Wildcard pattern should match deep subdomains like foo.bar.gochat.dev. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://foo.bar.gochat.dev") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "https://foo.bar.gochat.dev", resp.Header.Get("Access-Control-Allow-Origin"), "Wildcard *.gochat.dev should match deep subdomain foo.bar.gochat.dev") } func (s *MiddlewareE2ETestSuite) TestCORS_DisallowedOrigin() { // Non-whitelisted origin should NOT receive Allow-Origin header. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://evil.example.com") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Request still succeeds, just no CORS headers") assert.Empty(s.T(), resp.Header.Get("Access-Control-Allow-Origin"), "Non-whitelisted origin should NOT get Allow-Origin header") } func (s *MiddlewareE2ETestSuite) TestCORS_NoOriginHeader() { // Request without Origin header should still succeed but no CORS Allow-Origin. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) // No Origin header set resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Empty(s.T(), resp.Header.Get("Access-Control-Allow-Origin"), "No Allow-Origin when request lacks Origin header") } func (s *MiddlewareE2ETestSuite) TestCORS_DevModeFallback() { // DevMode with empty AllowedOrigins should fall back to Allow-Origin: * devRouter := gin.New() devRouter.Use(gin.Recovery()) devRouter.Use(middleware.CORS(middleware.CORSConfig{DevMode: true})) devRouter.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) devServer := httptest.NewServer(devRouter) defer devServer.Close() req, err := http.NewRequest("GET", fmt.Sprintf("%s/test", devServer.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://any-origin.example.com") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "*", resp.Header.Get("Access-Control-Allow-Origin"), "DevMode with empty AllowedOrigins should fall back to Allow-Origin: *") } func (s *MiddlewareE2ETestSuite) TestCORS_PreflightDisallowedOrigin() { // OPTIONS preflight from non-whitelisted origin — should still return 204 (CORS spec) // but without Allow-Origin header for the requesting origin. req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://evil.example.com") req.Header.Set("Access-Control-Request-Method", "GET") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() // CORS middleware aborts OPTIONS with 204 regardless; the browser will check Allow-Origin assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode) assert.Empty(s.T(), resp.Header.Get("Access-Control-Allow-Origin"), "Disallowed origin preflight should not get Allow-Origin") } func (s *MiddlewareE2ETestSuite) TestCORS_ExposeHeaders() { // Response should include Access-Control-Expose-Headers. req, err := http.NewRequest("GET", fmt.Sprintf("%s/health", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://app.gochat.io") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Contains(s.T(), resp.Header.Get("Access-Control-Expose-Headers"), "Content-Length", "Expose-Headers should include Content-Length (default)") } // ============================================================================ // Health Endpoint Tests // ============================================================================ func (s *MiddlewareE2ETestSuite) TestHealthEndpoint_ReturnsOKStatus() { resp, err := http.Get(fmt.Sprintf("%s/health", s.server.URL)) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "/health should return 200 OK") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err, "Health response should be valid JSON") assert.Equal(s.T(), "ok", result["status"], "Health status should be 'ok'") assert.Equal(s.T(), "gochat", result["service"], "Health service should be 'gochat'") } func (s *MiddlewareE2ETestSuite) TestHealthEndpoint_ContainsVersion() { resp, err := http.Get(fmt.Sprintf("%s/health", s.server.URL)) s.Require().NoError(err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) // config.Version defaults to "dev" when not set via ldflags assert.NotNil(s.T(), result["version"], "Health should include version field") } func (s *MiddlewareE2ETestSuite) TestHealthEndpoint_ContentTypeJSON() { resp, err := http.Get(fmt.Sprintf("%s/health", s.server.URL)) s.Require().NoError(err) defer resp.Body.Close() contentType := resp.Header.Get("Content-Type") assert.Contains(s.T(), contentType, "application/json", "Health endpoint should return JSON content type") } // ============================================================================ // Auth Middleware Integration Tests // ============================================================================ func (s *MiddlewareE2ETestSuite) TestAuthMiddleware_RejectsNoAuthorizationHeader() { // Request to protected route without Authorization header → 401. req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) // No Authorization header resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Protected route should reject request without Authorization header") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) assert.Equal(s.T(), "authorization header required", result["error"], "Error message should indicate missing authorization header") } func (s *MiddlewareE2ETestSuite) TestAuthMiddleware_RejectsNonBearerToken() { // Request with Authorization header but without "Bearer" prefix → 401. req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // Basic auth, not Bearer resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Protected route should reject non-Bearer Authorization header") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) assert.Equal(s.T(), "bearer token required", result["error"], "Error message should indicate Bearer token is required") } func (s *MiddlewareE2ETestSuite) TestAuthMiddleware_RejectsInvalidToken() { // Request with a malformed/invalid JWT → 401. req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Authorization", "Bearer this-is-not-a-valid-jwt") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Protected route should reject invalid JWT token") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) assert.Equal(s.T(), "invalid token", result["error"], "Error message should indicate invalid token") } func (s *MiddlewareE2ETestSuite) TestAuthMiddleware_AcceptsValidToken() { // Request with a valid JWT → 200 and user context injected. token, err := middleware.GenerateToken(s.jwtCfg, 1, 100, "agent") s.Require().NoError(err, "GenerateToken should succeed") req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Protected route should accept valid JWT token") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) // JWT claims are stored as float64 in JSON numbers assert.Equal(s.T(), float64(1), result["user_id"], "user_id should be extracted from JWT claims") assert.Equal(s.T(), float64(100), result["account_id"], "account_id should be extracted from JWT claims") assert.Equal(s.T(), "agent", result["role"], "role should be extracted from JWT claims") } func (s *MiddlewareE2ETestSuite) TestAuthMiddleware_RejectsExpiredToken() { // Generate token with an already-expired config (ExpiryHours=0 → instant expiry). expiredCfg := &config.JWTConfig{ Secret: s.jwtCfg.Secret, ExpiryHours: -1, // Negative hours → already expired RefreshExpiryHours: 24, } token, err := middleware.GenerateToken(expiredCfg, 1, 100, "agent") s.Require().NoError(err) req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Expired JWT token should be rejected") } // ============================================================================ // CORS + Auth Middleware Combined Tests // ============================================================================ func (s *MiddlewareE2ETestSuite) TestCORSAndAuth_CORSHeadersOnAuthFailure() { // CORS headers should still be present even when Auth middleware rejects. req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://app.gochat.io") // No Authorization header → will be rejected by AuthMiddleware resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) // CORS middleware runs before Auth, so CORS headers should still be present assert.Equal(s.T(), "https://app.gochat.io", resp.Header.Get("Access-Control-Allow-Origin"), "CORS headers should be present even on 401 responses") } func (s *MiddlewareE2ETestSuite) TestCORSAndAuth_PreflightToProtectedRoute() { // Preflight OPTIONS to a protected route should succeed (CORS preflight is not // subject to Auth middleware — OPTIONS is intercepted by CORS middleware first). req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://admin.gochat.io") req.Header.Set("Access-Control-Request-Method", "GET") req.Header.Set("Access-Control-Request-Headers", "Authorization") resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode, "Preflight OPTIONS should bypass Auth middleware and return 204") assert.Equal(s.T(), "https://admin.gochat.io", resp.Header.Get("Access-Control-Allow-Origin")) assert.Contains(s.T(), resp.Header.Get("Access-Control-Allow-Headers"), "Authorization") } func (s *MiddlewareE2ETestSuite) TestCORSAndAuth_CORSHeadersOnAuthSuccess() { // CORS + Auth both work: valid token + whitelisted origin → full success. token, err := middleware.GenerateToken(s.jwtCfg, 42, 200, "admin") s.Require().NoError(err) req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://app.gochat.io") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Equal(s.T(), "https://app.gochat.io", resp.Header.Get("Access-Control-Allow-Origin"), "CORS headers should be present on successful authenticated responses") body, err := io.ReadAll(resp.Body) s.Require().NoError(err) var result map[string]interface{} err = json.Unmarshal(body, &result) s.Require().NoError(err) assert.Equal(s.T(), float64(42), result["user_id"]) assert.Equal(s.T(), float64(200), result["account_id"]) assert.Equal(s.T(), "admin", result["role"]) } func (s *MiddlewareE2ETestSuite) TestCORSAndAuth_DisallowedOriginBlocksEvenWithValidToken() { // Valid token but disallowed origin: response body succeeds but CORS headers are missing. // The browser will block the response due to missing Allow-Origin. token, err := middleware.GenerateToken(s.jwtCfg, 1, 100, "agent") s.Require().NoError(err) req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/me", s.server.URL), nil) s.Require().NoError(err) req.Header.Set("Origin", "https://evil.example.com") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) resp, err := http.DefaultClient.Do(req) s.Require().NoError(err) defer resp.Body.Close() // Server still returns 200 with data (CORS is browser-enforced, not server-enforced) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) assert.Empty(s.T(), resp.Header.Get("Access-Control-Allow-Origin"), "Disallowed origin should not receive Allow-Origin, browser will block") } // ============================================================================ // Suite Entry Point // ============================================================================ func TestMiddlewareE2ESuite(t *testing.T) { suite.Run(t, new(MiddlewareE2ETestSuite)) }