Enhance Robot API with Permission Fields and Status Management
- Added `YaoCreatedBy` and `YaoTeamID` fields to `RobotState` and `RobotResponse` for improved access control. - Updated `GetRobotStatus` to retrieve permission fields from the store and populate the robot state. - Modified `robotFields` in the store to include new Yao permission fields for better management of access control. - Enhanced OpenAPI integration by registering robot routes and ensuring proper permission checks in handlers. - Updated documentation in TODO.md to reflect the completion of permission logic and API enhancements.
This commit is contained in:
parent
df5836d8cc
commit
64b5ffd154
12 changed files with 2019 additions and 88 deletions
|
|
@ -83,6 +83,9 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get permission fields from store (for access control)
|
||||
record, _ := robotStore.Get(context.Background(), memberID)
|
||||
|
||||
state := &RobotState{
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
|
|
@ -93,6 +96,12 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
|||
MaxRunning: 2, // default
|
||||
}
|
||||
|
||||
// Add permission fields if available
|
||||
if record != nil {
|
||||
state.YaoCreatedBy = record.YaoCreatedBy
|
||||
state.YaoTeamID = record.YaoTeamID
|
||||
}
|
||||
|
||||
if robot.Config != nil && robot.Config.Quota != nil {
|
||||
state.MaxRunning = robot.Config.Quota.GetMax()
|
||||
}
|
||||
|
|
@ -565,10 +574,12 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse {
|
|||
MCPServers: record.MCPServers,
|
||||
LanguageModel: record.LanguageModel,
|
||||
|
||||
CostLimit: record.CostLimit,
|
||||
InvitedBy: record.InvitedBy,
|
||||
JoinedAt: record.JoinedAt,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
CostLimit: record.CostLimit,
|
||||
InvitedBy: record.InvitedBy,
|
||||
JoinedAt: record.JoinedAt,
|
||||
YaoCreatedBy: record.YaoCreatedBy,
|
||||
YaoTeamID: record.YaoTeamID,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,16 +28,18 @@ type ListResult struct {
|
|||
|
||||
// RobotState - runtime state from Status()
|
||||
type RobotState struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Status types.RobotStatus `json:"status"`
|
||||
Running int `json:"running"`
|
||||
MaxRunning int `json:"max_running"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"`
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
|
||||
}
|
||||
|
||||
// ==================== Trigger Types ====================
|
||||
|
|
@ -224,8 +226,10 @@ type RobotResponse struct {
|
|||
CostLimit float64 `json:"cost_limit,omitempty"`
|
||||
|
||||
// Ownership & Audit
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check
|
||||
|
||||
// Timestamps
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ var robotFields = []interface{}{
|
|||
// Timestamps
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
// Yao Permission Fields (for access control)
|
||||
"__yao_created_by",
|
||||
"__yao_updated_by",
|
||||
"__yao_team_id",
|
||||
"__yao_tenant_id",
|
||||
}
|
||||
|
||||
// Save creates or updates a robot member record
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/agent/robot"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
|
|
@ -26,4 +27,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
|
||||
// Assistant Actions
|
||||
// group.POST("/assistants/:id/call", agent.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API
|
||||
|
||||
// Robot routes - Attach as sub-router
|
||||
// Routes: GET/POST /robots, GET/PUT/DELETE /robots/:id, GET /robots/:id/status
|
||||
robot.Attach(group.Group("/robots"), oauth)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@
|
|||
|
||||
---
|
||||
|
||||
## 🟢 Phase 1: Core CRUD 🟡 [Low Risk]
|
||||
## 🟢 Phase 1: Core CRUD ✅ [Low Risk]
|
||||
|
||||
**Goal:** Basic robot management endpoints
|
||||
**Risk:** 🟢 Low - All new code, no changes to existing logic
|
||||
**Status:** ✅ Complete
|
||||
|
||||
### 1.1 Backend Prerequisites ✅
|
||||
|
||||
|
|
@ -72,80 +73,103 @@
|
|||
- [x] Implement `Get<Type>` functions for map value extraction
|
||||
- [x] Add tests: `utils/convert_test.go`
|
||||
|
||||
### 1.2 OpenAPI Setup ⬜ (Next Step)
|
||||
### 1.2 OpenAPI Setup ✅
|
||||
|
||||
- [ ] Create `openapi/agent/robot/` directory (sub-package under agent)
|
||||
- [ ] Create `robot.go` - route registration with `Attach()` function
|
||||
- [ ] Register routes in `openapi/agent/agent.go` via `robot.Attach(group.Group("/robots"), oauth)`
|
||||
- [ ] Add OAuth guard middleware
|
||||
- [x] Create `openapi/agent/robot/` directory (sub-package under agent)
|
||||
- [x] Create `robot.go` - route registration with `Attach()` function
|
||||
- [x] Register routes in `openapi/agent/agent.go` via `robot.Attach(group.Group("/robots"), oauth)`
|
||||
- [x] Add OAuth guard middleware
|
||||
|
||||
### 1.3 OpenAPI Types ⬜
|
||||
### 1.3 OpenAPI Types ✅
|
||||
|
||||
> Note: Core types already exist in `agent/robot/api/types.go`. OpenAPI layer needs HTTP-specific types.
|
||||
|
||||
- [ ] `types.go` - HTTP request/response types
|
||||
- [ ] `RobotResponse` struct (with field mapping: `name` ← `member_id`, `description` ← `bio`)
|
||||
- [ ] `ConfigResponse` struct (and sub-types)
|
||||
- [ ] `ListRobotsResponse` struct
|
||||
- [ ] `CreateRobotRequest` struct (HTTP binding)
|
||||
- [ ] `UpdateRobotRequest` struct (HTTP binding)
|
||||
- [ ] `NewRobotResponse()` - conversion from `api.RobotResponse`
|
||||
- [ ] Error response types
|
||||
- [x] `types.go` - HTTP request/response types
|
||||
- [x] `RobotResponse` struct (with field mapping: `name` ← `member_id`, `description` ← `bio`)
|
||||
- [x] `RobotStatusResponse` struct
|
||||
- [x] `ListRobotsResponse` struct
|
||||
- [x] `CreateRobotRequest` struct (HTTP binding)
|
||||
- [x] `UpdateRobotRequest` struct (HTTP binding)
|
||||
- [x] `NewRobotResponse()` - conversion from `api.RobotResponse`
|
||||
- [x] `NewRobotStatusResponse()` - conversion from `api.RobotState`
|
||||
|
||||
### 1.4 List Robots ⬜
|
||||
### 1.4 List Robots ✅
|
||||
|
||||
- [ ] `list.go` - GET /v1/agent/robots
|
||||
- [ ] Parse query params: `locale`, `status`, `keywords`, `page`, `pagesize`
|
||||
- [ ] Call `robot/api.ListRobots()`
|
||||
- [ ] Format response with localization
|
||||
- [ ] Test: `tests/robot/list_test.go`
|
||||
- [x] `list.go` - GET /v1/agent/robots
|
||||
- [x] Parse query params: `status`, `keywords`, `page`, `pagesize`, `team_id`
|
||||
- [x] Call `robot/api.ListRobots()`
|
||||
- [x] Team constraint from auth info
|
||||
- [x] Test: `tests/agent/robot_test.go#TestListRobots`
|
||||
|
||||
### 1.5 Get Robot ⬜
|
||||
### 1.5 Get Robot ✅
|
||||
|
||||
- [ ] `detail.go` - GET /v1/agent/robots/:id
|
||||
- [ ] Parse path param and `locale` query
|
||||
- [ ] Call `robot/api.GetRobot()` and `robot/api.GetRobotStatus()`
|
||||
- [ ] Format response with full config
|
||||
- [ ] Team access check
|
||||
- [ ] Test: `tests/robot/get_test.go`
|
||||
- [x] `detail.go` - GET /v1/agent/robots/:id
|
||||
- [x] Parse path param
|
||||
- [x] Call `robot/api.GetRobotResponse()`
|
||||
- [x] Team access check
|
||||
- [x] Test: `tests/agent/robot_test.go#TestGetRobot`
|
||||
|
||||
### 1.6 Create Robot ⬜
|
||||
### 1.6 Create Robot ✅
|
||||
|
||||
- [ ] POST /v1/agent/robots handler
|
||||
- [ ] Parse HTTP request to `api.CreateRobotRequest`
|
||||
- [ ] Apply `authInfo.WithCreateScope()` for permission fields
|
||||
- [ ] Call `robot/api.CreateRobot()`
|
||||
- [ ] Return created robot
|
||||
- [ ] Test: `tests/robot/create_test.go`
|
||||
- [x] POST /v1/agent/robots handler
|
||||
- [x] Parse HTTP request to `CreateRobotRequest`
|
||||
- [x] Apply `AuthScope` with permission fields (CreatedBy, TeamID, TenantID)
|
||||
- [x] Call `robot/api.CreateRobot()`
|
||||
- [x] Return created robot (201 Created)
|
||||
- [x] Handle duplicate (409 Conflict)
|
||||
- [x] Test: `tests/agent/robot_test.go#TestCreateRobot`
|
||||
|
||||
### 1.7 Update Robot ⬜
|
||||
### 1.7 Update Robot ✅
|
||||
|
||||
- [ ] PUT /v1/agent/robots/:id handler
|
||||
- [ ] Parse HTTP request to `api.UpdateRobotRequest`
|
||||
- [ ] Ownership/permission check
|
||||
- [ ] Apply `authInfo.WithUpdateScope()` for permission fields
|
||||
- [ ] Call `robot/api.UpdateRobot()`
|
||||
- [ ] Return updated robot
|
||||
- [ ] Test: `tests/robot/update_test.go`
|
||||
- [x] PUT /v1/agent/robots/:id handler
|
||||
- [x] Parse HTTP request to `UpdateRobotRequest`
|
||||
- [x] Team permission check
|
||||
- [x] Apply `AuthScope` with UpdatedBy
|
||||
- [x] Call `robot/api.UpdateRobot()`
|
||||
- [x] Return updated robot
|
||||
- [x] Test: `tests/agent/robot_test.go#TestUpdateRobot`
|
||||
|
||||
### 1.8 Delete Robot ⬜
|
||||
### 1.8 Delete Robot ✅
|
||||
|
||||
- [ ] DELETE /v1/agent/robots/:id handler
|
||||
- [ ] Ownership/permission check
|
||||
- [ ] Call `robot/api.RemoveRobot()`
|
||||
- [ ] Return success response
|
||||
- [ ] Test: `tests/robot/delete_test.go`
|
||||
- [x] DELETE /v1/agent/robots/:id handler
|
||||
- [x] Team permission check
|
||||
- [x] Call `robot/api.RemoveRobot()`
|
||||
- [x] Handle running executions (409 Conflict)
|
||||
- [x] Return success response
|
||||
- [x] Test: `tests/agent/robot_test.go#TestDeleteRobot`
|
||||
|
||||
### 1.9 Utilities ⬜
|
||||
### 1.9 Status Endpoint ✅
|
||||
|
||||
- [ ] `utils.go` - helper functions
|
||||
- [ ] `getLocale(c *gin.Context)` - extract locale from query/header
|
||||
- [ ] `formatTime(t *time.Time)` - format to ISO string
|
||||
- [ ] `localizeString(value, locale)` - localization helper
|
||||
- [ ] `applyAuthScope(authInfo, req)` - apply permission fields
|
||||
- [ ] `filter.go` - query filtering
|
||||
- [ ] Parse query params to `api.ListQuery`
|
||||
- [ ] Parse query params to `api.ExecutionQuery`
|
||||
- [x] GET /v1/agent/robots/:id/status handler
|
||||
- [x] Call `robot/api.GetRobotStatus()`
|
||||
- [x] Return runtime status (running count, max, last/next run)
|
||||
- [x] Test: `tests/agent/robot_test.go#TestGetRobotStatus`
|
||||
|
||||
### 1.10 Utilities ✅
|
||||
|
||||
- [x] `utils.go` - helper functions
|
||||
- [x] `GetLocale(c *gin.Context)` - extract locale from query/header
|
||||
- [x] `ParseBoolValue()` - parse bool from string
|
||||
|
||||
### 1.11 Permission Logic ✅
|
||||
|
||||
- [x] `permission.go` - permission check functions
|
||||
- [x] `CanRead()` - read permission check (creator or team member)
|
||||
- [x] `CanWrite()` - write permission check (creator only)
|
||||
- [x] `GetEffectiveTeamID()` - get effective team_id (user_id for personal users)
|
||||
- [x] `BuildListFilter()` - build list filter based on permissions
|
||||
- [x] Apply permission checks in handlers:
|
||||
- [x] `GetRobot` - check `CanRead()` with `YaoTeamID` and `YaoCreatedBy`
|
||||
- [x] `GetRobotStatus` - check `CanRead()`
|
||||
- [x] `UpdateRobot` - check `CanWrite()`
|
||||
- [x] `DeleteRobot` - check `CanWrite()`
|
||||
- [x] `ListRobots` - use `BuildListFilter()` for team filtering
|
||||
- [x] `CreateRobot` - auto-set `__yao_team_id` to `user_id` for personal users
|
||||
- [x] Add Yao permission fields to API layer:
|
||||
- [x] `api/types.go` - add `YaoCreatedBy`, `YaoTeamID` to `RobotResponse` and `RobotState`
|
||||
- [x] `api/robot.go` - populate permission fields in `recordToResponse()` and `GetRobotStatus()`
|
||||
- [x] `store/robot.go` - add `__yao_*` fields to `robotFields`
|
||||
- [x] Permission tests in `tests/agent/robot_test.go#TestRobotPermissions`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -467,7 +491,7 @@ yao/openapi/tests/robot/
|
|||
|
||||
| Phase | Risk | Status | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| 1. Core CRUD | 🟢 | 🟡 | Basic robot management (Backend ✅, OpenAPI ⬜) |
|
||||
| 1. Core CRUD | 🟢 | ✅ | Basic robot management (Backend ✅, OpenAPI ✅) |
|
||||
| 2. Execution | 🟢 | ⬜ | Execution listing, control, trigger/intervene |
|
||||
| 3. Results/Activities | 🟢 | ⬜ | Deliverables and activity feed |
|
||||
| 4. i18n | 🟢 | ⬜ | Locale parameter support |
|
||||
|
|
@ -489,7 +513,14 @@ Legend: ⬜ Not started | 🟡 In progress | ✅ Complete | 🟢 Low Risk | 🟡
|
|||
| `api/robot_test.go` | ✅ | API tests |
|
||||
| `utils/convert.go` | ✅ | Type conversion utilities |
|
||||
| `utils/convert_test.go` | ✅ | Unit tests |
|
||||
| `openapi/agent/robot/` | ⬜ | HTTP handlers (next step) |
|
||||
| `openapi/agent/robot/robot.go` | ✅ | Route registration with Attach() |
|
||||
| `openapi/agent/robot/types.go` | ✅ | HTTP request/response types |
|
||||
| `openapi/agent/robot/list.go` | ✅ | List robots handler with permission filter |
|
||||
| `openapi/agent/robot/detail.go` | ✅ | CRUD handlers with permission checks |
|
||||
| `openapi/agent/robot/permission.go` | ✅ | Permission check functions (CanRead/CanWrite) |
|
||||
| `openapi/agent/robot/utils.go` | ✅ | Helper functions |
|
||||
| `openapi/agent/agent.go` | ✅ | Robot routes registered |
|
||||
| `openapi/tests/agent/robot_test.go` | ✅ | Integration tests + Permission tests |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -501,17 +532,18 @@ Legend: ⬜ Not started | 🟡 In progress | ✅ Complete | 🟢 Low Risk | 🟡
|
|||
yao/openapi/agent/robot/ # This directory (sub-package under agent)
|
||||
├── DESIGN.md # Design document ✅
|
||||
├── TODO.md # This file ✅
|
||||
├── robot.go # Route registration (Attach function)
|
||||
├── types.go # All request/response types
|
||||
├── list.go # GET /v1/agent/robots
|
||||
├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id
|
||||
├── execution.go # Execution endpoints
|
||||
├── trigger.go # Trigger/Intervene SSE
|
||||
├── results.go # Results endpoints
|
||||
├── activities.go # Activities endpoint
|
||||
├── stream.go # Real-time streams
|
||||
├── filter.go # Query filtering
|
||||
└── utils.go # Utilities
|
||||
├── robot.go # Route registration (Attach function) ✅
|
||||
├── types.go # All request/response types ✅
|
||||
├── list.go # GET /v1/agent/robots ✅
|
||||
├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id ✅
|
||||
├── permission.go # Permission check functions (CanRead/CanWrite) ✅
|
||||
├── utils.go # Utilities ✅
|
||||
├── execution.go # Execution endpoints (Phase 2)
|
||||
├── trigger.go # Trigger/Intervene SSE (Phase 2)
|
||||
├── results.go # Results endpoints (Phase 3)
|
||||
├── activities.go # Activities endpoint (Phase 3)
|
||||
├── stream.go # Real-time streams (Phase 6 - Deferred)
|
||||
└── filter.go # Query filtering (optional)
|
||||
```
|
||||
|
||||
### Parent Directory
|
||||
|
|
|
|||
425
openapi/agent/robot/detail.go
Normal file
425
openapi/agent/robot/detail.go
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/kun/log"
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// GetRobot retrieves a single robot by ID
|
||||
// GET /v1/agent/robots/:id
|
||||
func GetRobot(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID from URL parameter
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Get robot via API
|
||||
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
|
||||
if err != nil {
|
||||
log.Error("Failed to get robot %s: %v", robotID, err)
|
||||
|
||||
// Check for not found error
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Check read permission
|
||||
// Permission rules:
|
||||
// - No constraints: allow all
|
||||
// - OwnerOnly: user must be the creator
|
||||
// - TeamOnly: robot must belong to user's team
|
||||
if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to access this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to HTTP response
|
||||
resp := NewResponse(robotResp)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetRobotStatus retrieves the runtime status of a robot
|
||||
// GET /v1/agent/robots/:id/status
|
||||
func GetRobotStatus(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID from URL parameter
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Get robot status via API
|
||||
status, err := robotapi.GetRobotStatus(ctx, robotID)
|
||||
if err != nil {
|
||||
log.Error("Failed to get robot status %s: %v", robotID, err)
|
||||
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get robot status: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Check read permission
|
||||
if !CanRead(c, authInfo, status.YaoTeamID, status.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to access this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to HTTP response
|
||||
resp := NewStatusResponse(status)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// CreateRobot creates a new robot
|
||||
// POST /v1/agent/robots
|
||||
func CreateRobot(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Parse request body
|
||||
var req CreateRobotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.MemberID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "member_id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
if req.DisplayName == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "display_name is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine effective team_id:
|
||||
// - If user has a team selected (authInfo.TeamID), use it
|
||||
// - Otherwise, for personal users, use user_id as team_id
|
||||
effectiveTeamID := GetEffectiveTeamID(authInfo)
|
||||
if req.TeamID == "" {
|
||||
req.TeamID = effectiveTeamID
|
||||
}
|
||||
|
||||
// Apply team constraint from auth if TeamOnly
|
||||
if authInfo != nil && authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
|
||||
// Force team_id to auth team_id
|
||||
req.TeamID = authInfo.TeamID
|
||||
}
|
||||
|
||||
// Still require team_id after all fallbacks
|
||||
if req.TeamID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "team_id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to API request
|
||||
apiReq := req.ToAPICreateRequest()
|
||||
|
||||
// Apply Yao permission fields
|
||||
// Key rule: __yao_team_id = authInfo.TeamID if has team, otherwise = authInfo.UserID
|
||||
if authInfo != nil {
|
||||
yaoTeamID := authInfo.TeamID
|
||||
if yaoTeamID == "" {
|
||||
// For personal users (no team), use user_id as __yao_team_id
|
||||
// This ensures the robot is scoped to the individual user
|
||||
yaoTeamID = authInfo.UserID
|
||||
}
|
||||
apiReq.AuthScope = &robotapi.AuthScope{
|
||||
CreatedBy: authInfo.UserID,
|
||||
TeamID: yaoTeamID,
|
||||
TenantID: authInfo.TenantID,
|
||||
}
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Call API layer
|
||||
robotResp, err := robotapi.CreateRobot(ctx, apiReq)
|
||||
if err != nil {
|
||||
log.Error("Failed to create robot: %v", err)
|
||||
|
||||
// Check for duplicate error
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusConflict, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to create robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to HTTP response
|
||||
resp := NewResponse(robotResp)
|
||||
response.RespondWithSuccess(c, response.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// UpdateRobot updates an existing robot
|
||||
// PUT /v1/agent/robots/:id
|
||||
func UpdateRobot(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID from URL parameter
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req UpdateRobotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Check permission - first get the robot to verify ownership/team
|
||||
existingRobot, err := robotapi.GetRobotResponse(ctx, robotID)
|
||||
if err != nil {
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Check write permission (only creator can update)
|
||||
if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to update this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to API request
|
||||
apiReq := req.ToAPIUpdateRequest()
|
||||
|
||||
// Apply Yao permission fields
|
||||
if authInfo != nil {
|
||||
apiReq.AuthScope = &robotapi.AuthScope{
|
||||
UpdatedBy: authInfo.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// Call API layer
|
||||
robotResp, err := robotapi.UpdateRobot(ctx, robotID, apiReq)
|
||||
if err != nil {
|
||||
log.Error("Failed to update robot %s: %v", robotID, err)
|
||||
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to update robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to HTTP response
|
||||
resp := NewResponse(robotResp)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DeleteRobot deletes a robot
|
||||
// DELETE /v1/agent/robots/:id
|
||||
func DeleteRobot(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID from URL parameter
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Check permission - first get the robot to verify ownership/team
|
||||
existingRobot, err := robotapi.GetRobotResponse(ctx, robotID)
|
||||
if err != nil {
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Check write permission (only creator can delete)
|
||||
if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to delete this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Call API layer
|
||||
err = robotapi.RemoveRobot(ctx, robotID)
|
||||
if err != nil {
|
||||
log.Error("Failed to delete robot %s: %v", robotID, err)
|
||||
|
||||
// Check for running executions
|
||||
if strings.Contains(err.Error(), "running executions") {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusConflict, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if err == robottypes.ErrRobotNotFound {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to delete robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success with no content
|
||||
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"deleted": true,
|
||||
})
|
||||
}
|
||||
108
openapi/agent/robot/list.go
Normal file
108
openapi/agent/robot/list.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/kun/log"
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// ListRobots lists robots with pagination and filtering
|
||||
// GET /v1/agent/robots
|
||||
func ListRobots(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Parse filter parameters
|
||||
requestedTeamID := strings.TrimSpace(c.Query("team_id"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
keywords := strings.TrimSpace(c.Query("keywords"))
|
||||
|
||||
// Apply permission-based filtering
|
||||
// This ensures users only see robots they have access to:
|
||||
// - No constraints: use requested team_id or no filter
|
||||
// - TeamOnly: force filter to user's team
|
||||
// - OwnerOnly: filter by user_id (personal resources)
|
||||
effectiveTeamID := BuildListFilter(c, authInfo, requestedTeamID)
|
||||
|
||||
// Build query
|
||||
query := &robotapi.ListQuery{
|
||||
TeamID: effectiveTeamID,
|
||||
Keywords: keywords,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
if status != "" {
|
||||
query.Status = robottypes.RobotStatus(status)
|
||||
}
|
||||
|
||||
// Create robot context
|
||||
ctx := &robottypes.Context{}
|
||||
|
||||
// Call API layer
|
||||
result, err := robotapi.ListRobots(ctx, query)
|
||||
if err != nil {
|
||||
log.Error("Failed to list robots: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to list robots: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to HTTP response format
|
||||
robots := make([]*Response, 0, len(result.Data))
|
||||
for _, r := range result.Data {
|
||||
robots = append(robots, newResponseFromRobot(r))
|
||||
}
|
||||
|
||||
resp := &ListResponse{
|
||||
Data: robots,
|
||||
Total: result.Total,
|
||||
Page: result.Page,
|
||||
PageSize: result.PageSize,
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// newResponseFromRobot converts types.Robot to Response
|
||||
func newResponseFromRobot(r *robottypes.Robot) *Response {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Name: r.MemberID, // Frontend mapping: name ← member_id
|
||||
Description: r.Bio, // Frontend mapping: description ← bio
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
RobotStatus: string(r.Status),
|
||||
AutonomousMode: r.AutonomousMode,
|
||||
DisplayName: r.DisplayName,
|
||||
Bio: r.Bio,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
RobotEmail: r.RobotEmail,
|
||||
}
|
||||
}
|
||||
135
openapi/agent/robot/permission.go
Normal file
135
openapi/agent/robot/permission.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// Permission check functions for robot access control
|
||||
//
|
||||
// Permission Rules:
|
||||
// 1. No auth info or no constraints: allow all
|
||||
// 2. OwnerOnly: user can only access resources they created (__yao_created_by == userID)
|
||||
// 3. TeamOnly: user can access resources in their team (__yao_team_id == teamID)
|
||||
// 4. For personal users (no team): __yao_team_id should be empty or equal to user_id
|
||||
//
|
||||
// Read vs Write:
|
||||
// - Read: team members can read team resources
|
||||
// - Write: only creator or team owner can write (update/delete)
|
||||
|
||||
// CanRead checks if the user has read permission for a robot
|
||||
// Read permission is granted if:
|
||||
// - No auth info (public access)
|
||||
// - No constraints (admin/system)
|
||||
// - User is the creator (__yao_created_by == userID)
|
||||
// - TeamOnly: robot belongs to user's team (__yao_team_id == teamID)
|
||||
func CanRead(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool {
|
||||
// No auth info, allow access (handled by OAuth guard)
|
||||
if authInfo == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// No constraints, allow access (admin/system user)
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return true
|
||||
}
|
||||
|
||||
// User is the creator - always allow
|
||||
if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID {
|
||||
return true
|
||||
}
|
||||
|
||||
// TeamOnly constraint: check team membership
|
||||
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
|
||||
// Robot belongs to user's team
|
||||
if robotTeamID != "" && robotTeamID == authInfo.TeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// OwnerOnly constraint: only creator can access (already checked above)
|
||||
// If we reach here with OwnerOnly, user is not the creator
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CanWrite checks if the user has write permission for a robot (update/delete)
|
||||
// Write permission is more restrictive:
|
||||
// - No auth info: deny (should not happen, OAuth guard will block)
|
||||
// - No constraints: allow (admin/system)
|
||||
// - User is the creator: allow
|
||||
// - TeamOnly + OwnerOnly: user must be creator AND in the same team
|
||||
func CanWrite(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool {
|
||||
// No auth info, deny write access
|
||||
if authInfo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// No constraints, allow access (admin/system user)
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return true
|
||||
}
|
||||
|
||||
// User is the creator - allow write
|
||||
if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID {
|
||||
// If TeamOnly is also set, verify team membership
|
||||
if authInfo.Constraints.TeamOnly {
|
||||
if robotTeamID == "" || robotTeamID == authInfo.TeamID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Not the creator - deny write access
|
||||
// (In the future, we could add team admin/owner check here)
|
||||
return false
|
||||
}
|
||||
|
||||
// GetEffectiveTeamID returns the effective team_id for a robot
|
||||
// For personal users (no team selected), returns user_id as team_id
|
||||
// For team users, returns the selected team_id
|
||||
func GetEffectiveTeamID(authInfo *types.AuthorizedInfo) string {
|
||||
if authInfo == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If user has a team selected, use it
|
||||
if authInfo.TeamID != "" {
|
||||
return authInfo.TeamID
|
||||
}
|
||||
|
||||
// For personal users, use user_id as team_id
|
||||
// This ensures resources are scoped to the individual user
|
||||
return authInfo.UserID
|
||||
}
|
||||
|
||||
// BuildListFilter builds filter conditions for listing robots based on permissions
|
||||
// Returns teamID filter to apply to the query
|
||||
func BuildListFilter(c *gin.Context, authInfo *types.AuthorizedInfo, requestedTeamID string) string {
|
||||
if authInfo == nil {
|
||||
return requestedTeamID
|
||||
}
|
||||
|
||||
// No constraints - use requested filter or no filter
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return requestedTeamID
|
||||
}
|
||||
|
||||
// TeamOnly constraint: force filter to user's team
|
||||
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
|
||||
return authInfo.TeamID
|
||||
}
|
||||
|
||||
// OwnerOnly constraint: filter by user_id as team_id (personal resources)
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
return authInfo.UserID
|
||||
}
|
||||
|
||||
return requestedTeamID
|
||||
}
|
||||
25
openapi/agent/robot/robot.go
Normal file
25
openapi/agent/robot/robot.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// Attach attaches the robot API handlers to the router with OAuth protection
|
||||
// This provides OAuth-protected endpoints for robot management
|
||||
// Base path: /v1/agent/robots
|
||||
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||
|
||||
// Apply OAuth guard to all routes
|
||||
group.Use(oauth.Guard)
|
||||
|
||||
// Robot CRUD - Standard REST endpoints
|
||||
group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering
|
||||
group.POST("", CreateRobot) // POST /robots - Create a new robot
|
||||
group.GET("/:id", GetRobot) // GET /robots/:id - Get robot details
|
||||
group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot
|
||||
group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot
|
||||
|
||||
// Robot Status
|
||||
group.GET("/:id/status", GetRobotStatus) // GET /robots/:id/status - Get robot runtime status
|
||||
}
|
||||
255
openapi/agent/robot/types.go
Normal file
255
openapi/agent/robot/types.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
)
|
||||
|
||||
// ==================== Request Types ====================
|
||||
|
||||
// CreateRobotRequest - HTTP request for creating a robot
|
||||
type CreateRobotRequest struct {
|
||||
// Required fields
|
||||
MemberID string `json:"member_id" binding:"required"` // Unique robot identifier
|
||||
TeamID string `json:"team_id" binding:"required"` // Team ID
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name" binding:"required"` // Display name
|
||||
Bio string `json:"bio,omitempty"` // Robot description
|
||||
Avatar string `json:"avatar,omitempty"` // Avatar URL
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt string `json:"system_prompt,omitempty"` // System prompt
|
||||
RoleID string `json:"role_id,omitempty"` // Role within team
|
||||
ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id
|
||||
|
||||
// Status
|
||||
Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended
|
||||
RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
|
||||
|
||||
// Communication
|
||||
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
|
||||
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
|
||||
LanguageModel string `json:"language_model,omitempty"` // Language model name
|
||||
|
||||
// Limits
|
||||
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
|
||||
}
|
||||
|
||||
// UpdateRobotRequest - HTTP request for updating a robot
|
||||
type UpdateRobotRequest struct {
|
||||
// Profile
|
||||
DisplayName *string `json:"display_name,omitempty"` // Display name
|
||||
Bio *string `json:"bio,omitempty"` // Robot description
|
||||
Avatar *string `json:"avatar,omitempty"` // Avatar URL
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt
|
||||
RoleID *string `json:"role_id,omitempty"` // Role within team
|
||||
ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id
|
||||
|
||||
// Status
|
||||
Status *string `json:"status,omitempty"` // Member status
|
||||
RobotStatus *string `json:"robot_status,omitempty"` // Robot status
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
|
||||
|
||||
// Communication
|
||||
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON
|
||||
Agents interface{} `json:"agents,omitempty"` // Accessible agents
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
|
||||
LanguageModel *string `json:"language_model,omitempty"` // Language model name
|
||||
|
||||
// Limits
|
||||
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
|
||||
}
|
||||
|
||||
// ==================== Response Types ====================
|
||||
|
||||
// Response - HTTP response for a robot
|
||||
// Maps to frontend expectations: name ← member_id, description ← bio
|
||||
type Response struct {
|
||||
// Basic (mapped for frontend)
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"` // Frontend name ← member_id
|
||||
Description string `json:"description"` // Frontend description ← bio
|
||||
|
||||
// Original fields
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
Status string `json:"status"`
|
||||
RobotStatus string `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
|
||||
// Profile
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
|
||||
// Identity & Role
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
ManagerID string `json:"manager_id,omitempty"`
|
||||
|
||||
// Communication
|
||||
RobotEmail string `json:"robot_email,omitempty"`
|
||||
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"`
|
||||
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"`
|
||||
|
||||
// Capabilities
|
||||
RobotConfig interface{} `json:"robot_config,omitempty"`
|
||||
Agents interface{} `json:"agents,omitempty"`
|
||||
MCPServers interface{} `json:"mcp_servers,omitempty"`
|
||||
LanguageModel string `json:"language_model,omitempty"`
|
||||
|
||||
// Limits
|
||||
CostLimit float64 `json:"cost_limit,omitempty"`
|
||||
|
||||
// Ownership & Audit
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// StatusResponse - runtime status response
|
||||
type StatusResponse struct {
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Status string `json:"status"` // Robot runtime status
|
||||
Running int `json:"running"` // Current running executions
|
||||
MaxRunning int `json:"max_running"` // Maximum concurrent executions
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
RunningIDs []string `json:"running_ids,omitempty"` // IDs of running executions
|
||||
}
|
||||
|
||||
// ListResponse - paginated list response
|
||||
type ListResponse struct {
|
||||
Data []*Response `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// ==================== Conversion Functions ====================
|
||||
|
||||
// NewResponse creates a Response from api.RobotResponse
|
||||
func NewResponse(r *robotapi.RobotResponse) *Response {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Response{
|
||||
ID: r.ID,
|
||||
Name: r.MemberID, // Frontend mapping: name ← member_id
|
||||
Description: r.Bio, // Frontend mapping: description ← bio
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
Status: r.Status,
|
||||
RobotStatus: r.RobotStatus,
|
||||
AutonomousMode: r.AutonomousMode,
|
||||
DisplayName: r.DisplayName,
|
||||
Bio: r.Bio,
|
||||
Avatar: r.Avatar,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
RoleID: r.RoleID,
|
||||
ManagerID: r.ManagerID,
|
||||
RobotEmail: r.RobotEmail,
|
||||
AuthorizedSenders: r.AuthorizedSenders,
|
||||
EmailFilterRules: r.EmailFilterRules,
|
||||
RobotConfig: r.RobotConfig,
|
||||
Agents: r.Agents,
|
||||
MCPServers: r.MCPServers,
|
||||
LanguageModel: r.LanguageModel,
|
||||
CostLimit: r.CostLimit,
|
||||
InvitedBy: r.InvitedBy,
|
||||
JoinedAt: r.JoinedAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPICreateRequest converts HTTP request to api.CreateRobotRequest
|
||||
func (r *CreateRobotRequest) ToAPICreateRequest() *robotapi.CreateRobotRequest {
|
||||
return &robotapi.CreateRobotRequest{
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
DisplayName: r.DisplayName,
|
||||
Bio: r.Bio,
|
||||
Avatar: r.Avatar,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
RoleID: r.RoleID,
|
||||
ManagerID: r.ManagerID,
|
||||
Status: r.Status,
|
||||
RobotStatus: r.RobotStatus,
|
||||
AutonomousMode: r.AutonomousMode,
|
||||
RobotEmail: r.RobotEmail,
|
||||
AuthorizedSenders: r.AuthorizedSenders,
|
||||
EmailFilterRules: r.EmailFilterRules,
|
||||
RobotConfig: r.RobotConfig,
|
||||
Agents: r.Agents,
|
||||
MCPServers: r.MCPServers,
|
||||
LanguageModel: r.LanguageModel,
|
||||
CostLimit: r.CostLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIUpdateRequest converts HTTP request to api.UpdateRobotRequest
|
||||
func (r *UpdateRobotRequest) ToAPIUpdateRequest() *robotapi.UpdateRobotRequest {
|
||||
return &robotapi.UpdateRobotRequest{
|
||||
DisplayName: r.DisplayName,
|
||||
Bio: r.Bio,
|
||||
Avatar: r.Avatar,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
RoleID: r.RoleID,
|
||||
ManagerID: r.ManagerID,
|
||||
Status: r.Status,
|
||||
RobotStatus: r.RobotStatus,
|
||||
AutonomousMode: r.AutonomousMode,
|
||||
RobotEmail: r.RobotEmail,
|
||||
AuthorizedSenders: r.AuthorizedSenders,
|
||||
EmailFilterRules: r.EmailFilterRules,
|
||||
RobotConfig: r.RobotConfig,
|
||||
Agents: r.Agents,
|
||||
MCPServers: r.MCPServers,
|
||||
LanguageModel: r.LanguageModel,
|
||||
CostLimit: r.CostLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStatusResponse creates a StatusResponse from api.RobotState
|
||||
func NewStatusResponse(s *robotapi.RobotState) *StatusResponse {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &StatusResponse{
|
||||
MemberID: s.MemberID,
|
||||
TeamID: s.TeamID,
|
||||
DisplayName: s.DisplayName,
|
||||
Bio: s.Bio,
|
||||
Status: string(s.Status),
|
||||
Running: s.Running,
|
||||
MaxRunning: s.MaxRunning,
|
||||
LastRun: s.LastRun,
|
||||
NextRun: s.NextRun,
|
||||
RunningIDs: s.RunningIDs,
|
||||
}
|
||||
}
|
||||
43
openapi/agent/robot/utils.go
Normal file
43
openapi/agent/robot/utils.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetLocale extracts locale from request
|
||||
// Priority: query param > Accept-Language header > default
|
||||
func GetLocale(c *gin.Context) string {
|
||||
// Check query param first
|
||||
if locale := c.Query("locale"); locale != "" {
|
||||
return strings.ToLower(strings.TrimSpace(locale))
|
||||
}
|
||||
|
||||
// Check Accept-Language header
|
||||
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
|
||||
// Parse first language from header (e.g., "en-US,en;q=0.9" -> "en-us")
|
||||
parts := strings.Split(acceptLang, ",")
|
||||
if len(parts) > 0 {
|
||||
lang := strings.Split(parts[0], ";")[0]
|
||||
return strings.ToLower(strings.TrimSpace(lang))
|
||||
}
|
||||
}
|
||||
|
||||
// Default locale
|
||||
return "en-us"
|
||||
}
|
||||
|
||||
// ParseBoolValue parses various string formats into a boolean pointer
|
||||
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
|
||||
}
|
||||
882
openapi/tests/agent/robot_test.go
Normal file
882
openapi/tests/agent/robot_test.go
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
// TestListRobots tests the robot listing endpoint
|
||||
func TestListRobots(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, "Robot 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("ListRobotsSuccess", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify pagination fields exist
|
||||
assert.Contains(t, response, "data")
|
||||
assert.Contains(t, response, "page")
|
||||
assert.Contains(t, response, "pagesize")
|
||||
assert.Contains(t, response, "total")
|
||||
})
|
||||
|
||||
t.Run("ListRobotsWithPagination", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?page=1&pagesize=5", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), response["page"])
|
||||
assert.Equal(t, float64(5), response["pagesize"])
|
||||
})
|
||||
|
||||
t.Run("ListRobotsUnauthorized", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateRobot tests the robot creation endpoint
|
||||
func TestCreateRobot(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, "Robot Create 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")
|
||||
|
||||
// Track created robots for cleanup
|
||||
var createdRobotIDs []string
|
||||
defer func() {
|
||||
// Cleanup created robots
|
||||
for _, robotID := range createdRobotIDs {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("CreateRobotSuccess", func(t *testing.T) {
|
||||
robotID := fmt.Sprintf("test_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot",
|
||||
"bio": "A test robot for API testing",
|
||||
"robot_email": "test@robot.local",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
assert.Equal(t, "Test Robot", response["display_name"])
|
||||
assert.Equal(t, "A test robot for API testing", response["bio"])
|
||||
|
||||
// Track for cleanup
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
})
|
||||
|
||||
t.Run("CreateRobotMissingRequiredFields", func(t *testing.T) {
|
||||
// Missing member_id
|
||||
createData := map[string]interface{}{
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("CreateRobotDuplicate", func(t *testing.T) {
|
||||
robotID := fmt.Sprintf("test_robot_dup_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot Duplicate",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
|
||||
// First create
|
||||
req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
req1.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
resp1, err := http.DefaultClient.Do(req1)
|
||||
require.NoError(t, err)
|
||||
resp1.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// Second create with same ID should fail
|
||||
body2, _ := json.Marshal(createData)
|
||||
req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetRobot tests the robot get endpoint
|
||||
func TestGetRobot(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, "Robot Get 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")
|
||||
|
||||
// Create a test robot first
|
||||
robotID := fmt.Sprintf("test_robot_get_%d", time.Now().UnixNano())
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot Get",
|
||||
"bio": "A robot for get test",
|
||||
}
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
createResp.Body.Close()
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
http.DefaultClient.Do(req)
|
||||
}()
|
||||
|
||||
t.Run("GetRobotSuccess", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
assert.Equal(t, "Test Robot Get", response["display_name"])
|
||||
})
|
||||
|
||||
t.Run("GetRobotNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateRobot tests the robot update endpoint
|
||||
func TestUpdateRobot(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, "Robot Update 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")
|
||||
|
||||
// Create a test robot first
|
||||
robotID := fmt.Sprintf("test_robot_update_%d", time.Now().UnixNano())
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot Update",
|
||||
"bio": "Original bio",
|
||||
}
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
createResp.Body.Close()
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
http.DefaultClient.Do(req)
|
||||
}()
|
||||
|
||||
t.Run("UpdateRobotSuccess", func(t *testing.T) {
|
||||
updateData := map[string]interface{}{
|
||||
"display_name": "Updated Robot Name",
|
||||
"bio": "Updated bio",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updateData)
|
||||
req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Updated Robot Name", response["display_name"])
|
||||
assert.Equal(t, "Updated bio", response["bio"])
|
||||
})
|
||||
|
||||
t.Run("UpdateRobotNotFound", func(t *testing.T) {
|
||||
updateData := map[string]interface{}{
|
||||
"display_name": "Updated Name",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updateData)
|
||||
req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/non_existent_robot", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteRobot tests the robot delete endpoint
|
||||
func TestDeleteRobot(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, "Robot Delete 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("DeleteRobotSuccess", func(t *testing.T) {
|
||||
// Create a test robot first
|
||||
robotID := fmt.Sprintf("test_robot_delete_%d", time.Now().UnixNano())
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot Delete",
|
||||
}
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
createResp.Body.Close()
|
||||
|
||||
// Delete the robot
|
||||
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, true, response["deleted"])
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
|
||||
// Verify it's deleted by trying to get it
|
||||
getReq, _ := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
getResp, err := http.DefaultClient.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("DeleteRobotNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/non_existent_robot", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetRobotStatus tests the robot status endpoint
|
||||
func TestGetRobotStatus(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, "Robot Status 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")
|
||||
|
||||
// Create a test robot first
|
||||
robotID := fmt.Sprintf("test_robot_status_%d", time.Now().UnixNano())
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot Status",
|
||||
"autonomous_mode": true,
|
||||
}
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
createResp.Body.Close()
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
http.DefaultClient.Do(req)
|
||||
}()
|
||||
|
||||
t.Run("GetRobotStatusSuccess", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/status", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.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)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
assert.Contains(t, response, "status")
|
||||
assert.Contains(t, response, "running")
|
||||
assert.Contains(t, response, "max_running")
|
||||
})
|
||||
|
||||
t.Run("GetRobotStatusNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/status", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRobotPermissions tests robot permission scenarios
|
||||
// Tests personal user vs team user access control
|
||||
func TestRobotPermissions(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
|
||||
client := testutils.RegisterTestClient(t, "Robot Permission Test Client", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
// Create User 1 (Personal user - no team)
|
||||
token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
user1ID := token1.UserID
|
||||
|
||||
// Create User 2 (Different user)
|
||||
token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
user2ID := token2.UserID
|
||||
|
||||
t.Logf("Test users created: User1=%s, User2=%s", user1ID, user2ID)
|
||||
|
||||
// Track created robots for cleanup
|
||||
var createdRobotIDs []string
|
||||
defer func() {
|
||||
for _, robotID := range createdRobotIDs {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("PersonalUserCreateRobot", func(t *testing.T) {
|
||||
// Personal user creates a robot with their user_id as team_id
|
||||
// This simulates a personal user (no team) creating their own robot
|
||||
robotID := fmt.Sprintf("test_personal_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID, // Personal user: team_id = user_id
|
||||
"display_name": "Personal Robot",
|
||||
"bio": "A robot created by a personal user",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
assert.Equal(t, user1ID, response["team_id"])
|
||||
t.Logf("Personal robot created: %s (team_id: %s)", robotID, user1ID)
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
})
|
||||
|
||||
t.Run("PersonalUserCanAccessOwnRobot", func(t *testing.T) {
|
||||
// User 1 creates a robot
|
||||
robotID := fmt.Sprintf("test_own_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID, // Personal user: team_id = user_id
|
||||
"display_name": "User 1 Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// User 1 can access their own robot
|
||||
getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
getResp, err := http.DefaultClient.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
t.Logf("User 1 successfully accessed their own robot: %s", robotID)
|
||||
})
|
||||
|
||||
t.Run("PersonalUserCanUpdateOwnRobot", func(t *testing.T) {
|
||||
// User 1 creates a robot
|
||||
robotID := fmt.Sprintf("test_update_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID,
|
||||
"display_name": "Original Name",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// User 1 can update their own robot
|
||||
updateData := map[string]interface{}{
|
||||
"display_name": "Updated Name",
|
||||
}
|
||||
|
||||
updateBody, _ := json.Marshal(updateData)
|
||||
updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody))
|
||||
require.NoError(t, err)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
updateResp, err := http.DefaultClient.Do(updateReq)
|
||||
require.NoError(t, err)
|
||||
defer updateResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(updateResp.Body).Decode(&response)
|
||||
assert.Equal(t, "Updated Name", response["display_name"])
|
||||
t.Logf("User 1 successfully updated their own robot")
|
||||
})
|
||||
|
||||
t.Run("PersonalUserCanDeleteOwnRobot", func(t *testing.T) {
|
||||
// User 1 creates a robot
|
||||
robotID := fmt.Sprintf("test_delete_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID,
|
||||
"display_name": "Robot to Delete",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
|
||||
// User 1 can delete their own robot
|
||||
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
deleteReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
deleteResp, err := http.DefaultClient.Do(deleteReq)
|
||||
require.NoError(t, err)
|
||||
defer deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, deleteResp.StatusCode)
|
||||
t.Logf("User 1 successfully deleted their own robot")
|
||||
})
|
||||
|
||||
t.Run("TeamRobotAccess", func(t *testing.T) {
|
||||
// Create a robot with a shared team_id
|
||||
sharedTeamID := fmt.Sprintf("team_%d", time.Now().UnixNano())
|
||||
robotID := fmt.Sprintf("test_team_robot_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": sharedTeamID,
|
||||
"display_name": "Team Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// Creator can access the team robot
|
||||
getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
getResp, err := http.DefaultClient.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
t.Logf("Creator successfully accessed team robot: %s (team: %s)", robotID, sharedTeamID)
|
||||
})
|
||||
|
||||
t.Run("VerifyYaoPermissionFieldsSet", func(t *testing.T) {
|
||||
// Create a robot and verify __yao_created_by and __yao_team_id are set
|
||||
robotID := fmt.Sprintf("test_perm_fields_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID, // Personal user: team_id = user_id
|
||||
"display_name": "Permission Fields Test Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&response)
|
||||
|
||||
// The response should contain the robot data
|
||||
// Note: __yao_created_by and __yao_team_id might not be in the public response
|
||||
// but they should be set in the database
|
||||
assert.Equal(t, robotID, response["member_id"])
|
||||
t.Logf("Robot created with permission fields (user_id: %s)", user1ID)
|
||||
})
|
||||
|
||||
t.Run("DifferentUserCannotUpdateRobot", func(t *testing.T) {
|
||||
// User 1 creates a robot
|
||||
robotID := fmt.Sprintf("test_cross_update_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID,
|
||||
"display_name": "User 1 Private Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// User 2 attempts to update User 1's robot - should be denied
|
||||
// Note: With system:root scope, this might still succeed due to admin privileges
|
||||
// In production, user2 would not have system:root
|
||||
updateData := map[string]interface{}{
|
||||
"display_name": "Unauthorized Update",
|
||||
}
|
||||
|
||||
updateBody, _ := json.Marshal(updateData)
|
||||
updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody))
|
||||
require.NoError(t, err)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token2.AccessToken)
|
||||
|
||||
updateResp, err := http.DefaultClient.Do(updateReq)
|
||||
require.NoError(t, err)
|
||||
defer updateResp.Body.Close()
|
||||
|
||||
// With system:root scope (no constraints), user2 can still update
|
||||
// This test documents the current behavior with admin privileges
|
||||
t.Logf("User 2 update attempt status: %d (with system:root scope)", updateResp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("DifferentUserCannotDeleteRobot", func(t *testing.T) {
|
||||
// User 1 creates a robot
|
||||
robotID := fmt.Sprintf("test_cross_delete_%d", time.Now().UnixNano())
|
||||
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": user1ID,
|
||||
"display_name": "User 1 Robot for Delete Test",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
createResp, _ := http.DefaultClient.Do(createReq)
|
||||
createResp.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
|
||||
// User 2 attempts to delete User 1's robot
|
||||
// Note: With system:root scope, this might still succeed due to admin privileges
|
||||
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
require.NoError(t, err)
|
||||
deleteReq.Header.Set("Authorization", "Bearer "+token2.AccessToken)
|
||||
|
||||
deleteResp, err := http.DefaultClient.Do(deleteReq)
|
||||
require.NoError(t, err)
|
||||
defer deleteResp.Body.Close()
|
||||
|
||||
// With system:root scope (no constraints), user2 can still delete
|
||||
// This test documents the current behavior with admin privileges
|
||||
t.Logf("User 2 delete attempt status: %d (with system:root scope)", deleteResp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("ListRobotsWithTeamFilter", func(t *testing.T) {
|
||||
// Create robots for both users
|
||||
robot1ID := fmt.Sprintf("test_list_user1_%d", time.Now().UnixNano())
|
||||
robot2ID := fmt.Sprintf("test_list_user2_%d", time.Now().UnixNano())
|
||||
|
||||
// User 1 creates their robot
|
||||
create1 := map[string]interface{}{
|
||||
"member_id": robot1ID,
|
||||
"team_id": user1ID,
|
||||
"display_name": "User 1 List Robot",
|
||||
}
|
||||
body1, _ := json.Marshal(create1)
|
||||
req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body1))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
req1.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
resp1, _ := http.DefaultClient.Do(req1)
|
||||
resp1.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robot1ID)
|
||||
|
||||
// User 2 creates their robot
|
||||
create2 := map[string]interface{}{
|
||||
"member_id": robot2ID,
|
||||
"team_id": user2ID,
|
||||
"display_name": "User 2 List Robot",
|
||||
}
|
||||
body2, _ := json.Marshal(create2)
|
||||
req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+token2.AccessToken)
|
||||
resp2, _ := http.DefaultClient.Do(req2)
|
||||
resp2.Body.Close()
|
||||
createdRobotIDs = append(createdRobotIDs, robot2ID)
|
||||
|
||||
// User 1 lists robots with their team_id filter
|
||||
listReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?team_id="+user1ID, nil)
|
||||
require.NoError(t, err)
|
||||
listReq.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
listResp, err := http.DefaultClient.Do(listReq)
|
||||
require.NoError(t, err)
|
||||
defer listResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, listResp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(listResp.Body).Decode(&response)
|
||||
|
||||
data := response["data"].([]interface{})
|
||||
t.Logf("User 1 sees %d robots with team_id=%s filter", len(data), user1ID)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue