完善角色权限与数据隔离
This commit is contained in:
@@ -74,7 +74,6 @@ func multipartImageRequest(t *testing.T, method, target string, fileField string
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
func createTenant(t *testing.T, name, status string) model.Tenant {
|
||||
t.Helper()
|
||||
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)}
|
||||
@@ -381,6 +380,7 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
tenant := createTenant(t, "工作台租户", "normal")
|
||||
agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent")
|
||||
agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent")
|
||||
supervisor := createUser(t, tenant.ID, "workbench-supervisor", "supervisor")
|
||||
customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"}
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
t.Fatalf("创建工作台客户失败: %v", err)
|
||||
@@ -446,8 +446,14 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
transferRecorder := httptest.NewRecorder()
|
||||
transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID))
|
||||
router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne))
|
||||
if transferRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String())
|
||||
if transferRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("普通客服转接状态码 = %d,期望 %d,响应 = %s", transferRecorder.Code, http.StatusForbidden, transferRecorder.Body.String())
|
||||
}
|
||||
|
||||
managerTransferRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(managerTransferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, supervisor))
|
||||
if managerTransferRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("主管转接会话失败: %d %s", managerTransferRecorder.Code, managerTransferRecorder.Body.String())
|
||||
}
|
||||
|
||||
oldAgentMessageRecorder := httptest.NewRecorder()
|
||||
@@ -630,6 +636,149 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRoleCodeIsStableAndInvalidPermissionUpdateIsAtomic(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "自定义角色租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "role-admin", "admin")
|
||||
|
||||
// 首次访问角色列表会初始化权限码和内置角色。
|
||||
seedRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(seedRecorder, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
|
||||
if seedRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("初始化角色失败: %d %s", seedRecorder.Code, seedRecorder.Body.String())
|
||||
}
|
||||
|
||||
createRecorder := httptest.NewRecorder()
|
||||
createBody := []byte(`{"name":"VIP 客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(createRecorder, bearerRequest(t, http.MethodPost, "/api/roles", createBody, admin))
|
||||
if createRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("创建自定义角色失败: %d %s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
var createResponse struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createRecorder.Body.Bytes(), &createResponse); err != nil {
|
||||
t.Fatalf("解析自定义角色失败: %v", err)
|
||||
}
|
||||
originalCode := createResponse.Data.Code
|
||||
if originalCode == "" {
|
||||
t.Fatalf("自定义角色缺少稳定标识: %s", createRecorder.Body.String())
|
||||
}
|
||||
|
||||
updateRecorder := httptest.NewRecorder()
|
||||
updateBody := []byte(`{"name":"VIP 专属客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(updateRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", createResponse.Data.ID), updateBody, admin))
|
||||
if updateRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("重命名自定义角色失败: %d %s", updateRecorder.Code, updateRecorder.Body.String())
|
||||
}
|
||||
var role model.Role
|
||||
if err := model.DB.First(&role, createResponse.Data.ID).Error; err != nil {
|
||||
t.Fatalf("查询重命名角色失败: %v", err)
|
||||
}
|
||||
if role.Code != originalCode {
|
||||
t.Fatalf("角色改名后标识发生变化: %q -> %q", originalCode, role.Code)
|
||||
}
|
||||
|
||||
invalidRecorder := httptest.NewRecorder()
|
||||
invalidBody := []byte(`{"permissions":["permission.not_exists"],"data_scopes":{"customer":"all"}}`)
|
||||
router.ServeHTTP(invalidRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), invalidBody, admin))
|
||||
if invalidRecorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("无效权限保存状态码 = %d,期望 %d,响应 = %s", invalidRecorder.Code, http.StatusBadRequest, invalidRecorder.Body.String())
|
||||
}
|
||||
var permissionCount int64
|
||||
model.DB.Table("role_permissions rp").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("rp.role_id = ? AND p.code = ?", role.ID, "customer.view").
|
||||
Count(&permissionCount)
|
||||
if permissionCount != 1 {
|
||||
t.Fatalf("无效保存破坏了原权限配置,customer.view 数量 = %d", permissionCount)
|
||||
}
|
||||
|
||||
customUser := createUser(t, tenant.ID, "vip-agent", role.Code)
|
||||
beforeVersionRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(beforeVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser))
|
||||
beforeVersion := beforeVersionRecorder.Header().Get("X-Permission-Version")
|
||||
if beforeVersionRecorder.Code != http.StatusOK || beforeVersion == "" {
|
||||
t.Fatalf("首次权限版本响应异常: %d version=%q body=%s", beforeVersionRecorder.Code, beforeVersion, beforeVersionRecorder.Body.String())
|
||||
}
|
||||
|
||||
validRecorder := httptest.NewRecorder()
|
||||
validBody := []byte(`{"permissions":["customer.view","customer.create"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(validRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), validBody, admin))
|
||||
if validRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("更新自定义角色权限失败: %d %s", validRecorder.Code, validRecorder.Body.String())
|
||||
}
|
||||
|
||||
afterVersionRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(afterVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser))
|
||||
afterVersion := afterVersionRecorder.Header().Get("X-Permission-Version")
|
||||
if afterVersionRecorder.Code != http.StatusOK || afterVersion == "" || afterVersion == beforeVersion {
|
||||
t.Fatalf("权限更新后版本未推进: before=%q after=%q body=%s", beforeVersion, afterVersion, afterVersionRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndStatisticsDataScopesPreventCrossAgentAccess(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "数据范围租户", "normal")
|
||||
agentOne := createUser(t, tenant.ID, "scope-agent-one", "agent")
|
||||
agentTwo := createUser(t, tenant.ID, "scope-agent-two", "agent")
|
||||
customerOne := model.Customer{TenantID: tenant.ID, Name: "客服一客户", Source: "网页"}
|
||||
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客服二客户", Source: "网页"}
|
||||
if err := model.DB.Create(&customerOne).Error; err != nil {
|
||||
t.Fatalf("创建客服一客户失败: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&customerTwo).Error; err != nil {
|
||||
t.Fatalf("创建客服二客户失败: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
sessions := []model.Session{
|
||||
{TenantID: tenant.ID, CustomerID: customerOne.ID, AgentID: &agentOne.ID, Status: "ended", CreatedAt: now},
|
||||
{TenantID: tenant.ID, CustomerID: customerTwo.ID, AgentID: &agentTwo.ID, Status: "ended", CreatedAt: now},
|
||||
}
|
||||
if err := model.DB.Create(&sessions).Error; err != nil {
|
||||
t.Fatalf("创建数据范围会话失败: %v", err)
|
||||
}
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/customers", nil, agentOne))
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("查询本人客户失败: %d %s", listRecorder.Code, listRecorder.Body.String())
|
||||
}
|
||||
var listResponse struct {
|
||||
List []model.Customer `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(listRecorder.Body.Bytes(), &listResponse); err != nil || len(listResponse.List) != 1 || listResponse.List[0].ID != customerOne.ID {
|
||||
t.Fatalf("客户数据范围未生效: %s", listRecorder.Body.String())
|
||||
}
|
||||
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(detailRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/customers/%d", customerTwo.ID), nil, agentOne))
|
||||
if detailRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("跨客服读取客户状态码 = %d,期望 %d", detailRecorder.Code, http.StatusForbidden)
|
||||
}
|
||||
var deniedCount int64
|
||||
model.DB.Model(&model.OperationLog{}).
|
||||
Where("operator_id = ? AND action = ? AND target_type = ?", agentOne.ID, "data_access_denied", "customer").
|
||||
Count(&deniedCount)
|
||||
if deniedCount != 1 {
|
||||
t.Fatalf("客户越权未记录审计日志,数量 = %d", deniedCount)
|
||||
}
|
||||
|
||||
statsRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(statsRecorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi?period=today", nil, agentOne))
|
||||
if statsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("查询个人统计失败: %d %s", statsRecorder.Code, statsRecorder.Body.String())
|
||||
}
|
||||
var statsResponse struct {
|
||||
Data struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(statsRecorder.Body.Bytes(), &statsResponse); err != nil || statsResponse.Data.TotalSessions != 1 {
|
||||
t.Fatalf("统计数据范围未生效: %s", statsRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
|
||||
Reference in New Issue
Block a user