Enhance assistant model and query handling

- Added permission fields (YaoCreatedBy, YaoUpdatedBy, YaoTeamID, YaoTenantID) to the AssistantModel for better tracking of ownership and access control.
- Introduced a custom QueryFilter in the AssistantFilter struct to allow for flexible permission-based filtering of assistants.
- Updated the GetAssistants method to apply the custom query filter, improving the retrieval logic based on user permissions.
- Enhanced test coverage for the new QueryFilter functionality, ensuring accurate filtering of assistants based on various criteria.
This commit is contained in:
Max 2025-11-08 10:25:28 +08:00
parent c61688d8be
commit bed36b754f
11 changed files with 1736 additions and 27 deletions

View file

@ -339,6 +339,20 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
}
}
// Permission fields
if createdBy, ok := data["__yao_created_by"].(string); ok {
model.YaoCreatedBy = createdBy
}
if updatedBy, ok := data["__yao_updated_by"].(string); ok {
model.YaoUpdatedBy = updatedBy
}
if teamID, ok := data["__yao_team_id"].(string); ok {
model.YaoTeamID = teamID
}
if tenantID, ok := data["__yao_tenant_id"].(string); ok {
model.YaoTenantID = tenantID
}
return model, nil
}

View file

@ -0,0 +1,84 @@
package types
import "github.com/yaoapp/kun/log"
// AssistantAllowedFields defines the whitelist of fields that can be selected for assistants
var AssistantAllowedFields = map[string]bool{
"id": true,
"assistant_id": true,
"type": true,
"name": true,
"avatar": true,
"connector": true,
"description": true,
"path": true,
"sort": true,
"built_in": true,
"placeholder": true,
"options": true,
"prompts": true,
"workflow": true,
"kb": true,
"mcp": true,
"tools": true,
"tags": true,
"readonly": true,
"public": true,
"share": true,
"locales": true,
"automated": true,
"mentionable": true,
"created_at": true,
"updated_at": true,
"__yao_created_by": true,
"__yao_updated_by": true,
"__yao_team_id": true,
"__yao_tenant_id": true,
}
// AssistantDefaultFields defines the default fields to select for assistants when no specific fields are requested
var AssistantDefaultFields = []string{
"assistant_id",
"type",
"name",
"avatar",
"connector",
"description",
"sort",
"built_in",
"readonly",
"public",
"share",
"automated",
"mentionable",
"created_at",
"updated_at",
}
// ValidateAssistantFields validates and filters assistant select fields against the whitelist
// Returns the filtered fields. If input is empty, returns empty slice (meaning no restriction).
// If all fields are invalid, returns default fields as fallback.
func ValidateAssistantFields(fields []string) []string {
// If no fields specified, return empty slice (no restriction)
if len(fields) == 0 {
return []string{}
}
// Filter out any fields not in the whitelist
sanitized := make([]string, 0, len(fields))
for _, field := range fields {
if AssistantAllowedFields[field] {
sanitized = append(sanitized, field)
} else {
log.Warn("Ignoring invalid assistant select field: %s", field)
}
}
// If all fields were filtered out, return default fields as fallback
if len(sanitized) == 0 {
log.Warn("All assistant select fields were invalid, using default fields")
return AssistantDefaultFields
}
return sanitized
}

View file

