You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

204 lines
6.7 KiB
Go

package tests
// integration_http_test.go contains tests for backend behavior.
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/require"
gconfig "knowfoolery/backend/services/gateway-service/internal/infra/config"
"knowfoolery/backend/services/gateway-service/internal/infra/proxy"
"knowfoolery/backend/services/gateway-service/internal/infra/routing"
httpapi "knowfoolery/backend/services/gateway-service/internal/interfaces/http"
"knowfoolery/backend/services/gateway-service/internal/interfaces/http/middleware"
"knowfoolery/backend/shared/infra/auth/zitadel"
)
// TestGateway_PublicRoute_ProxiesAndRewritesPath verifies expected behavior.
func TestGateway_PublicRoute_ProxiesAndRewritesPath(t *testing.T) {
t.Parallel()
var receivedPath string
var receivedQuery string
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
receivedPath = r.URL.Path
receivedQuery = r.URL.RawQuery
return jsonResponse(http.StatusOK, `{"ok":true}`), nil
})}
app := buildTestApp(t, "http://upstream.local", client)
req := httptest.NewRequest(http.MethodGet, "/api/v1/leaderboard/top10?window=7d", nil)
req.Header.Set("Origin", "http://localhost:5173")
res, err := app.Test(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "/leaderboard/top10", receivedPath)
require.Equal(t, "window=7d", receivedQuery)
require.Equal(t, "http://localhost:5173", res.Header.Get("Access-Control-Allow-Origin"))
require.Equal(t, "DENY", res.Header.Get("X-Frame-Options"))
require.Equal(t, "degraded", res.Header.Get("X-RateLimit-Policy"))
}
// TestGateway_ProtectedRoute_RequiresAuth verifies expected behavior.
func TestGateway_ProtectedRoute_RequiresAuth(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, `{"ok":true}`), nil
})}
app := buildTestApp(t, "http://upstream.local", client)
req := httptest.NewRequest(http.MethodGet, "/api/v1/leaderboard/players/player-1", nil)
res, err := app.Test(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
}
// TestGateway_ProtectedRoute_ForwardsUserHeaders verifies expected behavior.
func TestGateway_ProtectedRoute_ForwardsUserHeaders(t *testing.T) {
t.Parallel()
received := make(map[string]string)
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
received["x-user-id"] = r.Header.Get("X-User-ID")
received["x-user-email"] = r.Header.Get("X-User-Email")
received["x-user-roles"] = r.Header.Get("X-User-Roles")
received["x-user-mfa"] = r.Header.Get("X-User-MFA-Verified")
return jsonResponse(http.StatusOK, `{"ok":true}`), nil
})}
app := buildTestApp(t, "http://upstream.local", client)
req := httptest.NewRequest(http.MethodGet, "/api/v1/leaderboard/players/player-1", nil)
req.Header.Set("Authorization", "Bearer test-token")
res, err := app.Test(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "user-123", received["x-user-id"])
require.Equal(t, "player@example.com", received["x-user-email"])
require.Equal(t, "player", received["x-user-roles"])
require.Equal(t, "true", received["x-user-mfa"])
}
// TestGateway_PreflightCors verifies expected behavior.
func TestGateway_PreflightCors(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, `{"ok":true}`), nil
})}
app := buildTestApp(t, "http://upstream.local", client)
req := httptest.NewRequest(http.MethodOptions, "/api/v1/sessions/start", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "POST")
res, err := app.Test(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNoContent, res.StatusCode)
require.Equal(t, "http://localhost:5173", res.Header.Get("Access-Control-Allow-Origin"))
}
// buildTestApp is a test helper.
func buildTestApp(t *testing.T, upstreamURL string, client *http.Client) *fiber.App {
t.Helper()
app := fiber.New()
app.Use(middleware.RequestContext(nil))
app.Use(middleware.SecurityHeaders(gconfig.SecurityHeadersConfig{
ContentSecurityPolicy: "default-src 'self'",
EnableHSTS: false,
HSTSMaxAge: 31536000,
FrameOptions: "DENY",
ContentTypeOptions: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
PermissionsPolicy: "geolocation=()",
}))
app.Use(middleware.CORS(gconfig.CORSConfig{
AllowedOrigins: []string{"http://localhost:5173"},
AllowedMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowedHeaders: "Origin,Content-Type,Accept,Authorization",
AllowCredentials: true,
MaxAgeSeconds: 300,
}))
authMiddleware := func(c fiber.Ctx) error {
path := c.Path()
public := []string{
"/api/v1/questions",
"/api/v1/leaderboard/top10",
"/api/v1/leaderboard/stats",
"/api/v1/admin/auth",
"/api/v1/users/register",
"/api/v1/users/verify-email",
}
for _, p := range public {
if strings.HasPrefix(path, p) {
return c.Next()
}
}
if c.Get("Authorization") == "" {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Authorization header required"})
}
c.Locals(string(zitadel.ContextKeyUserID), "user-123")
c.Locals(string(zitadel.ContextKeyUserEmail), "player@example.com")
c.Locals(string(zitadel.ContextKeyUserRoles), []string{"player"})
c.Locals(string(zitadel.ContextKeyMFAVerified), true)
return c.Next()
}
httpapi.RegisterRoutes(app, httpapi.Options{
PublicPrefix: "/api/v1",
Upstreams: routing.Upstreams{
GameSession: upstreamURL,
QuestionBank: upstreamURL,
User: upstreamURL,
Leaderboard: upstreamURL,
Admin: upstreamURL,
},
Proxy: proxy.NewWithClient(client, nil),
AuthMiddleware: authMiddleware,
RateLimitMiddleware: middleware.RateLimitMiddleware(nil, gconfig.RateLimitConfig{
GeneralRequests: 100, AuthRequests: 5, APIRequests: 60, AdminRequests: 30, Window: time.Minute}, "/api/v1", nil),
})
return app
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
// RoundTrip is a test helper.
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
// jsonResponse is a test helper.
func jsonResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{
"Content-Type": []string{"application/json"},
},
Body: io.NopCloser(strings.NewReader(body)),
}
}