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.
This commit is contained in:
Max 2025-07-30 16:49:28 +08:00
parent 1f2eaf6974
commit 315618da47
10 changed files with 932 additions and 80 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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
```

View file

@ -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}"
```

View file

@ -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

View file

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

42
openapi/signin/api.go Normal file
View file

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

388
openapi/signin/signin.go Normal file
View file

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

View file

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

View file

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