@ -0,0 +1,180 @@
package types
import (
"reflect"
"testing"
)
func TestValidateAssistantFields(t *testing.T) {
t.Run("EmptyInput_ReturnsEmptySlice", func(t *testing.T) {
result := ValidateAssistantFields([]string{})
if len(result) != 0 {
t.Errorf("Expected empty slice, got %v", result)
}
})
t.Run("NilInput_ReturnsEmptySlice", func(t *testing.T) {
result := ValidateAssistantFields(nil)
if len(result) != 0 {
t.Errorf("Expected empty slice, got %v", result)
}
})
t.Run("ValidFields_ReturnsFiltered", func(t *testing.T) {
input := []string{"assistant_id", "name", "type"}
result := ValidateAssistantFields(input)
expected := []string{"assistant_id", "name", "type"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
})
t.Run("MixedValidInvalidFields_ReturnsOnlyValid", func(t *testing.T) {
input := []string{"assistant_id", "invalid_field", "name", "malicious_column"}
result := ValidateAssistantFields(input)
expected := []string{"assistant_id", "name"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
})
t.Run("AllInvalidFields_ReturnsDefaultFields", func(t *testing.T) {
input := []string{"invalid1", "invalid2", "malicious"}
result := ValidateAssistantFields(input)
if !reflect.DeepEqual(result, AssistantDefaultFields) {
t.Errorf("Expected default fields when all invalid, got %v", result)
}
})
t.Run("PermissionFields_AreAllowed", func(t *testing.T) {
input := []string{"__yao_created_by", "__yao_team_id", "assistant_id"}
result := ValidateAssistantFields(input)
expected := []string{"__yao_created_by", "__yao_team_id", "assistant_id"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
})
t.Run("AllAllowedFields_AreInWhitelist", func(t *testing.T) {
// Verify all default fields are in the allowed list
for _, field := range AssistantDefaultFields {
if !AssistantAllowedFields[field] {
t.Errorf("Default field %s is not in allowed fields", field)
}
}
})
t.Run("SQLInjectionAttempt_IsFiltered", func(t *testing.T) {
input := []string{"assistant_id", "name; DROP TABLE assistants;--", "type"}
result := ValidateAssistantFields(input)
expected := []string{"assistant_id", "type"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected SQL injection attempt to be filtered, got %v", result)
}
})
t.Run("DuplicateFields_AreKept", func(t *testing.T) {
input := []string{"assistant_id", "name", "assistant_id", "name"}
result := ValidateAssistantFields(input)
// Duplicates should be kept as-is (validation doesn't deduplicate)
expected := []string{"assistant_id", "name", "assistant_id", "name"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
})
}
func TestAssistantAllowedFields(t *testing.T) {
t.Run("ContainsBasicFields", func(t *testing.T) {
requiredFields := []string{
"assistant_id",
"name",
"type",
"connector",
"description",
}
for _, field := range requiredFields {
if !AssistantAllowedFields[field] {
t.Errorf("Required field %s is missing from allowed fields", field)
}
}
})
t.Run("ContainsPermissionFields", func(t *testing.T) {
permissionFields := []string{
"__yao_created_by",
"__yao_updated_by",
"__yao_team_id",
"__yao_tenant_id",
}
for _, field := range permissionFields {
if !AssistantAllowedFields[field] {
t.Errorf("Permission field %s is missing from allowed fields", field)
}
}
})
t.Run("ContainsComplexFields", func(t *testing.T) {
complexFields := []string{
"options",
"prompts",
"workflow",
"kb",
"mcp",
"tools",
"placeholder",
"locales",
}
for _, field := range complexFields {
if !AssistantAllowedFields[field] {
t.Errorf("Complex field %s is missing from allowed fields", field)
}
}
})
}
func TestAssistantDefaultFields(t *testing.T) {
t.Run("ContainsEssentialFields", func(t *testing.T) {
essentialFields := []string{
"assistant_id",
"name",
"type",
}
defaultFieldsMap := make(map[string]bool)
for _, field := range AssistantDefaultFields {
defaultFieldsMap[field] = true
}
for _, field := range essentialFields {
if !defaultFieldsMap[field] {
t.Errorf("Essential field %s is missing from default fields", field)
}
}
})
t.Run("DoesNotContainSensitiveFields", func(t *testing.T) {
// Default fields should not include complex/large fields by default
sensitiveFields := []string{
"options",
"prompts",
"workflow",
"kb",
"mcp",
"tools",
"placeholder",
"locales",
}
defaultFieldsMap := make(map[string]bool)
for _, field := range AssistantDefaultFields {
defaultFieldsMap[field] = true
}
for _, field := range sensitiveFields {
if defaultFieldsMap[field] {
t.Errorf("Large/complex field %s should not be in default fields", field)
}
}
})
}

View file

@ -1,6 +1,9 @@
package types
import "github.com/yaoapp/yao/agent/i18n"
import (
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/i18n"
)
// Setting represents the conversation configuration structure
// Used to configure basic conversation parameters including connector, user field, table name, etc.
@ -48,18 +51,19 @@ type ChatGroupResponse struct {
// AssistantFilter represents the assistant filter structure
// Used for filtering and pagination when retrieving assistant lists
type AssistantFilter struct {
Tags []string `json:"tags,omitempty"` // Filter by tags
Type string `json:"type,omitempty"` // Filter by type
Keywords string `json:"keywords,omitempty"` // Search in name and description
Connector string `json:"connector,omitempty"` // Filter by connector
AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID
AssistantIDs []string `json:"assistant_ids,omitempty"` // Filter by assistant IDs
Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
Automated *bool `json:"automated,omitempty"` // Filter by automation status
BuiltIn *bool `json:"built_in,omitempty"` // Filter by built-in status
Page int `json:"page,omitempty"` // Page number, starting from 1
PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
Tags []string `json:"tags,omitempty"` // Filter by tags
Type string `json:"type,omitempty"` // Filter by type
Keywords string `json:"keywords,omitempty"` // Search in name and description
Connector string `json:"connector,omitempty"` // Filter by connector
AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID
AssistantIDs []string `json:"assistant_ids,omitempty"` // Filter by assistant IDs
Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
Automated *bool `json:"automated,omitempty"` // Filter by automation status
BuiltIn *bool `json:"built_in,omitempty"` // Filter by built-in status
Page int `json:"page,omitempty"` // Page number, starting from 1
PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
QueryFilter func(query.Query) `json:"-"` // Custom query function for permission filtering (not serialized)
}
// AssistantList represents the paginated assistant list response structure

View file

@ -258,6 +258,11 @@ func (conv *Xun) GetAssistants(filter types.AssistantFilter, locale ...string) (
qb.Where("built_in", *filter.BuiltIn)
}
// Apply custom query filter function (for permission filtering)
if filter.QueryFilter != nil {
qb.Where(filter.QueryFilter)
}
// Set defaults for pagination
if filter.PageSize <= 0 {
filter.PageSize = 20
@ -284,14 +289,17 @@ func (conv *Xun) GetAssistants(filter types.AssistantFilter, locale ...string) (
prevPage = 0
}
// Apply select fields if provided
// Apply select fields with security validation (only if fields are explicitly specified)
if len(filter.Select) > 0 {
selectFields := make([]interface{}, len(filter.Select))
for i, field := range filter.Select {
// ValidateAssistantFields will validate fields against whitelist
sanitized := types.ValidateAssistantFields(filter.Select)
selectFields := make([]interface{}, len(sanitized))
for i, field := range sanitized {
selectFields[i] = field
}
qb.Select(selectFields...)
}
// If no select fields specified, query will return all fields (SELECT *)
// Get paginated results
rows, err := qb.OrderBy("sort", "asc").

View file

@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config"
@ -1338,6 +1339,251 @@ func TestGetAssistantsWithLocale(t *testing.T) {
})
}
// TestGetAssistantsWithQueryFilter tests using QueryFilter for permission filtering
func TestGetAssistantsWithQueryFilter(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
// Create test assistants with different permission settings
assistants := []types.AssistantModel{
{
Name: "Public Assistant",
Type: "assistant",
Connector: "openai",
Description: "Public assistant visible to all",
Tags: []string{"query-filter-test"},
Public: true,
Share: "private",
YaoCreatedBy: "user-1",
YaoTeamID: "team-1",
},
{
Name: "Team Shared Assistant",
Type: "assistant",
Connector: "openai",
Description: "Team shared assistant",
Tags: []string{"query-filter-test"},
Public: false,
Share: "team",
YaoCreatedBy: "user-2",
YaoTeamID: "team-1",
},
{
Name: "Private Assistant Owner",
Type: "assistant",
Connector: "openai",
Description: "Private assistant owned by user-1",
Tags: []string{"query-filter-test"},
Public: false,
Share: "private",
YaoCreatedBy: "user-1",
YaoTeamID: "team-1",
},
{
Name: "Private Assistant Other",
Type: "assistant",
Connector: "openai",
Description: "Private assistant owned by user-3",
Tags: []string{"query-filter-test"},
Public: false,
Share: "private",
YaoCreatedBy: "user-3",
YaoTeamID: "team-2",
},
}
createdIDs := []string{}
for _, asst := range assistants {
id, err := store.SaveAssistant(&asst)
if err != nil {
t.Fatalf("Failed to create assistant: %v", err)
}
createdIDs = append(createdIDs, id)
}
t.Run("FilterByPublic", func(t *testing.T) {
// QueryFilter: only public assistants
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.Where("public", true)
},
})
if err != nil {
t.Fatalf("Failed to get public assistants: %v", err)
}
if len(response.Data) != 1 {
t.Errorf("Expected 1 public assistant, got %d", len(response.Data))
}
if len(response.Data) > 0 && response.Data[0].Name != "Public Assistant" {
t.Errorf("Expected 'Public Assistant', got '%s'", response.Data[0].Name)
}
})
t.Run("FilterByTeamAndShare", func(t *testing.T) {
// QueryFilter: team-1 assistants that are shared with team
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.Where("__yao_team_id", "team-1").
Where("share", "team")
},
})
if err != nil {
t.Fatalf("Failed to get team shared assistants: %v", err)
}
if len(response.Data) != 1 {
t.Errorf("Expected 1 team shared assistant, got %d", len(response.Data))
}
if len(response.Data) > 0 && response.Data[0].Name != "Team Shared Assistant" {
t.Errorf("Expected 'Team Shared Assistant', got '%s'", response.Data[0].Name)
}
})
t.Run("FilterByOwner", func(t *testing.T) {
// QueryFilter: assistants created by user-1
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.Where("__yao_created_by", "user-1")
},
})
if err != nil {
t.Fatalf("Failed to get user-1 assistants: %v", err)
}
if len(response.Data) != 2 {
t.Errorf("Expected 2 assistants for user-1, got %d", len(response.Data))
}
for _, asst := range response.Data {
if asst.YaoCreatedBy != "user-1" {
t.Errorf("Expected creator 'user-1', got '%s'", asst.YaoCreatedBy)
}
}
})
t.Run("ComplexQueryFilter", func(t *testing.T) {
// Complex QueryFilter: (public = true) OR (team_id = team-1 AND (created_by = user-1 OR share = team))
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.Where(func(qb query.Query) {
// Public assistants
qb.Where("public", true)
}).OrWhere(func(qb query.Query) {
// Team assistants where user is creator or shared with team
qb.Where("__yao_team_id", "team-1").Where(func(qb query.Query) {
qb.Where("__yao_created_by", "user-1").
OrWhere("share", "team")
})
})
},
})
if err != nil {
t.Fatalf("Failed to get filtered assistants: %v", err)
}
// Should find: Public Assistant, Team Shared Assistant, Private Assistant Owner
if len(response.Data) != 3 {
t.Errorf("Expected 3 assistants, got %d", len(response.Data))
}
// Verify we got the right assistants
names := make(map[string]bool)
for _, asst := range response.Data {
names[asst.Name] = true
}
expectedNames := []string{"Public Assistant", "Team Shared Assistant", "Private Assistant Owner"}
for _, name := range expectedNames {
if !names[name] {
t.Errorf("Expected to find '%s' in results", name)
}
}
// Should NOT find Private Assistant Other
if names["Private Assistant Other"] {
t.Error("Should not find 'Private Assistant Other' in results")
}
})
t.Run("QueryFilterWithNullCheck", func(t *testing.T) {
// QueryFilter: assistants where team_id is null
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.WhereNull("__yao_team_id")
},
})
if err != nil {
t.Fatalf("Failed to get assistants with null team_id: %v", err)
}
// All test assistants have team_id, so should find 0
if len(response.Data) != 0 {
t.Errorf("Expected 0 assistants with null team_id, got %d", len(response.Data))
}
})
t.Run("QueryFilterCombinedWithOtherFilters", func(t *testing.T) {
// Combine QueryFilter with other filters
response, err := store.GetAssistants(types.AssistantFilter{
Tags: []string{"query-filter-test"},
Connector: "openai",
Page: 1,
PageSize: 20,
QueryFilter: func(qb query.Query) {
qb.Where("public", true)
},
})
if err != nil {
t.Fatalf("Failed to get combined filtered assistants: %v", err)
}
// Should only find public openai assistants
if len(response.Data) != 1 {
t.Errorf("Expected 1 assistant, got %d", len(response.Data))
}
if len(response.Data) > 0 {
if response.Data[0].Connector != "openai" {
t.Errorf("Expected connector 'openai', got '%s'", response.Data[0].Connector)
}
if !response.Data[0].Public {
t.Error("Expected public assistant")
}
}
})
// Cleanup
for _, id := range createdIDs {
_ = store.DeleteAssistant(id)
}
}
// TestAssistantCompleteWorkflow tests a complete workflow
func TestAssistantCompleteWorkflow(t *testing.T) {
test.Prepare(t, config.Conf)

View file

@ -13,17 +13,17 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Get the Agent instance
n := agent.GetAgent()
// Create agents group with OAuth guard
agents := group.Group("/agents")
agents.Use(oauth.Guard)
// Create assistants group with OAuth guard
assistants := group.Group("/assistants")
assistants.Use(oauth.Guard)
// Agent CRUD - Standard REST endpoints
agents.GET("/", n.HandleAssistantList) // GET /agents - List agents
agents.POST("/", n.HandleAssistantSave) // POST /agents - Create/Update agent
agents.GET("/tags", n.HandleAssistantTags) // GET /agents/tags - Get all agent tags
agents.GET("/:id", n.HandleAssistantDetail) // GET /agents/:id - Get agent details
agents.DELETE("/:id", n.HandleAssistantDelete) // DELETE /agents/:id - Delete agent
// Assistant CRUD - Standard REST endpoints
assistants.GET("/", ListAssistants) // GET /assistants - List assistants
assistants.POST("/", n.HandleAssistantSave) // POST /assistants - Create/Update assistant
assistants.GET("/tags", n.HandleAssistantTags) // GET /assistants/tags - Get all assistant tags
assistants.GET("/:id", n.HandleAssistantDetail) // GET /assistants/:id - Get assistant details
assistants.DELETE("/:id", n.HandleAssistantDelete) // DELETE /assistants/:id - Delete assistant
// Agent Actions
agents.POST("/:id/call", n.HandleAssistantCall) // POST /agents/:id/call - Execute agent API
// Assistant Actions
assistants.POST("/:id/call", n.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API
}

161
openapi/agent/assistant.go Normal file
View file

@ -0,0 +1,161 @@
package agent
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/response"
)
// ListAssistants lists assistants with pagination and filtering
func ListAssistants(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get Agent instance from global variable
agentInstance := agent.GetAgent()
if agentInstance == nil || agentInstance.Store == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Agent store not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Parse pagination parameters
page := 1
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
pagesize := 20
if pagesizeStr := c.Query("pagesize"); pagesizeStr != "" {
if ps, err := strconv.Atoi(pagesizeStr); err == nil && ps > 0 && ps <= 100 {
pagesize = ps
}
}
// Validate pagination
if err := ValidatePagination(page, pagesize); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse select parameter
var selectFields []string
if selectParam := strings.TrimSpace(c.Query("select")); selectParam != "" {
requestedFields := strings.Split(selectParam, ",")
for _, field := range requestedFields {
field = strings.TrimSpace(field)
if field != "" && availableAssistantFields[field] {
selectFields = append(selectFields, field)
}
}
// If no valid fields found, use default
if len(selectFields) == 0 {
selectFields = defaultAssistantFields
}
} else {
selectFields = defaultAssistantFields
}
// Parse filter parameters
keywords := strings.TrimSpace(c.Query("keywords"))
typeParam := strings.TrimSpace(c.Query("type"))
if typeParam == "" {
typeParam = "assistant" // Default type
}
connector := strings.TrimSpace(c.Query("connector"))
assistantID := strings.TrimSpace(c.Query("assistant_id"))
// Parse assistant IDs (multiple)
var assistantIDs []string
if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" {
assistantIDs = strings.Split(assistantIDsParam, ",")
// Trim spaces
for i, id := range assistantIDs {
assistantIDs[i] = strings.TrimSpace(id)
}
}
// Parse tags
var tags []string
if tagsParam := c.Query("tags"); tagsParam != "" {
tags = strings.Split(tagsParam, ",")
// Trim spaces
for i, tag := range tags {
tags[i] = strings.TrimSpace(tag)
}
}
// Parse boolean filters
var builtIn, mentionable, automated *bool
if builtInParam := c.Query("built_in"); builtInParam != "" {
builtIn = parseBoolValue(builtInParam)
}
if mentionableParam := c.Query("mentionable"); mentionableParam != "" {
mentionable = parseBoolValue(mentionableParam)
}
if automatedParam := c.Query("automated"); automatedParam != "" {
automated = parseBoolValue(automatedParam)
}
// Note: public and share filters are not yet supported in AssistantFilter
// They would need to be added to the store layer for proper filtering
// Parse locale
locale := "en-us" // Default locale
if loc := c.Query("locale"); loc != "" {
locale = strings.ToLower(strings.TrimSpace(loc))
}
// Build filter using the existing AssistantFilter structure
filter := BuildAssistantFilter(AssistantFilterParams{
Page: page,
PageSize: pagesize,
Keywords: keywords,
Type: typeParam,
Connector: connector,
AssistantID: assistantID,
AssistantIDs: assistantIDs,
Tags: tags,
SelectFields: selectFields,
BuiltIn: builtIn,
Mentionable: mentionable,
Automated: automated,
})
// Apply permission-based filtering (Scope filtering)
filter.QueryFilter = AuthQueryFilter(c, authInfo)
// Use the existing GetAssistants method from agent.Store
result, err := agentInstance.Store.GetAssistants(filter, locale)
if err != nil {
log.Error("Failed to list assistants: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to list assistants: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Filter sensitive fields for built-in assistants
// For built-in assistants, clear code-level fields (prompts, workflow, tools, kb, mcp, options)
FilterBuiltInFields(result.Data)
// Return the result with standard response format
response.RespondWithSuccess(c, response.StatusOK, result)
}

146
openapi/agent/filter.go Normal file
View file

@ -0,0 +1,146 @@
package agent
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/xun/dbal/query"
agenttypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// AuthFilter applies permission-based filtering to query wheres for assistants
// This function builds where clauses based on the user's authorization constraints
// It supports TeamOnly and OwnerOnly constraints for data access control
//
// Parameters:
// - c: gin.Context containing authorization information
// - authInfo: authorized information extracted from the context
//
// Returns:
// - []model.QueryWhere: array of where clauses to apply to the query
func AuthFilter(c *gin.Context, authInfo *types.AuthorizedInfo) []model.QueryWhere {
if authInfo == nil {
return []model.QueryWhere{}
}
var wheres []model.QueryWhere
scope := authInfo.AccessScope()
// Team only - User can access:
// 1. Public records (public = true)
// 2. Records in their team where:
// - They created the record (__yao_created_by matches)
// - OR the record is shared with team (share = "team")
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
wheres = append(wheres, model.QueryWhere{
Wheres: []model.QueryWhere{
{Column: "public", Value: true, Method: "orwhere"},
{Wheres: []model.QueryWhere{
{Column: "__yao_team_id", Value: scope.TeamID},
{Wheres: []model.QueryWhere{
{Column: "__yao_created_by", Value: scope.CreatedBy},
{Column: "share", Value: "team", Method: "orwhere"},
}},
}, Method: "orwhere"},
},
})
return wheres
}
// Owner only - User can access:
// 1. Public records (public = true)
// 2. Records they created where:
// - __yao_team_id is null (not team records)
// - __yao_created_by matches their user ID
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
wheres = append(wheres, model.QueryWhere{
Wheres: []model.QueryWhere{
{Column: "public", Value: true, Method: "orwhere"},
{Wheres: []model.QueryWhere{
{Column: "__yao_team_id", OP: "null"},
{Column: "__yao_created_by", Value: scope.CreatedBy},
}, Method: "orwhere"},
},
})
return wheres
}
return wheres
}
// AuthQueryFilter returns a Query function for easy permission filtering
// This is a convenience function that can be directly used with query.Where()
//
// Usage:
//
// if filter := AuthQueryFilter(c, authInfo); filter != nil {
// qb.Where(filter)
// }
func AuthQueryFilter(c *gin.Context, authInfo *types.AuthorizedInfo) func(query.Query) {
if authInfo == nil {
return nil
}
scope := authInfo.AccessScope()
// Team only - User can access:
// 1. Public records (public = true)
// 2. Records in their team where:
// - They created the record (__yao_created_by matches)
// - OR the record is shared with team (share = "team")
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
return func(qb query.Query) {
qb.Where(func(qb query.Query) {
// Public records
qb.Where("public", true)
}).OrWhere(func(qb query.Query) {
// Team records where user is creator or share is team
qb.Where("__yao_team_id", scope.TeamID).Where(func(qb query.Query) {
qb.Where("__yao_created_by", scope.CreatedBy).
OrWhere("share", "team")
})
})
}
}
// Owner only - User can access:
// 1. Public records (public = true)
// 2. Records they created where:
// - __yao_team_id is null (not team records)
// - __yao_created_by matches their user ID
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
return func(qb query.Query) {
qb.Where(func(qb query.Query) {
// Public records
qb.Where("public", true)
}).OrWhere(func(qb query.Query) {
// Owner records (team_id is null and created by user)
qb.WhereNull("__yao_team_id").
Where("__yao_created_by", scope.CreatedBy)
})
}
}
return nil
}
// FilterBuiltInFields filters sensitive fields for built-in assistants
// For built-in assistants, code-level fields (prompts, workflow, tools, kb, mcp, options) should be cleared
func FilterBuiltInFields(assistants []*agenttypes.AssistantModel) {
if assistants == nil {
return
}
for _, assistant := range assistants {
if assistant != nil && assistant.BuiltIn {
// Clear code-level sensitive fields for built-in assistants
assistant.Prompts = nil
assistant.Workflow = nil
assistant.Tools = nil
assistant.KB = nil
assistant.MCP = nil
assistant.Options = nil
}
}
}

