完善角色权限与数据隔离

This commit is contained in:
yml2213
2026-07-26 14:00:50 +08:00
parent b483cbc26d
commit 16d9ab0e5e
38 changed files with 2847 additions and 292 deletions
@@ -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")