Files
skybridge/test/integration_test.go
2025-08-23 17:57:39 -04:00

676 lines
22 KiB
Go

package test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/handlers"
"github.com/kms/api-key-service/internal/repository"
"github.com/kms/api-key-service/internal/services"
)
// IntegrationTestSuite contains the test suite for end-to-end integration tests
type IntegrationTestSuite struct {
suite.Suite
server *httptest.Server
cfg config.ConfigProvider
db repository.DatabaseProvider
testUserID string
}
// SetupSuite runs once before all tests in the suite
func (suite *IntegrationTestSuite) SetupSuite() {
// Create test configuration - use the same database as the running services
suite.cfg = &TestConfig{
values: map[string]string{
"APP_ENV": "test",
"DB_HOST": "localhost",
"DB_PORT": "5432", // Use the mapped port from docker-compose
"DB_NAME": "kms",
"DB_USER": "postgres",
"DB_PASSWORD": "postgres",
"DB_SSLMODE": "disable",
"DB_MAX_OPEN_CONNS": "10",
"DB_MAX_IDLE_CONNS": "5",
"DB_CONN_MAX_LIFETIME": "5m",
"SERVER_HOST": "localhost",
"SERVER_PORT": "0", // Let the test server choose
"LOG_LEVEL": "debug",
"MIGRATION_PATH": "../migrations",
"INTERNAL_APP_ID": "internal.test-service",
"INTERNAL_HMAC_KEY": "test-hmac-key-for-integration-tests",
"AUTH_PROVIDER": "header",
"AUTH_HEADER_USER_EMAIL": "X-User-Email",
"JWT_SECRET": "test-jwt-secret-for-integration-tests",
"RATE_LIMIT_ENABLED": "false", // Disable for tests
"METRICS_ENABLED": "false",
},
}
suite.testUserID = "test-admin@example.com"
// Initialize mock database provider
suite.db = NewMockDatabaseProvider()
// Set up HTTP server with all handlers
suite.setupServer()
}
// TearDownSuite runs once after all tests in the suite
func (suite *IntegrationTestSuite) TearDownSuite() {
if suite.server != nil {
suite.server.Close()
}
if suite.db != nil {
suite.db.Close()
}
}
// SetupTest runs before each test
func (suite *IntegrationTestSuite) SetupTest() {
// Clean up test data before each test
suite.cleanupTestData()
}
func (suite *IntegrationTestSuite) setupServer() {
// Initialize mock repositories
appRepo := NewMockApplicationRepository()
tokenRepo := NewMockStaticTokenRepository()
permRepo := NewMockPermissionRepository()
grantRepo := NewMockGrantedPermissionRepository()
// Create a no-op logger for tests
logger := zap.NewNop()
// Initialize services
appService := services.NewApplicationService(appRepo, logger)
tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, suite.cfg.GetString("INTERNAL_HMAC_KEY"), suite.cfg, logger)
authService := services.NewAuthenticationService(suite.cfg, logger, permRepo)
// Initialize handlers
healthHandler := handlers.NewHealthHandler(suite.db, logger)
appHandler := handlers.NewApplicationHandler(appService, authService, logger)
tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger)
authHandler := handlers.NewAuthHandler(authService, tokenService, logger)
// Set up router using Gin with actual handlers
router := suite.setupRouter(healthHandler, appHandler, tokenHandler, authHandler)
// Create test server
suite.server = httptest.NewServer(router)
}
func (suite *IntegrationTestSuite) setupRouter(healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler) http.Handler {
// Use Gin for proper routing
gin.SetMode(gin.TestMode)
router := gin.New()
// Add authentication middleware
router.Use(suite.authMiddleware())
// Health endpoints
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
})
})
router.GET("/ready", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ready",
"timestamp": time.Now().Format(time.RFC3339),
})
})
// API routes
api := router.Group("/api")
{
// Auth endpoints (no auth middleware needed)
api.POST("/login", authHandler.Login)
api.POST("/verify", authHandler.Verify)
api.POST("/renew", authHandler.Renew)
// Protected endpoints
protected := api.Group("")
protected.Use(suite.requireAuth())
{
// Application endpoints
protected.GET("/applications", appHandler.List)
protected.POST("/applications", appHandler.Create)
protected.GET("/applications/:id", appHandler.GetByID)
protected.PUT("/applications/:id", appHandler.Update)
protected.DELETE("/applications/:id", appHandler.Delete)
// Token endpoints
protected.POST("/applications/:id/tokens", tokenHandler.Create)
}
}
return router
}
// authMiddleware adds user context from headers (for all routes)
func (suite *IntegrationTestSuite) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
userEmail := c.GetHeader(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"))
if userEmail != "" {
c.Set("user_id", userEmail)
}
c.Next()
}
}
// requireAuth middleware that requires authentication
func (suite *IntegrationTestSuite) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
c.Next()
}
}
func (suite *IntegrationTestSuite) withAuth(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userEmail := r.Header.Get(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"))
if userEmail == "" {
http.Error(w, `{"error":"Unauthorized","message":"Authentication required"}`, http.StatusUnauthorized)
return
}
// Add user to context (simplified)
r = r.WithContext(context.WithValue(r.Context(), "user_id", userEmail))
handler(w, r)
}
}
func (suite *IntegrationTestSuite) cleanupTestData() {
// For mock repositories, we don't need to clean up anything
// The repositories are recreated for each test
}
// TestHealthEndpoints tests the health check endpoints
func (suite *IntegrationTestSuite) TestHealthEndpoints() {
// Test health endpoint
resp, err := http.Get(suite.server.URL + "/health")
require.NoError(suite.T(), err)
defer resp.Body.Close()
assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
var healthResp map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&healthResp)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), "healthy", healthResp["status"])
assert.NotEmpty(suite.T(), healthResp["timestamp"])
}
// TestApplicationCRUD tests the complete CRUD operations for applications
func (suite *IntegrationTestSuite) TestApplicationCRUD() {
// Test data
testApp := domain.CreateApplicationRequest{
AppID: "com.test.integration-app",
AppLink: "https://test-integration.example.com",
Type: []domain.ApplicationType{domain.ApplicationTypeStatic, domain.ApplicationTypeUser},
CallbackURL: "https://test-integration.example.com/callback",
TokenRenewalDuration: domain.Duration{Duration: 7 * 24 * time.Hour}, // 7 days
MaxTokenDuration: domain.Duration{Duration: 30 * 24 * time.Hour}, // 30 days
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "Integration Test Team",
Owner: "test-integration@example.com",
},
}
// 1. Create Application
suite.T().Run("CreateApplication", func(t *testing.T) {
body, err := json.Marshal(testApp)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var createdApp domain.Application
err = json.NewDecoder(resp.Body).Decode(&createdApp)
require.NoError(t, err)
assert.Equal(t, testApp.AppID, createdApp.AppID)
assert.Equal(t, testApp.AppLink, createdApp.AppLink)
assert.Equal(t, testApp.Type, createdApp.Type)
assert.Equal(t, testApp.CallbackURL, createdApp.CallbackURL)
assert.NotEmpty(t, createdApp.HMACKey)
assert.Equal(t, testApp.Owner, createdApp.Owner)
assert.NotZero(t, createdApp.CreatedAt)
})
// 2. List Applications
suite.T().Run("ListApplications", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications", nil)
require.NoError(t, err)
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var listResp struct {
Data []domain.Application `json:"data"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Count int `json:"count"`
}
err = json.NewDecoder(resp.Body).Decode(&listResp)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(listResp.Data), 1)
// Find our test application
var foundApp *domain.Application
for _, app := range listResp.Data {
if app.AppID == testApp.AppID {
foundApp = &app
break
}
}
require.NotNil(t, foundApp, "Test application should be in the list")
assert.Equal(t, testApp.AppID, foundApp.AppID)
})
// 3. Get Specific Application
suite.T().Run("GetApplication", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications/"+testApp.AppID, nil)
require.NoError(t, err)
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var app domain.Application
err = json.NewDecoder(resp.Body).Decode(&app)
require.NoError(t, err)
assert.Equal(t, testApp.AppID, app.AppID)
assert.Equal(t, testApp.AppLink, app.AppLink)
})
}
// TestStaticTokenWorkflow tests the complete static token workflow
func (suite *IntegrationTestSuite) TestStaticTokenWorkflow() {
// First create an application
testApp := domain.CreateApplicationRequest{
AppID: "com.test.token-app",
AppLink: "https://test-token.example.com",
Type: []domain.ApplicationType{domain.ApplicationTypeStatic},
CallbackURL: "https://test-token.example.com/callback",
TokenRenewalDuration: domain.Duration{Duration: 7 * 24 * time.Hour},
MaxTokenDuration: domain.Duration{Duration: 30 * 24 * time.Hour},
Owner: domain.Owner{
Type: domain.OwnerTypeIndividual,
Name: "Token Test User",
Owner: "test-token@example.com",
},
}
// Create the application first
body, err := json.Marshal(testApp)
require.NoError(suite.T(), err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications", bytes.NewBuffer(body))
require.NoError(suite.T(), err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(suite.T(), err)
resp.Body.Close()
require.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
// 1. Create Static Token
var createdToken domain.CreateStaticTokenResponse
suite.T().Run("CreateStaticToken", func(t *testing.T) {
tokenReq := domain.CreateStaticTokenRequest{
AppID: testApp.AppID,
Owner: domain.Owner{
Type: domain.OwnerTypeIndividual,
Name: "API Client",
Owner: "test-api-client@example.com",
},
Permissions: []string{"repo.read", "repo.write"},
}
body, err := json.Marshal(tokenReq)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications/"+testApp.AppID+"/tokens", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
err = json.NewDecoder(resp.Body).Decode(&createdToken)
require.NoError(t, err)
assert.NotEmpty(t, createdToken.ID)
assert.NotEmpty(t, createdToken.Token)
assert.Equal(t, tokenReq.Permissions, createdToken.Permissions)
assert.NotZero(t, createdToken.CreatedAt)
})
// 2. Verify Token
suite.T().Run("VerifyStaticToken", func(t *testing.T) {
verifyReq := domain.VerifyRequest{
AppID: testApp.AppID,
Token: createdToken.Token,
Permissions: []string{"repo.read"},
}
body, err := json.Marshal(verifyReq)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/verify", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var verifyResp domain.VerifyResponse
err = json.NewDecoder(resp.Body).Decode(&verifyResp)
require.NoError(t, err)
assert.True(t, verifyResp.Valid)
assert.Equal(t, domain.TokenTypeStatic, verifyResp.TokenType)
// Verify that we get the actual permissions that were granted to the token
assert.Contains(t, verifyResp.Permissions, "repo.read")
assert.Contains(t, verifyResp.Permissions, "repo.write")
if verifyResp.PermissionResults != nil {
// Check that we get permission results for the requested permissions
assert.NotEmpty(t, verifyResp.PermissionResults)
// The token should have the "repo.read" permission we requested
assert.True(t, verifyResp.PermissionResults["repo.read"])
}
})
}
// TestUserTokenWorkflow tests the user token authentication flow
func (suite *IntegrationTestSuite) TestUserTokenWorkflow() {
// Create an application that supports user tokens
testApp := domain.CreateApplicationRequest{
AppID: "com.test.user-app",
AppLink: "https://test-user.example.com",
Type: []domain.ApplicationType{domain.ApplicationTypeUser},
CallbackURL: "https://test-user.example.com/callback",
TokenRenewalDuration: domain.Duration{Duration: 7 * 24 * time.Hour},
MaxTokenDuration: domain.Duration{Duration: 30 * 24 * time.Hour},
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "User Test Team",
Owner: "test-user-team@example.com",
},
}
// Create the application
body, err := json.Marshal(testApp)
require.NoError(suite.T(), err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications", bytes.NewBuffer(body))
require.NoError(suite.T(), err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(suite.T(), err)
resp.Body.Close()
require.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
// 1. User Login
suite.T().Run("UserLogin", func(t *testing.T) {
loginReq := domain.LoginRequest{
AppID: testApp.AppID,
Permissions: []string{"repo.read", "app.read"},
}
body, err := json.Marshal(loginReq)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/login", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), "test-user@example.com")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Debug: Print response body if not 200
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
t.Logf("Login failed with status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
// The response should contain either a token directly or a redirect URL
var responseBody map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&responseBody)
require.NoError(t, err)
// Check that we get some response (token, user_id, app_id, etc.)
assert.NotEmpty(t, responseBody)
// The current implementation returns a direct token response
if token, exists := responseBody["token"]; exists {
assert.NotEmpty(t, token)
}
if userID, exists := responseBody["user_id"]; exists {
assert.Equal(t, "test-user@example.com", userID)
}
if appID, exists := responseBody["app_id"]; exists {
assert.Equal(t, testApp.AppID, appID)
}
})
}
// TestAuthenticationMiddleware tests the authentication middleware
func (suite *IntegrationTestSuite) TestAuthenticationMiddleware() {
suite.T().Run("MissingAuthHeader", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
var errorResp map[string]string
err = json.NewDecoder(resp.Body).Decode(&errorResp)
require.NoError(t, err)
assert.Equal(t, "Unauthorized", errorResp["error"])
})
suite.T().Run("ValidAuthHeader", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications", nil)
require.NoError(t, err)
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// TestErrorHandling tests various error scenarios
func (suite *IntegrationTestSuite) TestErrorHandling() {
suite.T().Run("InvalidJSON", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications", bytes.NewBufferString("invalid json"))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
suite.T().Run("NonExistentApplication", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications/non-existent-app", nil)
require.NoError(t, err)
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestConcurrentRequests tests the service under concurrent load
func (suite *IntegrationTestSuite) TestConcurrentRequests() {
// Create a test application first
testApp := domain.CreateApplicationRequest{
AppID: "com.test.concurrent-app",
AppLink: "https://test-concurrent.example.com",
Type: []domain.ApplicationType{domain.ApplicationTypeStatic},
CallbackURL: "https://test-concurrent.example.com/callback",
TokenRenewalDuration: domain.Duration{Duration: 7 * 24 * time.Hour},
MaxTokenDuration: domain.Duration{Duration: 30 * 24 * time.Hour},
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "Concurrent Test Team",
Owner: "test-concurrent@example.com",
},
}
body, err := json.Marshal(testApp)
require.NoError(suite.T(), err)
req, err := http.NewRequest(http.MethodPost, suite.server.URL+"/api/applications", bytes.NewBuffer(body))
require.NoError(suite.T(), err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
require.NoError(suite.T(), err)
resp.Body.Close()
require.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
// Test concurrent requests
suite.T().Run("ConcurrentHealthChecks", func(t *testing.T) {
const numRequests = 50
results := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func() {
resp, err := http.Get(suite.server.URL + "/health")
if err != nil {
results <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
results <- assert.AnError
return
}
results <- nil
}()
}
// Collect results
for i := 0; i < numRequests; i++ {
err := <-results
assert.NoError(t, err)
}
})
suite.T().Run("ConcurrentApplicationListing", func(t *testing.T) {
const numRequests = 20
results := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func() {
req, err := http.NewRequest(http.MethodGet, suite.server.URL+"/api/applications", nil)
if err != nil {
results <- err
return
}
req.Header.Set(suite.cfg.GetString("AUTH_HEADER_USER_EMAIL"), suite.testUserID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
results <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
results <- assert.AnError
return
}
results <- nil
}()
}
// Collect results
for i := 0; i < numRequests; i++ {
err := <-results
assert.NoError(t, err)
}
})
}
// TestIntegrationSuite runs the integration test suite
func TestIntegrationSuite(t *testing.T) {
suite.Run(t, new(IntegrationTestSuite))
}