113
openapi/agent/types.go Normal file
View file

@ -0,0 +1,113 @@
package agent
import (
"fmt"
"strings"
agenttypes "github.com/yaoapp/yao/agent/store/types"
)
// Assistant field definitions
var (
// availableAssistantFields defines all available fields for security filtering
availableAssistantFields = map[string]bool{
"id": true, "assistant_id": true, "type": true, "name": true, "avatar": true,
"connector": true, "description": true, "path": true, "sort": true,
"built_in": true, "placeholder": true, "options": true, "prompts": true,
"workflow": true, "kb": true, "mcp": true, "tools": true, "tags": true,
"readonly": true, "public": true, "share": true, "locales": true,
"automated": true, "mentionable": true,
"created_at": true, "updated_at": true, "deleted_at": true,
"__yao_created_by": true, "__yao_updated_by": true, "__yao_team_id": true,
}
// defaultAssistantFields defines the default compact field list
defaultAssistantFields = []string{
"assistant_id", "type", "name", "avatar", "connector", "description",
"sort", "built_in", "tags", "readonly", "public", "share",
"automated", "mentionable", "created_at", "updated_at",
}
)
// parseBoolValue parses various string formats into a boolean pointer
// Supports: 1, 0, "1", "0", "true", "false", etc.
func parseBoolValue(value string) *bool {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "1", "true", "yes", "on":
v := true
return &v
case "0", "false", "no", "off":
v := false
return &v
}
return nil
}
// AssistantFilterParams represents the parameters for building an AssistantFilter
type AssistantFilterParams struct {
Page int
PageSize int
Keywords string
Type string
Connector string
AssistantID string
AssistantIDs []string
Tags []string
SelectFields []string
BuiltIn *bool
Mentionable *bool
Automated *bool
Public *bool
Share string
}
// BuildAssistantFilter builds an AssistantFilter from parameters
func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilter {
filter := agenttypes.AssistantFilter{
Page: params.Page,
PageSize: params.PageSize,
Keywords: params.Keywords,
Tags: params.Tags,
Type: params.Type,
Connector: params.Connector,
AssistantID: params.AssistantID,
AssistantIDs: params.AssistantIDs,
Select: params.SelectFields,
BuiltIn: params.BuiltIn,
Mentionable: params.Mentionable,
Automated: params.Automated,
}
// Set default type if not specified
if filter.Type == "" {
filter.Type = "assistant"
}
// Set default pagination
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.PageSize > 100 {
filter.PageSize = 100
}
return filter
}
// ValidatePagination validates pagination parameters
func ValidatePagination(page, pagesize int) error {
if page < 0 {
return fmt.Errorf("page must be positive")
}
if pagesize < 0 {
return fmt.Errorf("pagesize must be positive")
}
if pagesize > 100 {
return fmt.Errorf("pagesize cannot exceed 100")
}
return nil
}

