feat(custom-roles): align chatwoot permissions
This commit is contained in:
@@ -18,7 +18,7 @@ type AccountUser struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"user_id"`
|
||||
AccountID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"account_id"`
|
||||
Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // agent, administrator, custom_role
|
||||
Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // Chatwoot: agent or administrator; custom role is represented by CustomRoleID.
|
||||
CustomRoleID uint `gorm:"default:0" json:"custom_role_id,omitempty"` // 0 means no custom role; >0 references CustomRole
|
||||
AgentCapacityPolicyID *uint `gorm:"index" json:"agent_capacity_policy_id,omitempty"` // enterprise capacity policy assignment
|
||||
Availability string `gorm:"size:50;default:'offline'" json:"availability"` // online/offline/busy
|
||||
@@ -32,6 +32,7 @@ type AccountUser struct {
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
CustomRole *CustomRole `gorm:"foreignKey:CustomRoleID" json:"custom_role,omitempty"`
|
||||
AgentCapacityPolicy *AgentCapacityPolicy `gorm:"foreignKey:AgentCapacityPolicyID" json:"agent_capacity_policy,omitempty"`
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ func (au *AccountUser) IsAgent() bool {
|
||||
|
||||
// HasCustomRole returns true if the AccountUser has a custom enterprise role.
|
||||
func (au *AccountUser) HasCustomRole() bool {
|
||||
return au.Role == "custom_role" && au.CustomRoleID > 0
|
||||
return au.CustomRoleID > 0
|
||||
}
|
||||
|
||||
// IsOnline returns true if the agent availability is "online".
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestCustomRoleAccountUserAssociation(t *testing.T) {
|
||||
// Create AccountUser with CustomRole
|
||||
au := &model.AccountUser{
|
||||
UserID: user.ID, AccountID: acc.ID,
|
||||
Role: "custom_role", CustomRoleID: role.ID,
|
||||
Role: "agent", CustomRoleID: role.ID,
|
||||
Availability: "online",
|
||||
}
|
||||
assert.NoError(t, db.Create(au).Error)
|
||||
@@ -251,7 +251,7 @@ func TestCustomRoleAccountUserAssociation(t *testing.T) {
|
||||
var fetched model.AccountUser
|
||||
assert.NoError(t, db.First(&fetched, au.ID).Error)
|
||||
assert.Equal(t, role.ID, fetched.CustomRoleID)
|
||||
assert.Equal(t, "custom_role", fetched.Role)
|
||||
assert.Equal(t, "agent", fetched.Role)
|
||||
}
|
||||
|
||||
// TestAttachmentMessageAssociation verifies Attachment -> Message association.
|
||||
@@ -321,4 +321,4 @@ func TestCascadeSoftDelete(t *testing.T) {
|
||||
var fetched model.User
|
||||
assert.NoError(t, db.First(&fetched, user.ID).Error)
|
||||
assert.Equal(t, acc.ID, fetched.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestCustomRoleDefaultValues(t *testing.T) {
|
||||
|
||||
var fetched model.CustomRole
|
||||
assert.NoError(t, db.First(&fetched, role.ID).Error)
|
||||
assert.Equal(t, "{}", fetched.Permissions, "Permissions should default to '{}'")
|
||||
assert.Equal(t, "[]", fetched.Permissions, "Permissions should default to Chatwoot permission array")
|
||||
}
|
||||
|
||||
// TestPlatformAppDefaultValues verifies PlatformApp model default field values.
|
||||
@@ -321,4 +321,4 @@ func TestNotificationPreferenceUniqueConstraint(t *testing.T) {
|
||||
}
|
||||
err := db.Create(pref2).Error
|
||||
assert.Error(t, err, "Duplicate notification preference should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
+126
-36
@@ -1,17 +1,9 @@
|
||||
package model
|
||||
|
||||
// CustomRole represents an enterprise custom role with fine-grained permissions.
|
||||
// CustomRole represents an enterprise custom role with Chatwoot-compatible
|
||||
// permission keys. Chatwoot stores permissions as a text array; GoChat stores the
|
||||
// same string array as JSON for portability while keeping legacy map reads.
|
||||
// Reference: Chatwoot enterprise/app/models/custom_role.rb
|
||||
// P2E §2.1 — 6 permission dimensions: conversation_manage, conversation_delete,
|
||||
// contact_manage, report_manage, knowledge_base_manage, automation_manage
|
||||
//
|
||||
// CustomRoles allow enterprise accounts to define roles between agent and administrator
|
||||
// with specific permission levels (full/read/none) on each dimension.
|
||||
//
|
||||
// NOTE: PermissionMatrix type is intentionally NOT imported from internal/auth to avoid
|
||||
// a circular dependency (auth -> model -> auth). Instead, the model stores permissions
|
||||
// as raw JSONB string, and the auth/service layers handle deserialization into PermissionMatrix.
|
||||
// See RBACService.GetPermissionMatrix(customRole) for the conversion.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -21,26 +13,31 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionDimension constants — duplicated here to avoid circular import with auth package.
|
||||
// These must be kept in sync with auth.PermissionDimension.
|
||||
// PermissionDimension constants are duplicated here to avoid a circular import
|
||||
// with auth. The first six constants are the current Chatwoot custom-role keys.
|
||||
type PermissionDimension string
|
||||
|
||||
const (
|
||||
DimensionConversationManage PermissionDimension = "conversation_manage"
|
||||
DimensionConversationDelete PermissionDimension = "conversation_delete"
|
||||
DimensionContactManage PermissionDimension = "contact_manage"
|
||||
DimensionReportManage PermissionDimension = "report_manage"
|
||||
DimensionKnowledgeBaseManage PermissionDimension = "knowledge_base_manage"
|
||||
DimensionAutomationManage PermissionDimension = "automation_manage"
|
||||
DimensionConversationManage PermissionDimension = "conversation_manage"
|
||||
DimensionConversationUnassignedManage PermissionDimension = "conversation_unassigned_manage"
|
||||
DimensionConversationParticipatingManage PermissionDimension = "conversation_participating_manage"
|
||||
DimensionContactManage PermissionDimension = "contact_manage"
|
||||
DimensionReportManage PermissionDimension = "report_manage"
|
||||
DimensionKnowledgeBaseManage PermissionDimension = "knowledge_base_manage"
|
||||
|
||||
// Legacy local dimensions. These are accepted when reading older JSON map
|
||||
// values but are not valid Chatwoot custom-role API keys.
|
||||
DimensionConversationDelete PermissionDimension = "conversation_delete"
|
||||
DimensionAutomationManage PermissionDimension = "automation_manage"
|
||||
)
|
||||
|
||||
// PermissionLevel constants — duplicated here to avoid circular import with auth package.
|
||||
type PermissionLevel string
|
||||
|
||||
const (
|
||||
PermissionLevelFull PermissionLevel = "full"
|
||||
PermissionLevelRead PermissionLevel = "read"
|
||||
PermissionLevelNone PermissionLevel = "none"
|
||||
PermissionLevelFull PermissionLevel = "full"
|
||||
PermissionLevelRead PermissionLevel = "read"
|
||||
PermissionLevelNone PermissionLevel = "none"
|
||||
)
|
||||
|
||||
// CustomRole represents a custom enterprise role definition.
|
||||
@@ -48,24 +45,93 @@ type CustomRole struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Permissions string `gorm:"type:jsonb;not null;default:'{}'" json:"permissions"` // JSONB: {"conversation_manage":"read", ...}
|
||||
Permissions string `gorm:"type:jsonb;not null;default:'[]'" json:"permissions"` // JSON array of Chatwoot permission keys.
|
||||
Description string `gorm:"size:500" json:"description,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
|
||||
// Relations
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
AccountUsers []AccountUser `gorm:"foreignKey:CustomRoleID" json:"account_users,omitempty"`
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
AccountUsers []AccountUser `gorm:"foreignKey:CustomRoleID" json:"account_users,omitempty"`
|
||||
}
|
||||
|
||||
func (CustomRole) TableName() string { return "custom_roles" }
|
||||
|
||||
var validCustomRolePermissions = map[PermissionDimension]struct{}{
|
||||
DimensionConversationManage: {},
|
||||
DimensionConversationUnassignedManage: {},
|
||||
DimensionConversationParticipatingManage: {},
|
||||
DimensionContactManage: {},
|
||||
DimensionReportManage: {},
|
||||
DimensionKnowledgeBaseManage: {},
|
||||
}
|
||||
|
||||
// IsValidCustomRolePermission reports whether key is one of Chatwoot's current
|
||||
// CustomRole::PERMISSIONS values.
|
||||
func IsValidCustomRolePermission(key PermissionDimension) bool {
|
||||
_, ok := validCustomRolePermissions[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetPermissionKeys returns Chatwoot custom-role permission keys. It accepts the
|
||||
// current JSON array shape and the legacy local JSON map shape for old rows.
|
||||
func (cr *CustomRole) GetPermissionKeys() ([]PermissionDimension, error) {
|
||||
if cr.Permissions == "" || cr.Permissions == "{}" || cr.Permissions == "[]" || cr.Permissions == "null" {
|
||||
return []PermissionDimension{}, nil
|
||||
}
|
||||
|
||||
var keys []PermissionDimension
|
||||
if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil {
|
||||
return normalizePermissionKeys(keys)
|
||||
}
|
||||
|
||||
legacyMap, err := cr.GetPermissionMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = make([]PermissionDimension, 0, len(legacyMap))
|
||||
for dim, level := range legacyMap {
|
||||
if !IsValidCustomRolePermission(dim) || level == PermissionLevelNone {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, dim)
|
||||
}
|
||||
return normalizePermissionKeys(keys)
|
||||
}
|
||||
|
||||
// SetPermissionKeys stores Chatwoot custom-role permission keys as a JSON array.
|
||||
func (cr *CustomRole) SetPermissionKeys(keys []PermissionDimension) error {
|
||||
normalized, err := normalizePermissionKeys(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize custom role permissions: %w", err)
|
||||
}
|
||||
cr.Permissions = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPermissionMap deserializes the JSONB permissions field into a map of
|
||||
// PermissionDimension → PermissionLevel. This is the raw deserialization;
|
||||
// the auth.PolicyContext layer converts this into auth.PermissionMatrix for
|
||||
// policy evaluation.
|
||||
func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLevel, error) {
|
||||
var keys []PermissionDimension
|
||||
if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil {
|
||||
pm := map[PermissionDimension]PermissionLevel{}
|
||||
normalized, err := normalizePermissionKeys(keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range normalized {
|
||||
pm[key] = PermissionLevelFull
|
||||
}
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
var m map[PermissionDimension]PermissionLevel
|
||||
if cr.Permissions == "" || cr.Permissions == "{}" {
|
||||
return map[PermissionDimension]PermissionLevel{}, nil
|
||||
@@ -73,17 +139,25 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve
|
||||
if err := json.Unmarshal([]byte(cr.Permissions), &m); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse custom role permissions JSON: %w", err)
|
||||
}
|
||||
for dim, level := range m {
|
||||
if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone {
|
||||
return nil, fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetPermissionMap serializes a permission map into the JSONB permissions field.
|
||||
// SetPermissionMap stores non-none valid Chatwoot permissions from a legacy
|
||||
// permission matrix as the current JSON array shape.
|
||||
func (cr *CustomRole) SetPermissionMap(m map[PermissionDimension]PermissionLevel) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize custom role permissions: %w", err)
|
||||
keys := make([]PermissionDimension, 0, len(m))
|
||||
for dim, level := range m {
|
||||
if level == PermissionLevelNone || !IsValidCustomRolePermission(dim) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, dim)
|
||||
}
|
||||
cr.Permissions = string(data)
|
||||
return nil
|
||||
return cr.SetPermissionKeys(keys)
|
||||
}
|
||||
|
||||
// Validate checks that the custom role has a valid name and permission dimensions.
|
||||
@@ -92,20 +166,36 @@ func (cr *CustomRole) Validate() error {
|
||||
return fmt.Errorf("custom role name is required")
|
||||
}
|
||||
|
||||
pm, err := cr.GetPermissionMap()
|
||||
keys, err := cr.GetPermissionKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for dim, level := range pm {
|
||||
if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone {
|
||||
return fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim)
|
||||
for _, key := range keys {
|
||||
if !IsValidCustomRolePermission(key) {
|
||||
return fmt.Errorf("invalid custom role permission '%s'", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePermissionKeys(keys []PermissionDimension) ([]PermissionDimension, error) {
|
||||
seen := map[PermissionDimension]struct{}{}
|
||||
normalized := make([]PermissionDimension, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if !IsValidCustomRolePermission(key) {
|
||||
return nil, fmt.Errorf("invalid custom role permission '%s'", key)
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, key)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// CustomRoleValidationError represents a validation error for a custom role.
|
||||
type CustomRoleValidationError struct {
|
||||
Dimension string
|
||||
@@ -115,4 +205,4 @@ type CustomRoleValidationError struct {
|
||||
|
||||
func (e *CustomRoleValidationError) Error() string {
|
||||
return "custom_role validation error: dimension '" + e.Dimension + "' has invalid level '" + e.Level + "' — " + e.Message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +89,11 @@ func TestAccountUserIsAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountUserHasCustomRole(t *testing.T) {
|
||||
au := &model.AccountUser{Role: "custom_role", CustomRoleID: 5}
|
||||
au := &model.AccountUser{Role: "agent", CustomRoleID: 5}
|
||||
assert.True(t, au.HasCustomRole())
|
||||
|
||||
au.Role = "agent"
|
||||
assert.False(t, au.HasCustomRole())
|
||||
assert.True(t, au.HasCustomRole())
|
||||
|
||||
au.Role = "custom_role"
|
||||
au.CustomRoleID = 0
|
||||
@@ -115,13 +115,13 @@ func TestAccountUserIsOnline(t *testing.T) {
|
||||
|
||||
func TestCustomRoleGetPermissionMap(t *testing.T) {
|
||||
cr := &model.CustomRole{
|
||||
Permissions: `{"conversation_manage":"full","contact_manage":"read","automation_manage":"none"}`,
|
||||
Permissions: `["conversation_manage","contact_manage"]`,
|
||||
}
|
||||
pm, err := cr.GetPermissionMap()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionConversationManage])
|
||||
assert.Equal(t, model.PermissionLevelRead, pm[model.DimensionContactManage])
|
||||
assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionAutomationManage])
|
||||
assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionContactManage])
|
||||
assert.Empty(t, pm[model.DimensionAutomationManage])
|
||||
}
|
||||
|
||||
func TestCustomRoleGetPermissionMapEmpty(t *testing.T) {
|
||||
@@ -152,20 +152,19 @@ func TestCustomRoleSetPermissionMap(t *testing.T) {
|
||||
err := cr.SetPermissionMap(pm)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, cr.Permissions, "conversation_manage")
|
||||
assert.Contains(t, cr.Permissions, "full")
|
||||
assert.Contains(t, cr.Permissions, "contact_manage")
|
||||
assert.Contains(t, cr.Permissions, "read")
|
||||
|
||||
// Verify round-trip
|
||||
pm2, err := cr.GetPermissionMap()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, pm, pm2)
|
||||
assert.Equal(t, model.PermissionLevelFull, pm2[model.DimensionConversationManage])
|
||||
assert.Equal(t, model.PermissionLevelFull, pm2[model.DimensionContactManage])
|
||||
}
|
||||
|
||||
func TestCustomRoleValidate(t *testing.T) {
|
||||
cr := &model.CustomRole{
|
||||
Name: "Valid Role",
|
||||
Permissions: `{"conversation_manage":"full","contact_manage":"read"}`,
|
||||
Name: "Valid Role",
|
||||
Permissions: `["conversation_manage","contact_manage"]`,
|
||||
}
|
||||
assert.NoError(t, cr.Validate())
|
||||
|
||||
@@ -175,7 +174,7 @@ func TestCustomRoleValidate(t *testing.T) {
|
||||
|
||||
// Invalid permission level
|
||||
cr.Name = "Invalid Role"
|
||||
cr.Permissions = `{"conversation_manage":"invalid_level"}`
|
||||
cr.Permissions = `["conversation_delete"]`
|
||||
assert.Error(t, cr.Validate())
|
||||
|
||||
// Invalid JSON
|
||||
@@ -275,13 +274,13 @@ func TestPermissionLevelConstants(t *testing.T) {
|
||||
|
||||
func TestAccountStructFields(t *testing.T) {
|
||||
acc := model.Account{
|
||||
Name: "Test",
|
||||
Domain: "test.com",
|
||||
Locale: "en",
|
||||
Timezone: "UTC",
|
||||
Active: true,
|
||||
Status: "active",
|
||||
FeatureFlags: `{"feature_x":true}`,
|
||||
Name: "Test",
|
||||
Domain: "test.com",
|
||||
Locale: "en",
|
||||
Timezone: "UTC",
|
||||
Active: true,
|
||||
Status: "active",
|
||||
FeatureFlags: `{"feature_x":true}`,
|
||||
AutoResolveDuration: 7,
|
||||
}
|
||||
assert.Equal(t, "Test", acc.Name)
|
||||
@@ -296,20 +295,20 @@ func TestAccountStructFields(t *testing.T) {
|
||||
|
||||
func TestUserStructFields(t *testing.T) {
|
||||
user := model.User{
|
||||
AccountID: 1,
|
||||
Name: "Agent",
|
||||
Email: "agent@test.com",
|
||||
Password: "secret",
|
||||
Provider: "email",
|
||||
UID: "ext123",
|
||||
AvatarURL: "https://img.test.com/avatar.png",
|
||||
Role: "agent",
|
||||
Active: true,
|
||||
Available: false,
|
||||
TOTPSecret: "otp_secret",
|
||||
TOTPEnabled: false,
|
||||
AccountID: 1,
|
||||
Name: "Agent",
|
||||
Email: "agent@test.com",
|
||||
Password: "secret",
|
||||
Provider: "email",
|
||||
UID: "ext123",
|
||||
AvatarURL: "https://img.test.com/avatar.png",
|
||||
Role: "agent",
|
||||
Active: true,
|
||||
Available: false,
|
||||
TOTPSecret: "otp_secret",
|
||||
TOTPEnabled: false,
|
||||
CustomRoleID: nil,
|
||||
SignInCount: 5,
|
||||
SignInCount: 5,
|
||||
}
|
||||
assert.Equal(t, uint(1), user.AccountID)
|
||||
assert.Equal(t, "Agent", user.Name)
|
||||
@@ -359,12 +358,12 @@ func TestMessageStructFields(t *testing.T) {
|
||||
|
||||
func TestContactStructFields(t *testing.T) {
|
||||
contact := model.Contact{
|
||||
AccountID: 1,
|
||||
Name: "Alice",
|
||||
Email: "alice@test.com",
|
||||
PhoneNumber: "+1234567890",
|
||||
AvatarURL: "https://img.test.com/alice.png",
|
||||
Identifier: "ext_id_123",
|
||||
AccountID: 1,
|
||||
Name: "Alice",
|
||||
Email: "alice@test.com",
|
||||
PhoneNumber: "+1234567890",
|
||||
AvatarURL: "https://img.test.com/alice.png",
|
||||
Identifier: "ext_id_123",
|
||||
}
|
||||
assert.Equal(t, uint(1), contact.AccountID)
|
||||
assert.Equal(t, "Alice", contact.Name)
|
||||
@@ -374,14 +373,14 @@ func TestContactStructFields(t *testing.T) {
|
||||
|
||||
func TestInboxStructFields(t *testing.T) {
|
||||
inbox := model.Inbox{
|
||||
AccountID: 1,
|
||||
Name: "Support",
|
||||
ChannelType: "web_widget",
|
||||
ChannelID: 5,
|
||||
AccountID: 1,
|
||||
Name: "Support",
|
||||
ChannelType: "web_widget",
|
||||
ChannelID: 5,
|
||||
EnableAutoAssignment: true,
|
||||
AutoAssignmentLimit: 10,
|
||||
Enabled: true,
|
||||
ChannelConfig: `{"color":"#1f93ff"}`,
|
||||
Enabled: true,
|
||||
ChannelConfig: `{"color":"#1f93ff"}`,
|
||||
}
|
||||
assert.Equal(t, uint(1), inbox.AccountID)
|
||||
assert.Equal(t, "Support", inbox.Name)
|
||||
@@ -405,4 +404,4 @@ func TestCustomRoleValidationError(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "conversation_manage")
|
||||
assert.Contains(t, err.Error(), "invalid")
|
||||
assert.Contains(t, err.Error(), "not a valid level")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user