diff --git a/agent/store/types/convert.go b/agent/store/types/convert.go index 42fe05f5..9baa1d57 100644 --- a/agent/store/types/convert.go +++ b/agent/store/types/convert.go @@ -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 } diff --git a/agent/store/types/fields.go b/agent/store/types/fields.go new file mode 100644 index 00000000..b6d0ea05 --- /dev/null +++ b/agent/store/types/fields.go @@ -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 +} diff --git a/agent/store/types/fields_test.go b/agent/store/types/fields_test.go new file mode 100644 index 00000000..48e12e65 --- /dev/null +++ b/agent/store/types/fields_test.go @@ -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) + } + } + }) +} diff --git a/agent/store/types/types.go b/agent/store/types/types.go index b1051ca8..0f02f661 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -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 diff --git a/agent/store/xun/assistant.go b/agent/store/xun/assistant.go index 7a76aeec..bbcdaee4 100644 --- a/agent/store/xun/assistant.go +++ b/agent/store/xun/assistant.go @@ -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"). diff --git a/agent/store/xun/assistant_test.go b/agent/store/xun/assistant_test.go index 394dab34..aaf323c3 100644 --- a/agent/store/xun/assistant_test.go +++ b/agent/store/xun/assistant_test.go @@ -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) diff --git a/openapi/agent/agent.go b/openapi/agent/agent.go index ed5e6005..46a8f3d6 100644 --- a/openapi/agent/agent.go +++ b/openapi/agent/agent.go @@ -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 } diff --git a/openapi/agent/assistant.go b/openapi/agent/assistant.go new file mode 100644 index 00000000..7c48b648 --- /dev/null +++ b/openapi/agent/assistant.go @@ -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) +} diff --git a/openapi/agent/filter.go b/openapi/agent/filter.go new file mode 100644 index 00000000..c858e44b --- /dev/null +++ b/openapi/agent/filter.go @@ -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 + } + } +} diff --git a/openapi/agent/types.go b/openapi/agent/types.go new file mode 100644 index 00000000..137f7b14 --- /dev/null +++ b/openapi/agent/types.go @@ -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 +} diff --git a/openapi/tests/agent/assistant_test.go b/openapi/tests/agent/assistant_test.go new file mode 100644 index 00000000..116f56b6 --- /dev/null +++ b/openapi/tests/agent/assistant_test.go @@ -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() + } +}