View file

@ -0,0 +1,753 @@
package openapi_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestListAssistants tests the assistants listing endpoint
func TestListAssistants(t *testing.T) {
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
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Agent List Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("ListAssistantsSuccess", func(t *testing.T) {
// Test listing all assistants
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Expect successful response
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should successfully retrieve assistants")
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Response should have pagination structure
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants", len(data))
} else {
t.Logf("Successfully retrieved assistants response (data field type: %T)", response["data"])
}
// Check pagination fields
assert.Contains(t, response, "page")
assert.Contains(t, response, "pagesize")
assert.Contains(t, response, "total")
})
t.Run("ListAssistantsWithPagination", func(t *testing.T) {
// Test with pagination parameters
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?page=1&pagesize=10", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Verify pagination values
page, hasPage := response["page"].(float64)
pagesize, hasPagesize := response["pagesize"].(float64)
if hasPage && hasPagesize {
assert.Equal(t, float64(1), page, "Page should be 1")
assert.Equal(t, float64(10), pagesize, "Pagesize should be 10")
t.Logf("Pagination working correctly: page=%d, pagesize=%d", int(page), int(pagesize))
}
})
t.Run("ListAssistantsWithKeywords", func(t *testing.T) {
// Test with keywords filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?keywords=test", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with keywords filter", len(data))
}
})
t.Run("ListAssistantsWithType", func(t *testing.T) {
// Test with type filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?type=assistant", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with type filter", len(data))
}
})
t.Run("ListAssistantsWithTags", func(t *testing.T) {
// Test with tags filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?tags=productivity,ai", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with tags filter", len(data))
}
})
t.Run("ListAssistantsWithBuiltInFilter", func(t *testing.T) {
// Test with built_in filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?built_in=true", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d built-in assistants", len(data))
// Verify that built-in assistants have sensitive fields filtered
for _, item := range data {
assistant, ok := item.(map[string]interface{})
if !ok {
continue
}
builtIn, hasBuiltIn := assistant["built_in"].(bool)
if hasBuiltIn && builtIn {
// Check that code-level fields are null or absent
prompts := assistant["prompts"]
workflow := assistant["workflow"]
tools := assistant["tools"]
kb := assistant["kb"]
mcp := assistant["mcp"]
options := assistant["options"]
// These should be nil or absent for built-in assistants
if prompts != nil {
t.Logf("Warning: Built-in assistant has non-nil prompts field: %v", prompts)
}
if workflow != nil {
t.Logf("Warning: Built-in assistant has non-nil workflow field: %v", workflow)
}
if tools != nil {
t.Logf("Warning: Built-in assistant has non-nil tools field: %v", tools)
}
if kb != nil {
t.Logf("Warning: Built-in assistant has non-nil kb field: %v", kb)
}
if mcp != nil {
t.Logf("Warning: Built-in assistant has non-nil mcp field: %v", mcp)
}
if options != nil {
t.Logf("Warning: Built-in assistant has non-nil options field: %v", options)
}
}
}
}
})
t.Run("ListAssistantsWithMentionableFilter", func(t *testing.T) {
// Test with mentionable filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?mentionable=true", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d mentionable assistants", len(data))
}
})
t.Run("ListAssistantsWithAutomatedFilter", func(t *testing.T) {
// Test with automated filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?automated=false", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d non-automated assistants", len(data))
}
})
t.Run("ListAssistantsWithSelectFields", func(t *testing.T) {
// Test with select parameter to limit returned fields
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?select=assistant_id,name,avatar,type", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData && len(data) > 0 {
// Check first assistant to verify field selection worked
assistant, ok := data[0].(map[string]interface{})
if ok {
t.Logf("Assistant fields returned: %+v", assistant)
// Note: The actual fields returned depend on the implementation
// This test verifies the select parameter is accepted without error
}
}
})
t.Run("ListAssistantsWithInvalidSelectFields", func(t *testing.T) {
// Test with invalid select fields (should be filtered by whitelist)
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?select=invalid_field,malicious_sql", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should still return 200, but with default/filtered fields
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
t.Logf("Successfully handled invalid select fields by using whitelist")
})
t.Run("ListAssistantsWithMultipleFilters", func(t *testing.T) {
// Test with multiple filter parameters combined
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?type=assistant&built_in=false&mentionable=true&page=1&pagesize=5", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with multiple filters", len(data))
}
})
t.Run("ListAssistantsWithConnector", func(t *testing.T) {
// Test with connector filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?connector=openai", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with connector filter", len(data))
}
})
t.Run("ListAssistantsWithAssistantID", func(t *testing.T) {
// Test with specific assistant_id filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?assistant_id=test_assistant", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with assistant_id filter", len(data))
}
})
t.Run("ListAssistantsWithAssistantIDs", func(t *testing.T) {
// Test with multiple assistant_ids filter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?assistant_ids=assistant1,assistant2,assistant3", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d assistants with assistant_ids filter", len(data))
}
})
t.Run("ListAssistantsWithInvalidPagination", func(t *testing.T) {
// Test with invalid pagination parameters (should use defaults)
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?page=-1&pagesize=1000", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return error for invalid pagination
if resp.StatusCode == http.StatusBadRequest {
var errorResponse map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&errorResponse)
assert.NoError(t, err)
assert.Contains(t, errorResponse, "error")
t.Logf("Correctly rejected invalid pagination parameters")
} else {
// Or apply default/corrected values
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Applied default/corrected pagination values")
}
})
}
// TestAssistantEndpointsUnauthorized tests that endpoints return 401 when not authenticated
func TestAssistantEndpointsUnauthorized(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
endpoints := []struct {
method string
path string
}{
{"GET", "/agent/assistants"},
{"GET", "/agent/assistants?page=1&pagesize=10"},
{"GET", "/agent/assistants?keywords=test"},
{"GET", "/agent/assistants?type=assistant"},
{"GET", "/agent/assistants?built_in=true"},
}
for _, endpoint := range endpoints {
t.Run(fmt.Sprintf("Unauthorized_%s_%s", endpoint.method, endpoint.path), func(t *testing.T) {
req, err := http.NewRequest(endpoint.method, serverURL+baseURL+endpoint.path, nil)
assert.NoError(t, err)
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
t.Logf("Correctly rejected unauthorized request to %s %s", endpoint.method, endpoint.path)
})
}
}
// TestAssistantPermissionFiltering tests that permission-based filtering works correctly
func TestAssistantPermissionFiltering(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Create two different test users with tokens
client := testutils.RegisterTestClient(t, "Agent Permission Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// User 1 token
token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// User 2 token (different user)
token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("User1CanSeeOwnAssistants", func(t *testing.T) {
// User 1 should see their own assistants
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("User 1 can see %d assistants", len(data))
}
})
t.Run("User2SeesFilteredResults", func(t *testing.T) {
// User 2 should see different assistants (permission filtering applied)
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token2.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("User 2 can see %d assistants (permission filtering applied)", len(data))
}
})
}
// TestAssistantResponseStructure tests that the response structure is correct
func TestAssistantResponseStructure(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
client := testutils.RegisterTestClient(t, "Agent Response Structure Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("ResponseHasCorrectStructure", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?page=1&pagesize=5", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Verify response structure matches OpenAPI standard
assert.Contains(t, response, "data", "Response should have 'data' field")
assert.Contains(t, response, "page", "Response should have 'page' field")
assert.Contains(t, response, "pagesize", "Response should have 'pagesize' field")
assert.Contains(t, response, "total", "Response should have 'total' field")
// Verify data is an array
data, ok := response["data"].([]interface{})
assert.True(t, ok, "Data field should be an array")
t.Logf("Response structure is correct with %d assistants", len(data))
})
}
// TestAssistantLocaleSupport tests that locale parameter works correctly
func TestAssistantLocaleSupport(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
client := testutils.RegisterTestClient(t, "Agent Locale Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
locales := []string{"en-us", "zh-cn", "ja-jp", "de-de", "fr-fr"}
for _, locale := range locales {
t.Run(fmt.Sprintf("LocaleSupport_%s", locale), func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?locale="+locale, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
t.Logf("Successfully retrieved assistants with locale: %s", locale)
})
}
}
// TestAssistantEdgeCases tests edge cases and boundary conditions
func TestAssistantEdgeCases(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
client := testutils.RegisterTestClient(t, "Agent Edge Cases Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("EmptyKeywordsParameter", func(t *testing.T) {
// Test with empty keywords parameter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?keywords=", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Handled empty keywords parameter correctly")
})
t.Run("EmptyTagsParameter", func(t *testing.T) {
// Test with empty tags parameter
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?tags=", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Handled empty tags parameter correctly")
})
t.Run("VeryLongKeywords", func(t *testing.T) {
// Test with very long keywords string
longKeywords := string(make([]byte, 1000))
for i := range longKeywords {
longKeywords = longKeywords[:i] + "test"
}
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?keywords="+longKeywords[:500], nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
// Should handle gracefully (either return results or error)
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest)
t.Logf("Handled very long keywords parameter (status: %d)", resp.StatusCode)
})
t.Run("SpecialCharactersInKeywords", func(t *testing.T) {
// Test with special characters in keywords
specialKeywords := "test&special=chars<>\"';--"
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?keywords="+specialKeywords, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Handled special characters in keywords correctly")
})
t.Run("MaxPageSize", func(t *testing.T) {
// Test with maximum page size (should be capped at 100)
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?pagesize=100", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
pagesize, ok := response["pagesize"].(float64)
if ok {
assert.LessOrEqual(t, int(pagesize), 100, "Pagesize should be capped at 100")
t.Logf("Correctly capped pagesize at %d", int(pagesize))
}
})
}
// BenchmarkListAssistants benchmarks the list assistants endpoint
func BenchmarkListAssistants(b *testing.B) {
// Convert testing.B to testing.T for Prepare/Clean
t := &testing.T{}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
client := testutils.RegisterTestClient(t, "Agent Benchmark Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Reset timer after setup
b.ResetTimer()
// Run benchmark
for i := 0; i < b.N; i++ {
req, _ := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?page=1&pagesize=20", nil)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
b.Fatalf("Request failed: %v", err)
}
resp.Body.Close()
}
}