Files
gochat/tests/e2e/middleware_e2e_test.go
T
2026-06-04 15:44:48 +08:00

547 lines
20 KiB
Go

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))
}