yao/openapi/openapi_test.go
Max 55931bb59b Add OAuth token revocation and introspection tests
- Implemented comprehensive tests for the OAuth token revocation and introspection endpoints, ensuring correct handling of valid, invalid, and missing token scenarios.
- Enhanced the oauthRevoke and oauthIntrospect methods to comply with RFC specifications, returning appropriate status codes and responses.
- Introduced a utility function for obtaining access tokens directly in tests, streamlining the testing process for OAuth endpoints.
- Improved error handling and logging for better debugging and verification during tests.
2025-07-21 17:57:34 +08:00

542 lines
19 KiB
Go

package openapi
import (
"context"
"fmt"
"net"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/test"
)
// testServer holds the test HTTP server instance
var testServer *http.Server
// Prepare initializes the OpenAPI test environment and starts a mock HTTP server.
//
// AI ASSISTANT INSTRUCTIONS:
// All tests in the openapi package MUST use these utility functions for proper test environment setup.
// This is a preparation utility function, NOT an actual test case.
//
// TESTING GUIDELINES FOR AI ASSISTANTS:
// 1. DO NOT modify configuration files (openapi.yao, app.yao, etc.) to make tests pass
// 2. DO NOT bypass validation or security checks to make tests pass
// 3. If tests fail, investigate the root cause - it may be a real program bug that needs fixing
// 4. Tests should verify actual functionality, not just pass assertions
// 5. Use realistic test data that represents real-world usage scenarios
// 6. When tests fail, check: environment setup, missing dependencies, configuration issues, actual code bugs
// 7. Fix the underlying issue in the code, not the test or configuration
//
// Usage pattern for ALL openapi tests:
//
// func TestYourFunction(t *testing.T) {
// serverURL := Prepare(t)
// defer Clean()
//
// // Get base URL from server config for correct path construction
// baseURL := ""
// if Server != nil && Server.Config != nil {
// baseURL = Server.Config.BaseURL
// }
//
// // Your actual test code here...
// // Use serverURL + baseURL + endpoint to make HTTP requests
// // Example: http.Get(serverURL + baseURL + "/helloworld/hello")
// // The OpenAPI server will be available as the global Server variable
// }
//
// PREREQUISITES:
// Before running any tests in this package, you MUST execute the following command in your terminal:
//
// source $YAO_SOURCE_ROOT/env.local.sh
//
// This loads the required environment variables for the test environment.
//
// WHAT THIS FUNCTION DOES:
// Step 1: Calls test.Prepare(t, config.Conf) to initialize the base Yao test environment
//
// This sets up database connections, configurations, and other core dependencies
//
// Step 2: Calls Load(config.Conf) to initialize the OpenAPI server instance
//
// This creates the global Server variable that contains the Gin router and all endpoints
//
// Step 3: Creates a Gin router and attaches the OpenAPI server to it
//
// The server uses Server.Config.BaseURL as the base path for all endpoints
//
// Step 4: Starts an HTTP server on a random available port (127.0.0.1:xxxxx)
//
// This allows actual HTTP testing of the OpenAPI endpoints
//
// RETURN VALUE:
// Returns the server URL in format "http://127.0.0.1:xxxxx" where xxxxx is the random port
// NOTE: You need to append Server.Config.BaseURL to construct the full endpoint URL
//
// ERROR HANDLING:
// If any step fails, the test will fail immediately with a descriptive error message.
func Prepare(t *testing.T) string {
// Step 1: Initialize base test environment with all Yao dependencies
test.Prepare(t, config.Conf)
// Step 2: Initialize OpenAPI server and make it available globally
_, err := Load(config.Conf)
if err != nil {
t.Fatalf("Failed to load OpenAPI server: %v", err)
}
// Step 3: Create Gin router and attach OpenAPI server
gin.SetMode(gin.TestMode)
router := gin.New()
// Attach the OpenAPI server to the router
if Server != nil {
Server.Attach(router)
}
// Step 4: Start HTTP server on random available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
testServer = &http.Server{
Handler: router,
}
// Start server in background
go func() {
if err := testServer.Serve(listener); err != nil && err != http.ErrServerClosed {
t.Errorf("Failed to start test server: %v", err)
}
}()
// Wait a moment for server to start
time.Sleep(10 * time.Millisecond)
// Return server URL
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
return serverURL
}
// Clean cleans up the OpenAPI test environment and shuts down the test server.
//
// AI ASSISTANT INSTRUCTIONS:
// This function MUST be called with defer in every test that uses Prepare().
// This is a cleanup utility function, NOT an actual test case.
// Always use: defer Clean()
//
// WHAT THIS FUNCTION DOES:
// Step 1: Gracefully shutdown the HTTP test server if it exists
//
// This ensures all pending requests are completed and resources are freed
//
// Step 2: Reset the global Server variable to nil
//
// This ensures no state leakage between tests and prevents memory leaks
//
// Step 3: Calls test.Clean() to clean up the base test environment
//
// This closes database connections, cleans up temporary files, and resets global state
//
// IMPORTANT NOTES:
// - This function should ALWAYS be called with defer to ensure cleanup happens even if tests panic
// - Proper cleanup prevents test interference and resource leaks
// - The order of cleanup steps is important: HTTP server first, then OpenAPI cleanup, then base cleanup
// - Server shutdown has a 5-second timeout to prevent hanging tests
func Clean() {
// Step 1: Gracefully shutdown the HTTP test server
if testServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := testServer.Shutdown(ctx); err != nil {
// Force close if graceful shutdown fails
testServer.Close()
}
testServer = nil
}
// Step 2: Reset OpenAPI server instance to prevent state leakage
Server = nil
// Step 3: Clean up base test environment and all dependencies
test.Clean()
}
// RegisterTestClient registers a test OAuth client and returns the client information.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function to create test OAuth clients for testing OAuth endpoints.
// This function provides realistic test clients that can be used for authentication flows.
// ALWAYS clean up test clients using CleanupTestClient() to prevent test interference.
//
// Usage pattern:
//
// func TestOAuthEndpoint(t *testing.T) {
// serverURL := Prepare(t)
// defer Clean()
//
// // Register a test client
// client := RegisterTestClient(t, "Test Client", []string{"http://localhost/callback"})
// defer CleanupTestClient(t, client.ClientID)
//
// // Use client.ClientID and client.ClientSecret in your tests
// // Example: test OAuth authorize with real client_id
// }
//
// PARAMETERS:
// - t: The test instance for error reporting
// - clientName: Human-readable name for the client (e.g., "Test Web App")
// - redirectURIs: List of valid redirect URIs for the client
//
// RETURN VALUE:
// Returns a pointer to types.ClientInfo containing:
// - ClientID: Generated unique client identifier
// - ClientSecret: Generated client secret (for confidential clients)
// - RedirectURIs: The provided redirect URIs
// - Other OAuth client metadata
//
// ERROR HANDLING:
// If client registration fails, the test will fail immediately with a descriptive error message.
func RegisterTestClient(t *testing.T, clientName string, redirectURIs []string) *types.ClientInfo {
if Server == nil || Server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
// Create dynamic client registration request
req := &types.DynamicClientRegistrationRequest{
ClientName: clientName,
RedirectURIs: redirectURIs,
GrantTypes: []string{
"authorization_code",
"refresh_token",
"client_credentials",
},
ResponseTypes: []string{
"code",
},
ApplicationType: "web",
TokenEndpointAuthMethod: "client_secret_basic",
Scope: "openid profile email",
}
// Register the client using the OAuth service
ctx := context.Background()
response, err := Server.OAuth.DynamicClientRegistration(ctx, req)
if err != nil {
t.Fatalf("Failed to register test client: %v", err)
}
// Convert response to ClientInfo for easier usage
clientInfo := &types.ClientInfo{
ClientID: response.ClientID,
ClientSecret: response.ClientSecret,
ClientName: response.ClientName,
RedirectURIs: response.RedirectURIs,
GrantTypes: response.GrantTypes,
ResponseTypes: response.ResponseTypes,
ApplicationType: response.ApplicationType,
TokenEndpointAuthMethod: response.TokenEndpointAuthMethod,
Scope: response.Scope,
ClientURI: response.ClientURI,
LogoURI: response.LogoURI,
TosURI: response.TosURI,
PolicyURI: response.PolicyURI,
Contacts: response.Contacts,
}
t.Logf("Registered test client: %s (ID: %s)", clientName, clientInfo.ClientID)
return clientInfo
}
// CleanupTestClient removes a test OAuth client from the system.
//
// AI ASSISTANT INSTRUCTIONS:
// ALWAYS call this function to clean up test clients created with RegisterTestClient().
// Use defer to ensure cleanup happens even if tests fail or panic.
// Proper cleanup prevents test interference and maintains a clean test environment.
//
// Usage pattern:
//
// client := RegisterTestClient(t, "Test Client", []string{"http://localhost/callback"})
// defer CleanupTestClient(t, client.ClientID)
//
// PARAMETERS:
// - t: The test instance for error reporting
// - clientID: The client ID to remove (obtained from RegisterTestClient return value)
//
// ERROR HANDLING:
// If client deletion fails, logs an error but does not fail the test.
// This prevents cleanup failures from affecting test results.
func CleanupTestClient(t *testing.T, clientID string) {
if Server == nil || Server.OAuth == nil {
// Server might already be cleaned up, which is OK
return
}
if clientID == "" {
return
}
// Delete the client using the OAuth service
ctx := context.Background()
err := Server.OAuth.DeleteClient(ctx, clientID)
if err != nil {
// Log error but don't fail the test - cleanup should be resilient
t.Logf("Warning: Failed to cleanup test client %s: %v", clientID, err)
} else {
t.Logf("Cleaned up test client: %s", clientID)
}
}
// CreateTestClientCredentials creates a simple test client with just ID and secret for basic testing.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function when you need a quick test client without full OAuth registration.
// This is useful for testing non-OAuth endpoints or when you need predictable client credentials.
// This creates an in-memory client that doesn't persist and doesn't need cleanup.
//
// Usage pattern:
//
// clientID, clientSecret := CreateTestClientCredentials()
// // Use in Basic Auth or client_credentials grant tests
//
// RETURN VALUES:
// - clientID: A predictable test client ID
// - clientSecret: A predictable test client secret
//
// NOTE: This function creates temporary credentials and doesn't register them with the OAuth service.
// For full OAuth flow testing, use RegisterTestClient() instead.
func CreateTestClientCredentials() (clientID, clientSecret string) {
return "test-client-id", "test-client-secret"
}
// ObtainAuthorizationCode dynamically obtains an authorization code for testing OAuth token endpoints.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function to get a real authorization code for testing OAuth token exchange.
// This function simulates the complete OAuth authorization flow and returns all necessary information
// for testing the token endpoint with realistic data.
//
// Usage pattern:
//
// func TestOAuthToken(t *testing.T) {
// serverURL := Prepare(t)
// defer Clean()
//
// // Register a test client
// client := RegisterTestClient(t, "Test Client", []string{"https://localhost/callback"})
// defer CleanupTestClient(t, client.ClientID)
//
// // Obtain authorization code dynamically
// authInfo := ObtainAuthorizationCode(t, serverURL, client.ClientID, "https://localhost/callback", "openid profile")
//
// // Now test token endpoint with real authorization code
// // POST to /oauth/token with grant_type=authorization_code&code=authInfo.Code&...
// }
//
// PARAMETERS:
// - t: The test instance for error reporting
// - serverURL: The test server URL (from Prepare function)
// - clientID: The OAuth client ID (from RegisterTestClient)
// - redirectURI: The redirect URI (must match client registration)
// - scope: The requested OAuth scope (e.g., "openid profile email")
//
// RETURN VALUE:
// Returns AuthorizationInfo struct containing:
// - Code: The authorization code for token exchange
// - State: The state parameter for CSRF protection
// - RedirectURI: The redirect URI used in the flow
// - ClientID: The client ID used in the flow
// - Scope: The scope requested in the flow
//
// WHAT THIS FUNCTION DOES:
// 1. Creates a realistic authorization request with proper parameters
// 2. Calls the OAuth service directly to simulate user authorization
// 3. Extracts the authorization code from the response
// 4. Returns all information needed for token endpoint testing
//
// ERROR HANDLING:
// If authorization fails, the test will fail immediately with a descriptive error message.
type AuthorizationInfo struct {
Code string
State string
RedirectURI string
ClientID string
Scope string
}
func ObtainAuthorizationCode(t *testing.T, serverURL, clientID, redirectURI, scope string) *AuthorizationInfo {
if Server == nil || Server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
// Generate a unique state parameter for CSRF protection
state := fmt.Sprintf("test-state-%d", time.Now().UnixNano())
// Create authorization request
authReq := &types.AuthorizationRequest{
ClientID: clientID,
ResponseType: "code",
RedirectURI: redirectURI,
Scope: scope,
State: state,
}
// Call OAuth service to process authorization request
ctx := context.Background()
authResp, err := Server.OAuth.Authorize(ctx, authReq)
if err != nil {
t.Fatalf("Failed to obtain authorization code: %v", err)
}
// Check if authorization response contains an error
if authResp.Error != "" {
t.Fatalf("Authorization failed: %s - %s", authResp.Error, authResp.ErrorDescription)
}
// Verify we got an authorization code
if authResp.Code == "" {
t.Fatal("Authorization response missing code")
}
authInfo := &AuthorizationInfo{
Code: authResp.Code,
State: authResp.State,
RedirectURI: redirectURI,
ClientID: clientID,
Scope: scope,
}
t.Logf("Obtained authorization code: %s (state: %s)", authInfo.Code, authInfo.State)
return authInfo
}
// ObtainAccessToken directly obtains an access token for testing OAuth endpoints that require authentication.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function to get a real access token for testing OAuth endpoints like introspect, revoke, etc.
// This function handles the complete OAuth flow (authorization + token exchange) and returns a ready-to-use token.
//
// Usage pattern:
//
// func TestOAuthIntrospect(t *testing.T) {
// serverURL := Prepare(t)
// defer Clean()
//
// // Register a test client
// client := RegisterTestClient(t, "Test Client", []string{"https://localhost/callback"})
// defer CleanupTestClient(t, client.ClientID)
//
// // Obtain access token directly
// tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
//
// // Now test introspect endpoint with real access token
// // POST to /oauth/introspect with token=tokenInfo.AccessToken
// }
//
// PARAMETERS:
// - t: The test instance for error reporting
// - serverURL: The test server URL (from Prepare function)
// - clientID: The OAuth client ID (from RegisterTestClient)
// - clientSecret: The OAuth client secret (from RegisterTestClient)
// - redirectURI: The redirect URI (must match client registration)
// - scope: The requested OAuth scope (e.g., "openid profile email")
//
// RETURN VALUE:
// Returns TokenInfo struct containing:
// - AccessToken: The access token for API calls
// - RefreshToken: The refresh token for token renewal
// - TokenType: The token type (usually "Bearer")
// - ExpiresIn: Token expiration time in seconds
// - Scope: The granted scope
// - ClientID: The client ID used to obtain the token
//
// WHAT THIS FUNCTION DOES:
// 1. Calls ObtainAuthorizationCode to get an authorization code
// 2. Exchanges the authorization code for an access token using the OAuth service
// 3. Returns all token information needed for authenticated API testing
//
// ERROR HANDLING:
// If token exchange fails, the test will fail immediately with a descriptive error message.
type TokenInfo struct {
AccessToken string
RefreshToken string
TokenType string
ExpiresIn int
Scope string
ClientID string
}
func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirectURI, scope string) *TokenInfo {
if Server == nil || Server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
// Step 1: Get authorization code
authInfo := ObtainAuthorizationCode(t, serverURL, clientID, redirectURI, scope)
// Step 2: Exchange authorization code for access token
ctx := context.Background()
token, err := Server.OAuth.Token(ctx, "authorization_code", authInfo.Code, clientID, "")
if err != nil {
t.Fatalf("Failed to exchange authorization code for token: %v", err)
}
// Verify we got a valid token
if token.AccessToken == "" {
t.Fatal("Token response missing access token")
}
tokenInfo := &TokenInfo{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresIn: token.ExpiresIn,
Scope: token.Scope,
ClientID: clientID,
}
t.Logf("Obtained access token: %s (type: %s, expires_in: %d)",
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn)
return tokenInfo
}
func TestLoad(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
assert.NotNil(t, Server)
assert.NotEmpty(t, serverURL)
assert.Contains(t, serverURL, "http://127.0.0.1:")
}
func TestObtainAccessToken(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
// Register a test client
client := RegisterTestClient(t, "Token Utility Test Client", []string{"https://localhost/callback"})
defer CleanupTestClient(t, client.ClientID)
// Test the ObtainAccessToken utility function
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile email")
// Verify token information
assert.NotEmpty(t, tokenInfo.AccessToken, "Access token should not be empty")
assert.NotEmpty(t, tokenInfo.RefreshToken, "Refresh token should not be empty")
assert.Equal(t, "Bearer", tokenInfo.TokenType, "Token type should be Bearer")
assert.Greater(t, tokenInfo.ExpiresIn, 0, "ExpiresIn should be greater than 0")
assert.Equal(t, client.ClientID, tokenInfo.ClientID, "Client ID should match")
// Note: Scope might be empty in token response, which is valid
t.Logf("Successfully obtained token: AccessToken=%s, TokenType=%s, ExpiresIn=%d, Scope=%s",
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn, tokenInfo.Scope)
}