From 315618da47511a314d64ca0979e233e1a3b5023a Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 30 Jul 2025 16:49:28 +0800 Subject: [PATCH] Add Signin API and update file management endpoints - Introduced a comprehensive Signin API for user authentication, supporting multiple OAuth providers (Google, GitHub, Microsoft, Apple). - Updated the file management API endpoints to use a singular `/file` path instead of `/files`, enhancing consistency across the API. - Revised README documentation to include detailed descriptions of the new Signin API and updated file management endpoints. - Enhanced test cases to reflect the changes in endpoint structure and ensure robust coverage for the new Signin functionality. --- .github/workflows/pr-test.yml | 24 ++ .github/workflows/unit-test.yml | 25 ++ openapi/README.md | 180 ++++++++++++++- openapi/file/README.md | 54 ++--- openapi/file/file.go | 12 +- openapi/openapi.go | 12 +- openapi/signin/api.go | 42 ++++ openapi/signin/signin.go | 388 ++++++++++++++++++++++++++++++++ openapi/tests/file/file_test.go | 74 +++--- openapi/tests/signin_test.go | 201 +++++++++++++++++ 10 files changed, 932 insertions(+), 80 deletions(-) create mode 100644 openapi/signin/api.go create mode 100644 openapi/signin/signin.go create mode 100644 openapi/tests/signin_test.go diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index de6a3c19..5ae12500 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -80,6 +80,25 @@ env: S3_BUCKET: ${{ secrets.S3_BUCKET }} S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }} + # === Openapi Signin Configs === + ## Google + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + + ## Microsoft + MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }} + MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }} + + ## Apple + APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }} + APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8" + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + ## Github + GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }} + GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }} + jobs: UnitTest: runs-on: ubuntu-latest @@ -236,6 +255,11 @@ jobs: with: ref: ${{ env.HEAD }} + - name: Setup Apple Private Key + run: | + mkdir -p ../app/openapi/certs/apple + echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8 + - name: Start Redis uses: supercharge/redis-github-action@1.4.0 with: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 1cd20423..681f6719 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -84,6 +84,26 @@ env: S3_BUCKET: ${{ secrets.S3_BUCKET }} S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }} + + # === Openapi Signin Configs === + ## Google + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + + ## Microsoft + MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }} + MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }} + + ## Apple + APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }} + APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8" + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + ## Github + GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }} + GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }} + jobs: unit-test: runs-on: ubuntu-latest @@ -190,6 +210,11 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Setup Apple Private Key + run: | + mkdir -p ../app/openapi/certs/apple + echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8 + - name: Setup Go ${{ matrix.go }} uses: actions/setup-go@v5 with: diff --git a/openapi/README.md b/openapi/README.md index 838d19f3..6bd36976 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -322,6 +322,140 @@ openai.api_key = "your-oauth-token" All Chat endpoints require OAuth authentication. +## Signin API + +Comprehensive authentication API for user signin, configuration management, and OAuth integration with support for multiple authentication providers. + +The Signin API provides: + +- **Signin Configuration**: Get public signin configuration for different locales +- **Password Authentication**: Traditional username/password signin flow +- **OAuth Integration**: Third-party authentication provider callbacks +- **Multi-Locale Support**: Localized signin configurations and messages +- **Provider Management**: Support for multiple OAuth providers (Google, GitHub, etc.) + +**Key Endpoints:** + +- `GET /signin` - Get signin configuration for locale +- `POST /signin` - Authenticate with username/password +- `GET /signin/authback/{id}` - OAuth authentication callback handler + +**Configuration:** + +Signin configurations are defined in DSL files with multi-locale support: + +**[View Configuration Examples →](https://github.com/YaoApp/yao-dev-app/blob/main/openapi/signin.en.yao)** + +### Get Signin Configuration + +Retrieve public signin configuration for a specific locale: + +``` +GET /signin?locale={locale} +``` + +**Parameters:** + +- `locale` (optional): Language locale (e.g., "en", "zh-cn") + +**Example:** + +```bash +curl -X GET "/v1/signin?locale=en" \ + -H "Content-Type: application/json" +``` + +**Response:** + +```json +{ + "title": "Sign In", + "subtitle": "Welcome back", + "providers": [ + { + "id": "google", + "name": "Google", + "icon": "google", + "enabled": true + }, + { + "id": "github", + "name": "GitHub", + "icon": "github", + "enabled": true + } + ], + "password_enabled": true, + "register_enabled": true, + "forgot_password_enabled": true +} +``` + +### Password Signin + +Authenticate using username and password: + +``` +POST /signin +``` + +**Request Body:** + +```json +{ + "username": "user@example.com", + "password": "your_password", + "remember": true +} +``` + +**Response:** + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "def50200...", + "user": { + "id": "user123", + "email": "user@example.com", + "name": "John Doe" + } +} +``` + +### OAuth Authentication Callback + +Handle OAuth provider authentication callbacks: + +``` +GET /signin/authback/{provider_id} +``` + +**Parameters:** + +- `provider_id` (path): OAuth provider identifier (e.g., "google", "github") +- Standard OAuth parameters in query string (code, state, etc.) + +**Example:** + +``` +GET /signin/authback/google?code=auth_code&state=csrf_token +``` + +This endpoint processes the OAuth callback and returns authentication tokens or redirects to the configured success/error URLs. + +**Features:** + +- **Multi-Provider Support**: Google, GitHub, Microsoft, and custom OAuth providers +- **Locale Awareness**: Configuration adapts to user's preferred language +- **Security**: CSRF protection, secure token handling, and validation +- **Customizable UI**: Configurable signin forms and provider buttons +- **Session Management**: Automatic session creation and token management + +**Note:** Signin endpoints are publicly accessible for authentication purposes, but return OAuth tokens that must be used for subsequent API calls. + ## File Management API Comprehensive API for managing file uploads, downloads, and file operations with support for multiple storage backends. @@ -340,12 +474,12 @@ The File Management API provides: **Key Endpoints:** -- `POST /files/{uploaderID}` - Upload files (supports chunked upload) -- `GET /files/{uploaderID}` - List files with pagination and filters -- `GET /files/{uploaderID}/{fileID}` - Get file metadata -- `GET /files/{uploaderID}/{fileID}/content` - Download file content -- `GET /files/{uploaderID}/{fileID}/exists` - Check file existence -- `DELETE /files/{uploaderID}/{fileID}` - Delete file +- `POST /file/{uploaderID}` - Upload files (supports chunked upload) +- `GET /file/{uploaderID}` - List files with pagination and filters +- `GET /file/{uploaderID}/{fileID}` - Get file metadata +- `GET /file/{uploaderID}/{fileID}/content` - Download file content +- `GET /file/{uploaderID}/{fileID}/exists` - Check file existence +- `DELETE /file/{uploaderID}/{fileID}` - Delete file **Advanced Features:** @@ -519,12 +653,40 @@ curl -X GET "/v1/chat/completions?content=Help%20me%20create%20a%20user%20model& -H "Accept: text/event-stream" ``` +### User Authentication with Signin API + +1. **Get signin configuration**: + +```bash +curl -X GET "/v1/signin?locale=en" \ + -H "Content-Type: application/json" +``` + +2. **Authenticate with password**: + +```bash +curl -X POST "/v1/signin" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "user@example.com", + "password": "secure_password", + "remember": true + }' +``` + +3. **Use authentication token for API access**: + +```bash +curl -X GET "/v1/dsl/list/model" \ + -H "Authorization: Bearer {received_access_token}" +``` + ### File Upload and Management 1. **Upload a file with metadata**: ```bash -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {access_token}" \ -F "file=@document.pdf" \ -F "path=documents/reports/quarterly-report.pdf" \ @@ -536,14 +698,14 @@ curl -X POST "/v1/files/default" \ 2. **List and filter files**: ```bash -curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&page=1&page_size=10" \ +curl -X GET "/v1/file/default?status=completed&content_type=application/pdf&page=1&page_size=10" \ -H "Authorization: Bearer {access_token}" ``` 3. **Download file content** (with optimized delivery): ```bash -curl -X GET "/v1/files/default/{file_id}/content" \ +curl -X GET "/v1/file/default/{file_id}/content" \ -H "Authorization: Bearer {access_token}" \ --output downloaded-document.pdf ``` diff --git a/openapi/file/README.md b/openapi/file/README.md index c8df62b3..fddc9240 100644 --- a/openapi/file/README.md +++ b/openapi/file/README.md @@ -4,7 +4,7 @@ This document describes the RESTful API for managing file uploads, downloads, an ## Base URL -All endpoints are prefixed with the configured base URL followed by `/files` (e.g., `/v1/files`). +All endpoints are prefixed with the configured base URL followed by `/file` (e.g., `/v1/file`). ## Authentication @@ -28,7 +28,7 @@ The File Management API provides comprehensive file handling capabilities includ Upload files with support for chunked uploads, compression, and metadata. ``` -POST /files/{uploaderID} +POST /file/{uploaderID} ``` **Parameters:** @@ -57,7 +57,7 @@ POST /files/{uploaderID} ```bash # Simple file upload -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -F "file=@document.pdf" \ -F "path=documents/reports/quarterly-report.pdf" \ @@ -66,7 +66,7 @@ curl -X POST "/v1/files/default" \ -F "gzip=true" # Chunked upload (first chunk) -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -H "Content-Range: bytes 0-1023/2048" \ -H "Content-Sync: chunk-upload" \ @@ -95,7 +95,7 @@ curl -X POST "/v1/files/default" \ List files with pagination, filtering, and sorting capabilities. ``` -GET /files/{uploaderID}?page={page}&page_size={page_size}&status={status}&content_type={content_type}&name={name}&order_by={order_by}&select={select} +GET /file/{uploaderID}?page={page}&page_size={page_size}&status={status}&content_type={content_type}&name={name}&order_by={order_by}&select={select} ``` **Parameters:** @@ -116,15 +116,15 @@ GET /files/{uploaderID}?page={page}&page_size={page_size}&status={status}&conten ```bash # List files with pagination -curl -X GET "/v1/files/default?page=1&page_size=10" \ +curl -X GET "/v1/file/default?page=1&page_size=10" \ -H "Authorization: Bearer {token}" # List files with filters -curl -X GET "/v1/files/default?status=completed&content_type=image/jpeg&name=photo*" \ +curl -X GET "/v1/file/default?status=completed&content_type=image/jpeg&name=photo*" \ -H "Authorization: Bearer {token}" # List with custom ordering and field selection -curl -X GET "/v1/files/default?order_by=bytes desc&select=file_id,filename,bytes" \ +curl -X GET "/v1/file/default?order_by=bytes desc&select=file_id,filename,bytes" \ -H "Authorization: Bearer {token}" ``` @@ -157,7 +157,7 @@ curl -X GET "/v1/files/default?order_by=bytes desc&select=file_id,filename,bytes Get detailed metadata for a specific file. ``` -GET /files/{uploaderID}/{fileID} +GET /file/{uploaderID}/{fileID} ``` **Parameters:** @@ -168,7 +168,7 @@ GET /files/{uploaderID}/{fileID} **Example:** ```bash -curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ +curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd" \ -H "Authorization: Bearer {token}" ``` @@ -197,7 +197,7 @@ curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ Download the actual file content directly from storage. ``` -GET /files/{uploaderID}/{fileID}/content +GET /file/{uploaderID}/{fileID}/content ``` **Parameters:** @@ -208,7 +208,7 @@ GET /files/{uploaderID}/{fileID}/content **Example:** ```bash -curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/content" \ +curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd/content" \ -H "Authorization: Bearer {token}" \ --output downloaded-file.pdf ``` @@ -235,7 +235,7 @@ Content-Length: 2048576 Check if a file exists without downloading it. ``` -GET /files/{uploaderID}/{fileID}/exists +GET /file/{uploaderID}/{fileID}/exists ``` **Parameters:** @@ -246,7 +246,7 @@ GET /files/{uploaderID}/{fileID}/exists **Example:** ```bash -curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" \ +curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" \ -H "Authorization: Bearer {token}" ``` @@ -264,7 +264,7 @@ curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" Delete a file and its metadata. ``` -DELETE /files/{uploaderID}/{fileID} +DELETE /file/{uploaderID}/{fileID} ``` **Parameters:** @@ -275,7 +275,7 @@ DELETE /files/{uploaderID}/{fileID} **Example:** ```bash -curl -X DELETE "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ +curl -X DELETE "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd" \ -H "Authorization: Bearer {token}" ``` @@ -340,7 +340,7 @@ For large files, use chunked upload for better reliability: ```bash # Upload chunk 1 -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -H "Content-Range: bytes 0-1048575/3145728" \ -H "Content-Sync: chunk-upload" \ @@ -348,7 +348,7 @@ curl -X POST "/v1/files/default" \ -F "file=@chunk1.bin" # Upload chunk 2 -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -H "Content-Range: bytes 1048576-2097151/3145728" \ -H "Content-Sync: chunk-upload" \ @@ -356,7 +356,7 @@ curl -X POST "/v1/files/default" \ -F "file=@chunk2.bin" # Upload final chunk (triggers merge) -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -H "Content-Range: bytes 2097152-3145727/3145728" \ -H "Content-Sync: chunk-upload" \ @@ -397,7 +397,7 @@ All endpoints return standardized error responses: 1. **Upload a file:** ```bash -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -F "file=@document.pdf" \ -F "path=documents/important-doc.pdf" \ @@ -407,14 +407,14 @@ curl -X POST "/v1/files/default" \ 2. **List files to find the uploaded file:** ```bash -curl -X GET "/v1/files/default?name=important-doc*" \ +curl -X GET "/v1/file/default?name=important-doc*" \ -H "Authorization: Bearer {token}" ``` 3. **Download the file:** ```bash -curl -X GET "/v1/files/default/{file_id}/content" \ +curl -X GET "/v1/file/default/{file_id}/content" \ -H "Authorization: Bearer {token}" \ --output downloaded-document.pdf ``` @@ -439,7 +439,7 @@ for i in chunk_*; do START=$((CHUNK_SIZE * (${i#chunk_} - 1))) END=$((START + $(stat -c%s $i) - 1)) - curl -X POST "/v1/files/default" \ + curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -H "Content-Range: bytes ${START}-${END}/${TOTAL_SIZE}" \ -H "Content-Sync: chunk-upload" \ @@ -453,7 +453,7 @@ done 1. **Upload with comprehensive metadata:** ```bash -curl -X POST "/v1/files/default" \ +curl -X POST "/v1/file/default" \ -H "Authorization: Bearer {token}" \ -F "file=@report.pdf" \ -F "path=reports/2024/quarterly-report.pdf" \ @@ -466,21 +466,21 @@ curl -X POST "/v1/files/default" \ 2. **List files with filters:** ```bash -curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&order_by=created_at desc" \ +curl -X GET "/v1/file/default?status=completed&content_type=application/pdf&order_by=created_at desc" \ -H "Authorization: Bearer {token}" ``` 3. **Get detailed file information:** ```bash -curl -X GET "/v1/files/default/{file_id}" \ +curl -X GET "/v1/file/default/{file_id}" \ -H "Authorization: Bearer {token}" ``` 4. **Clean up old files:** ```bash -curl -X DELETE "/v1/files/default/{file_id}" \ +curl -X DELETE "/v1/file/default/{file_id}" \ -H "Authorization: Bearer {token}" ``` diff --git a/openapi/file/file.go b/openapi/file/file.go index af19e882..2e627ba9 100644 --- a/openapi/file/file.go +++ b/openapi/file/file.go @@ -20,22 +20,22 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.Use(oauth.Guard) // Upload a file (supports chunked upload) - group.POST("/files/:uploaderID", upload) + group.POST("/:uploaderID", upload) // List files - group.GET("/files/:uploaderID", list) + group.GET("/:uploaderID", list) // Retrieve file - group.GET("/files/:uploaderID/:fileID", retrieve) + group.GET("/:uploaderID/:fileID", retrieve) // Delete file - group.DELETE("/files/:uploaderID/:fileID", delete) + group.DELETE("/:uploaderID/:fileID", delete) // Retrieve file content - group.GET("/files/:uploaderID/:fileID/content", content) + group.GET("/:uploaderID/:fileID/content", content) // Check if file exists - group.GET("/files/:uploaderID/:fileID/exists", exists) + group.GET("/:uploaderID/:fileID/exists", exists) } // upload handles file upload diff --git a/openapi/openapi.go b/openapi/openapi.go index e455f839..f23d19ff 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -13,6 +13,7 @@ import ( "github.com/yaoapp/yao/openapi/kb" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/signin" ) // Server is the OpenAPI server @@ -52,6 +53,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) { return nil, err } + // Load signin configurations + err = signin.Load(appConfig) + if err != nil { + return nil, err + } + // Create the OpenAPI server Server = &OpenAPI{Config: &config, OAuth: oauthService} return Server, nil @@ -82,7 +89,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { dsl.Attach(group.Group("/dsl"), openapi.OAuth) // File handlers - file.Attach(group, openapi.OAuth) + file.Attach(group.Group("/file"), openapi.OAuth) // Knowledge Base handlers kb.Attach(group.Group("/kb"), openapi.OAuth) @@ -90,5 +97,8 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // Chat handlers chat.Attach(group.Group("/chat"), openapi.OAuth) + // Signin handlers + signin.Attach(group, openapi.OAuth) + // Custom handlers (Defined by developer) } diff --git a/openapi/signin/api.go b/openapi/signin/api.go new file mode 100644 index 00000000..d0e517d3 --- /dev/null +++ b/openapi/signin/api.go @@ -0,0 +1,42 @@ +package signin + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" +) + +// Attach attaches the signin handlers to the router +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + group.GET("/signin", getConfig) + group.POST("/signin", signin) + group.GET("/signin/authback/:id", authback) +} + +// getConfig is the handler for get signin configuration +func getConfig(c *gin.Context) { + // Get locale from query parameter (optional) + locale := c.Query("locale") + + // Get public configuration for the specified locale + config := GetPublicConfig(locale) + + // If no configuration found, return error + if config == nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "No signin configuration found for the requested locale", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Return the public configuration + response.RespondWithSuccess(c, response.StatusOK, config) +} + +// signin is the handler for signin (password login) +func signin(c *gin.Context) {} + +// authback is the handler for authback +func authback(c *gin.Context) {} diff --git a/openapi/signin/signin.go b/openapi/signin/signin.go new file mode 100644 index 00000000..6d77dc3e --- /dev/null +++ b/openapi/signin/signin.go @@ -0,0 +1,388 @@ +package signin + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/config" +) + +// Global variables to store loaded configurations +var ( + // Full configurations with sensitive data (for backend use) + fullConfigs = make(map[string]*Config) + // Public configurations without sensitive data (for frontend use) + publicConfigs = make(map[string]*Config) + // Default language code + defaultLang = "" + // Mutex for thread safety + configMutex sync.RWMutex +) + +// Config represents the signin page configuration +type Config struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + SuccessURL string `json:"success_url,omitempty"` + FailureURL string `json:"failure_url,omitempty"` + Form *FormConfig `json:"form,omitempty"` + Token *TokenConfig `json:"token,omitempty"` + ThirdParty *ThirdParty `json:"third_party,omitempty"` +} + +// FormConfig represents the form configuration +type FormConfig struct { + Username *UsernameConfig `json:"username,omitempty"` + Password *PasswordConfig `json:"password,omitempty"` + Captcha *CaptchaConfig `json:"captcha,omitempty"` + ForgotPasswordLink bool `json:"forgot_password_link,omitempty"` + RememberMe bool `json:"remember_me,omitempty"` + RegisterLink string `json:"register_link,omitempty"` + TermsOfServiceLink string `json:"terms_of_service_link,omitempty"` + PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"` +} + +// UsernameConfig represents the username field configuration +type UsernameConfig struct { + Placeholder string `json:"placeholder,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// PasswordConfig represents the password field configuration +type PasswordConfig struct { + Placeholder string `json:"placeholder,omitempty"` +} + +// CaptchaConfig represents the captcha configuration +type CaptchaConfig struct { + Type string `json:"type,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + +// TokenConfig represents the token configuration +type TokenConfig struct { + ExpiresIn string `json:"expires_in,omitempty"` + RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"` +} + +// ThirdParty represents the third party login configuration +type ThirdParty struct { + Register *RegisterConfig `json:"register,omitempty"` + Providers []*Provider `json:"providers,omitempty"` +} + +// RegisterConfig represents the auto register configuration +type RegisterConfig struct { + Auto bool `json:"auto,omitempty"` + Role string `json:"role,omitempty"` +} + +// Provider represents a third party login provider +type Provider struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Logo string `json:"logo,omitempty"` + Color string `json:"color,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Endpoints *Endpoints `json:"endpoints,omitempty"` + Mapping map[string]string `json:"mapping,omitempty"` +} + +// SecretGenerator represents the client secret generator configuration +type SecretGenerator struct { + Type string `json:"type,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Header map[string]interface{} `json:"header,omitempty"` + Payload map[string]interface{} `json:"payload,omitempty"` +} + +// Endpoints represents the OAuth endpoints +type Endpoints struct { + Authorization string `json:"authorization,omitempty"` + Token string `json:"token,omitempty"` + UserInfo string `json:"user_info,omitempty"` +} + +// Load loads all signin configurations from the openapi directory +func Load(appConfig config.Config) error { + configMutex.Lock() + defer configMutex.Unlock() + + // Clear existing configurations + fullConfigs = make(map[string]*Config) + publicConfigs = make(map[string]*Config) + defaultLang = "" + + // Find all signin configuration files + files, err := findSigninFiles() + if err != nil { + return fmt.Errorf("failed to find signin files: %v", err) + } + + // If no signin files found, that's not necessarily an error + // Some applications might not have signin configurations + if len(files) == 0 { + return nil + } + + // Load each configuration file + for _, file := range files { + lang := extractLanguageFromFilename(file) + + configPath := filepath.Join("openapi", file) + configRaw, err := application.App.Read(configPath) + if err != nil { + return fmt.Errorf("failed to read signin config %s: %v", file, err) + } + + // Parse the configuration + var signinConfig Config + err = application.Parse(configPath, configRaw, &signinConfig) + if err != nil { + return fmt.Errorf("failed to parse signin config %s: %v", file, err) + } + + // Process ENV variables in full config + fullConfig := signinConfig + processENVVariables(&fullConfig, appConfig.Root) + + // Create public config (without sensitive data) + publicConfig := createPublicConfig(&fullConfig) + + // Store configurations + fullConfigs[lang] = &fullConfig + publicConfigs[lang] = &publicConfig + + // Set default language + if defaultLang == "" || lang == "en" || file == "signin.yao" { + defaultLang = lang + } + } + + return nil +} + +// findSigninFiles finds all signin configuration files in the openapi directory +func findSigninFiles() ([]string, error) { + var files []string + signinFilePattern := regexp.MustCompile(`^signin(\.[a-z]{2}(-[a-z]{2})?)?\.yao$`) + + // Use Walk to find all signin files in the openapi directory + err := application.App.Walk("openapi", func(root, filename string, isdir bool) error { + if isdir { + return nil + } + + baseName := filepath.Base(filename) + if signinFilePattern.MatchString(baseName) { + files = append(files, baseName) + } + + return nil + }, "*.yao") + + if err != nil { + return nil, err + } + + return files, nil +} + +// extractLanguageFromFilename extracts language code from filename +func extractLanguageFromFilename(filename string) string { + // signin.yao -> "" + // signin.en.yao -> "en" + // signin.zh-cn.yao -> "zh-cn" + + if filename == "signin.yao" { + return "" + } + + parts := strings.Split(filename, ".") + if len(parts) >= 3 { + return parts[1] + } + + return "" +} + +// processENVVariables processes environment variables in the configuration +func processENVVariables(config *Config, rootPath string) { + // Process form captcha options + if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil { + for key, value := range config.Form.Captcha.Options { + if strValue, ok := value.(string); ok { + config.Form.Captcha.Options[key] = replaceENVVar(strValue) + } + } + } + + // Process third party providers + if config.ThirdParty != nil && config.ThirdParty.Providers != nil { + for _, provider := range config.ThirdParty.Providers { + provider.ClientID = replaceENVVar(provider.ClientID) + provider.ClientSecret = replaceENVVar(provider.ClientSecret) + + // Process client secret generator + if provider.ClientSecretGenerator != nil { + provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey) + + // Convert relative path to absolute path for private key + if provider.ClientSecretGenerator.PrivateKey != "" && !filepath.IsAbs(provider.ClientSecretGenerator.PrivateKey) { + provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey) + } + + // Process header values + if provider.ClientSecretGenerator.Header != nil { + for key, value := range provider.ClientSecretGenerator.Header { + if strValue, ok := value.(string); ok { + provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue) + } + } + } + + // Process payload values + if provider.ClientSecretGenerator.Payload != nil { + for key, value := range provider.ClientSecretGenerator.Payload { + if strValue, ok := value.(string); ok { + provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue) + } + } + } + } + } + } +} + +// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME +func replaceENVVar(value string) string { + if strings.HasPrefix(value, "$ENV.") { + envVar := strings.TrimPrefix(value, "$ENV.") + if envValue := os.Getenv(envVar); envValue != "" { + return envValue + } + } + return value +} + +// createPublicConfig creates a public version of the configuration without sensitive data +func createPublicConfig(fullConfig *Config) Config { + publicConfig := *fullConfig + + // Remove sensitive data from third party providers + if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil { + publicProviders := make([]*Provider, len(publicConfig.ThirdParty.Providers)) + for i, provider := range publicConfig.ThirdParty.Providers { + publicProvider := *provider + + // Remove sensitive fields + publicProvider.ClientSecret = "" + publicProvider.ClientSecretGenerator = nil + + publicProviders[i] = &publicProvider + } + publicConfig.ThirdParty.Providers = publicProviders + } + + return publicConfig +} + +// GetFullConfig returns the full configuration for a given language +func GetFullConfig(lang string) *Config { + configMutex.RLock() + defer configMutex.RUnlock() + + // Normalize language code to lowercase + if lang != "" { + lang = strings.ToLower(lang) + } + + // Try to get specific language config + if config, exists := fullConfigs[lang]; exists { + return config + } + + // Fallback to default language + if defaultLang != "" { + if config, exists := fullConfigs[defaultLang]; exists { + return config + } + } + + // Return any available config as last resort + for _, config := range fullConfigs { + return config + } + + return nil +} + +// GetPublicConfig returns the public configuration for a given language +func GetPublicConfig(lang string) *Config { + configMutex.RLock() + defer configMutex.RUnlock() + + // Normalize language code to lowercase + if lang != "" { + lang = strings.ToLower(lang) + } + + // Try to get specific language config + if config, exists := publicConfigs[lang]; exists { + return config + } + + // Fallback to default language + if defaultLang != "" { + if config, exists := publicConfigs[defaultLang]; exists { + return config + } + } + + // Return any available config as last resort + for _, config := range publicConfigs { + return config + } + + return nil +} + +// GetAvailableLanguages returns all available language codes +func GetAvailableLanguages() []string { + configMutex.RLock() + defer configMutex.RUnlock() + + var languages []string + for lang := range fullConfigs { + if lang != "" { + languages = append(languages, lang) + } + } + + // Add default language if it exists and is empty string + if defaultLang == "" && len(fullConfigs) > 0 { + languages = append(languages, "default") + } + + return languages +} + +// GetDefaultLanguage returns the default language code +func GetDefaultLanguage() string { + configMutex.RLock() + defer configMutex.RUnlock() + + if defaultLang == "" { + return "default" + } + return defaultLang +} diff --git a/openapi/tests/file/file_test.go b/openapi/tests/file/file_test.go index 9511b9fc..b625f98f 100644 --- a/openapi/tests/file/file_test.go +++ b/openapi/tests/file/file_test.go @@ -127,7 +127,7 @@ func TestFileUpload(t *testing.T) { t.Run("UploadFileSuccess", func(t *testing.T) { // Create multipart request - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, "path": "documents/reports/quarterly-report.txt", @@ -177,7 +177,7 @@ func TestFileUpload(t *testing.T) { t.Run("UploadFileWithCompression", func(t *testing.T) { // Test with gzip compression - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, "gzip": "true", @@ -205,7 +205,7 @@ func TestFileUpload(t *testing.T) { t.Run("UploadFileInvalidUploader", func(t *testing.T) { // Test with invalid uploader ID - requestURL := serverURL + baseURL + "/files/" + invalidUploaderID + requestURL := serverURL + baseURL + "/file/" + invalidUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) assert.NoError(t, err) @@ -228,7 +228,7 @@ func TestFileUpload(t *testing.T) { t.Run("UploadFileNoFile", func(t *testing.T) { // Test with no file in request - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := http.NewRequest("POST", requestURL, strings.NewReader("no file data")) assert.NoError(t, err) @@ -252,7 +252,7 @@ func TestFileUpload(t *testing.T) { t.Run("UploadFileMissingUploaderID", func(t *testing.T) { // Test with missing uploader ID in path - requestURL := serverURL + baseURL + "/files/" + requestURL := serverURL + baseURL + "/file/" req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) assert.NoError(t, err) @@ -287,7 +287,7 @@ func TestFileChunkedUpload(t *testing.T) { tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") t.Run("ChunkedUploadSuccess", func(t *testing.T) { - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID uid := fmt.Sprintf("chunked-test-%d", time.Now().UnixNano()) // Split content into chunks @@ -353,7 +353,7 @@ func TestFileList(t *testing.T) { fileName := fmt.Sprintf("test-file-%d.txt", i) content := fmt.Sprintf("Test content for file %d", i) - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", fileName, []byte(content), map[string]string{ "original_filename": fileName, }) @@ -378,7 +378,7 @@ func TestFileList(t *testing.T) { t.Run("ListFilesSuccess", func(t *testing.T) { // Test basic file listing - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID, nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID, nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -405,7 +405,7 @@ func TestFileList(t *testing.T) { t.Run("ListFilesWithPagination", func(t *testing.T) { // Test with pagination parameters - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?page=1&page_size=2", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?page=1&page_size=2", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -427,7 +427,7 @@ func TestFileList(t *testing.T) { t.Run("ListFilesWithFilters", func(t *testing.T) { // Test with filter parameters - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?status=uploaded&content_type=text/plain", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?status=uploaded&content_type=text/plain", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -442,7 +442,7 @@ func TestFileList(t *testing.T) { t.Run("ListFilesInvalidUploader", func(t *testing.T) { // Test with invalid uploader ID - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+invalidUploaderID, nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+invalidUploaderID, nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -476,7 +476,7 @@ func TestFileRetrieve(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) { // Upload a file first - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, }) @@ -499,7 +499,7 @@ func TestFileRetrieve(t *testing.T) { t.Run("RetrieveFileSuccess", func(t *testing.T) { // Retrieve file metadata encodedFileID := url.QueryEscape(testFileID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -528,7 +528,7 @@ func TestFileRetrieve(t *testing.T) { // Test with non-existent file ID nonExistentID := "non-existent-file-id" encodedFileID := url.QueryEscape(nonExistentID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -550,7 +550,7 @@ func TestFileRetrieve(t *testing.T) { t.Run("RetrieveFileMissingIDs", func(t *testing.T) { // Test with missing file ID - this URL actually matches the list endpoint // which is correct RESTful behavior, so we expect 200 OK - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -594,7 +594,7 @@ func TestFileContent(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) { // Upload a file first - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, }) @@ -617,7 +617,7 @@ func TestFileContent(t *testing.T) { t.Run("GetFileContentSuccess", func(t *testing.T) { // Get file content encodedFileID := url.QueryEscape(testFileID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -641,7 +641,7 @@ func TestFileContent(t *testing.T) { // Test with non-existent file ID nonExistentID := "non-existent-file-id" encodedFileID := url.QueryEscape(nonExistentID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -676,7 +676,7 @@ func TestFileExists(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) { // Upload a file first - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, }) @@ -699,7 +699,7 @@ func TestFileExists(t *testing.T) { t.Run("FileExistsTrue", func(t *testing.T) { // Check if uploaded file exists encodedFileID := url.QueryEscape(testFileID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -726,7 +726,7 @@ func TestFileExists(t *testing.T) { // Check if non-existent file exists nonExistentID := "non-existent-file-id" encodedFileID := url.QueryEscape(nonExistentID) - req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -768,7 +768,7 @@ func TestFileDelete(t *testing.T) { t.Run("DeleteFileSuccess", func(t *testing.T) { // Upload a file first - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, }) @@ -788,7 +788,7 @@ func TestFileDelete(t *testing.T) { // Now delete the file encodedFileID := url.QueryEscape(testFileID) - deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -814,7 +814,7 @@ func TestFileDelete(t *testing.T) { // Test deleting non-existent file nonExistentID := "non-existent-file-id" encodedFileID := url.QueryEscape(nonExistentID) - req, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + req, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -850,12 +850,12 @@ func TestFileEndpointsUnauthorized(t *testing.T) { method string path string }{ - {"POST", "/files/" + testUploaderID}, - {"GET", "/files/" + testUploaderID}, - {"GET", "/files/" + testUploaderID + "/test-file-id"}, - {"DELETE", "/files/" + testUploaderID + "/test-file-id"}, - {"GET", "/files/" + testUploaderID + "/test-file-id/content"}, - {"GET", "/files/" + testUploaderID + "/test-file-id/exists"}, + {"POST", "/file/" + testUploaderID}, + {"GET", "/file/" + testUploaderID}, + {"GET", "/file/" + testUploaderID + "/test-file-id"}, + {"DELETE", "/file/" + testUploaderID + "/test-file-id"}, + {"GET", "/file/" + testUploaderID + "/test-file-id/content"}, + {"GET", "/file/" + testUploaderID + "/test-file-id/exists"}, } for _, endpoint := range endpoints { @@ -908,7 +908,7 @@ func TestFileIntegration(t *testing.T) { t.Run("FullFileLifecycle", func(t *testing.T) { // Step 1: Upload a file - requestURL := serverURL + baseURL + "/files/" + testUploaderID + requestURL := serverURL + baseURL + "/file/" + testUploaderID uploadReq, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ "original_filename": testFileName, "path": "integration/test/file.txt", @@ -931,7 +931,7 @@ func TestFileIntegration(t *testing.T) { // Step 2: Verify file exists encodedFileID := url.QueryEscape(testFileID) - existsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + existsReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil) assert.NoError(t, err) existsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -942,7 +942,7 @@ func TestFileIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, existsResp.StatusCode) // Step 3: Retrieve file metadata - retrieveReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + retrieveReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) retrieveReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -953,7 +953,7 @@ func TestFileIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, retrieveResp.StatusCode) // Step 4: Download file content - contentReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + contentReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil) assert.NoError(t, err) contentReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -968,7 +968,7 @@ func TestFileIntegration(t *testing.T) { assert.Equal(t, testFileContent, string(content)) // Step 5: List files and verify our file is included - listReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?name="+testFileName, nil) + listReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?name="+testFileName, nil) assert.NoError(t, err) listReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -979,7 +979,7 @@ func TestFileIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, listResp.StatusCode) // Step 6: Delete the file - deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil) assert.NoError(t, err) deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) @@ -990,7 +990,7 @@ func TestFileIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, deleteResp.StatusCode) // Step 7: Verify file no longer exists - finalExistsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + finalExistsReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil) assert.NoError(t, err) finalExistsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) diff --git a/openapi/tests/signin_test.go b/openapi/tests/signin_test.go new file mode 100644 index 00000000..395b3183 --- /dev/null +++ b/openapi/tests/signin_test.go @@ -0,0 +1,201 @@ +package openapi_test + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/signin" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +func TestSigninLoad(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Test loading signin configurations + err := signin.Load(config.Conf) + assert.NoError(t, err, "signin.Load should succeed") + + // Test that we can get available languages + languages := signin.GetAvailableLanguages() + assert.IsType(t, []string{}, languages, "Should return string slice") + t.Logf("Available languages: %v", languages) + + // Test default language + defaultLang := signin.GetDefaultLanguage() + assert.IsType(t, "", defaultLang, "Should return string") + t.Logf("Default language: %s", defaultLang) +} + +func TestSigninGetConfigs(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Load signin configurations + err := signin.Load(config.Conf) + assert.NoError(t, err, "signin.Load should succeed") + + // Test getting configs for different languages + testCases := []string{"", "en", "zh-cn", "fr"} + + for _, lang := range testCases { + t.Run("lang_"+lang, func(t *testing.T) { + fullConfig := signin.GetFullConfig(lang) + publicConfig := signin.GetPublicConfig(lang) + + if fullConfig != nil { + t.Logf("Full config for '%s': %+v", lang, fullConfig.Title) + assert.NotNil(t, publicConfig, "Public config should exist if full config exists") + + // Test that public config removes sensitive data + if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil { + for i := range fullConfig.ThirdParty.Providers { + if publicConfig.ThirdParty != nil && i < len(publicConfig.ThirdParty.Providers) { + publicProvider := publicConfig.ThirdParty.Providers[i] + assert.Empty(t, publicProvider.ClientSecret, "Client secret should be empty in public config") + assert.Nil(t, publicProvider.ClientSecretGenerator, "Client secret generator should be nil in public config") + } + } + } + } else { + t.Logf("No config found for language: %s", lang) + } + }) + } +} + +func TestSigninLanguageNormalization(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Load signin configurations + err := signin.Load(config.Conf) + assert.NoError(t, err, "signin.Load should succeed") + + // Test that language codes are normalized to lowercase + config1 := signin.GetFullConfig("EN") + config2 := signin.GetFullConfig("en") + assert.Equal(t, config1, config2, "Language codes should be normalized to lowercase") + + config3 := signin.GetPublicConfig("ZH-CN") + config4 := signin.GetPublicConfig("zh-cn") + assert.Equal(t, config3, config4, "Language codes should be normalized to lowercase") +} + +func TestSigninConfigStructure(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + _ = serverURL // Server URL not needed for this test + + // Load signin configurations + err := signin.Load(config.Conf) + assert.NoError(t, err, "signin.Load should succeed") + + // Get a config to test structure + config := signin.GetFullConfig("") + if config != nil { + t.Logf("Config loaded successfully with title: %s", config.Title) + + // Verify config structure is valid + assert.IsType(t, &signin.Config{}, config, "Should return correct config type") + + // Test form configuration + if config.Form != nil { + t.Logf("Form configuration found") + if config.Form.Username != nil { + assert.IsType(t, []string{}, config.Form.Username.Fields, "Username fields should be string slice") + } + if config.Form.Captcha != nil { + assert.IsType(t, map[string]interface{}{}, config.Form.Captcha.Options, "Captcha options should be map") + } + } + + // Test third party configuration + if config.ThirdParty != nil { + t.Logf("Third party configuration found with %d providers", len(config.ThirdParty.Providers)) + if config.ThirdParty.Providers != nil { + assert.IsType(t, []*signin.Provider{}, config.ThirdParty.Providers, "Providers should be slice of Provider pointers") + for i, provider := range config.ThirdParty.Providers { + t.Logf("Provider %d: %s", i, provider.ID) + assert.IsType(t, []string{}, provider.Scopes, "Provider scopes should be string slice") + assert.IsType(t, map[string]string{}, provider.Mapping, "Provider mapping should be string map") + } + } + } + } else { + t.Log("No signin configuration found") + } +} + +func TestSigninAPI(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Test API endpoints + testCases := []struct { + name string + endpoint string + expectCode int + }{ + {"get config without locale", "/signin", 200}, + {"get config with en locale", "/signin?locale=en", 200}, + {"get config with zh-cn locale", "/signin?locale=zh-cn", 200}, + {"get config with invalid locale", "/signin?locale=invalid", 200}, // should fallback to default + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + url := serverURL + baseURL + tc.endpoint + resp, err := http.Get(url) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) + + if resp.StatusCode == 200 { + // Parse response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + var config signin.Config + err = json.Unmarshal(body, &config) + assert.NoError(t, err, "Should parse JSON response") + + t.Logf("API response for %s: %s", tc.endpoint, config.Title) + + // Verify it's public config (no sensitive data) + if config.ThirdParty != nil && config.ThirdParty.Providers != nil { + for _, provider := range config.ThirdParty.Providers { + assert.Empty(t, provider.ClientSecret, "Client secret should be empty in API response") + assert.Nil(t, provider.ClientSecretGenerator, "Client secret generator should be nil in API response") + } + } + } + } + }) + } +}