fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,6 +13,7 @@ import (
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
@@ -59,378 +64,274 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
|
||||
admin := models.User{
|
||||
Username: "admin",
|
||||
Nickname: "Admin Operator",
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("create admin error = %v", err)
|
||||
}
|
||||
principal := &dto.AuthPrincipal{
|
||||
UserID: admin.ID,
|
||||
Username: admin.Username,
|
||||
Nickname: admin.Nickname,
|
||||
Roles: []string{constants.RoleCodeSuperAdmin},
|
||||
}
|
||||
|
||||
readiness, err := services.SetupReadinessService.GetReadiness(principal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadiness error = %v", err)
|
||||
}
|
||||
if readiness.CanReceiveRequestsNow {
|
||||
t.Fatalf("expected CanReceiveRequestsNow = false on empty DB")
|
||||
}
|
||||
if readiness.ReadySteps != 0 {
|
||||
t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps)
|
||||
}
|
||||
if readiness.TotalSteps != 6 {
|
||||
t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps)
|
||||
}
|
||||
if readiness.CurrentOperator.CanReceiveNow {
|
||||
t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB")
|
||||
}
|
||||
|
||||
fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: "quick_setup_all",
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err)
|
||||
}
|
||||
if !fixResp.Readiness.CanReceiveRequestsNow {
|
||||
t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps)
|
||||
}
|
||||
if fixResp.Readiness.ReadySteps != 6 {
|
||||
t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps)
|
||||
}
|
||||
if !fixResp.Readiness.CurrentOperator.CanReceiveNow {
|
||||
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes)
|
||||
}
|
||||
|
||||
// Second call must be idempotent
|
||||
fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: "quick_setup_all",
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err)
|
||||
}
|
||||
if !fixResp2.Readiness.CanReceiveRequestsNow {
|
||||
t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all")
|
||||
}
|
||||
|
||||
var teamCount int64
|
||||
db.Model(&models.AgentTeam{}).Count(&teamCount)
|
||||
if teamCount != 1 {
|
||||
t.Fatalf("expected idempotent 1 team, got %d", teamCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
opUser := models.User{
|
||||
Username: "operator1",
|
||||
Nickname: "Operator One",
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&opUser).Error; err != nil {
|
||||
t.Fatalf("create user error = %v", err)
|
||||
}
|
||||
team := models.AgentTeam{
|
||||
Name: "Support Team",
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&team).Error; err != nil {
|
||||
t.Fatalf("create team error = %v", err)
|
||||
}
|
||||
// Schedule in the past (off-hours now)
|
||||
pastSchedule := models.AgentTeamSchedule{
|
||||
TeamID: team.ID,
|
||||
StartAt: now.Add(-48 * time.Hour),
|
||||
EndAt: now.Add(-24 * time.Hour),
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&pastSchedule).Error; err != nil {
|
||||
t.Fatalf("create past schedule error = %v", err)
|
||||
}
|
||||
// Operator is Busy and AutoAssignEnabled = false
|
||||
profile := models.AgentProfile{
|
||||
UserID: opUser.ID,
|
||||
TeamID: team.ID,
|
||||
AgentCode: "OP-001",
|
||||
DisplayName: "Operator One",
|
||||
ServiceStatus: enums.ServiceStatusBusy,
|
||||
MaxConcurrentCount: 5,
|
||||
AutoAssignEnabled: false,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&profile).Error; err != nil {
|
||||
t.Fatalf("create profile error = %v", err)
|
||||
}
|
||||
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil {
|
||||
t.Fatalf("update auto_assign_enabled error = %v", err)
|
||||
}
|
||||
|
||||
principal := &dto.AuthPrincipal{
|
||||
UserID: opUser.ID,
|
||||
Username: opUser.Username,
|
||||
Nickname: opUser.Nickname,
|
||||
Roles: []string{constants.RoleCodeAdmin},
|
||||
}
|
||||
|
||||
readiness, err := services.SetupReadinessService.GetReadiness(principal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadiness error = %v", err)
|
||||
}
|
||||
if readiness.CurrentOperator.TeamHasScheduleNow {
|
||||
t.Fatalf("expected TeamHasScheduleNow = false for past schedule")
|
||||
}
|
||||
if readiness.CurrentOperator.CanReceiveNow {
|
||||
t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours")
|
||||
}
|
||||
|
||||
// Fix operator profile and schedule
|
||||
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: "enable_my_operator_profile",
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("enable_my_operator_profile error = %v", err)
|
||||
}
|
||||
readinessAfter, err := services.SetupReadinessService.GetReadiness(principal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadiness after fix error = %v", err)
|
||||
}
|
||||
if !readinessAfter.CurrentOperator.CanReceiveNow {
|
||||
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
|
||||
opUser := models.User{
|
||||
Username: "op2",
|
||||
Nickname: "Operator Two",
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
db.Create(&opUser)
|
||||
principal := &dto.AuthPrincipal{
|
||||
UserID: opUser.ID,
|
||||
Username: opUser.Username,
|
||||
Nickname: opUser.Nickname,
|
||||
Roles: []string{constants.RoleCodeAdmin},
|
||||
}
|
||||
|
||||
// Setup team + active schedule + idle operator
|
||||
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: "enable_my_operator_profile",
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("enable_my_operator_profile error = %v", err)
|
||||
}
|
||||
|
||||
// Create AI Agent in AIOnly mode and empty TeamIDs
|
||||
agent := models.AIAgent{
|
||||
Name: "AI Bot",
|
||||
Status: enums.StatusOk,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly,
|
||||
RolloutPercent: 100,
|
||||
TeamIDs: "",
|
||||
}
|
||||
db.Create(&agent)
|
||||
|
||||
channel := models.Channel{
|
||||
Name: "Web Widget",
|
||||
ChannelType: "web",
|
||||
ChannelID: "web-test-1",
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
db.Create(&channel)
|
||||
|
||||
sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
|
||||
ChannelID: channel.ID,
|
||||
BypassAI: true,
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("SimulateRoute 1 error = %v", err)
|
||||
}
|
||||
if sim1.WillReachOperator {
|
||||
t.Fatalf("expected WillReachOperator = false when AIOnly")
|
||||
}
|
||||
if sim1.FinalOutcomeCode != "blocked_ai_only_mode" {
|
||||
t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode)
|
||||
}
|
||||
|
||||
// Switch to HumanOnly but still empty TeamIDs
|
||||
db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly)
|
||||
sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
|
||||
ChannelID: channel.ID,
|
||||
BypassAI: true,
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("SimulateRoute 2 error = %v", err)
|
||||
}
|
||||
if sim2.WillReachOperator {
|
||||
t.Fatalf("expected WillReachOperator = false when TeamIDs is empty")
|
||||
}
|
||||
if sim2.FinalOutcomeCode != "blocked_agent_no_teams" {
|
||||
t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode)
|
||||
}
|
||||
|
||||
// Fix AI Agent handoff via quick fix
|
||||
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: "fix_ai_agent_handoff",
|
||||
EntityID: agent.ID,
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("fix_ai_agent_handoff error = %v", err)
|
||||
}
|
||||
|
||||
sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
|
||||
ChannelID: channel.ID,
|
||||
BypassAI: true,
|
||||
}, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("SimulateRoute 3 error = %v", err)
|
||||
}
|
||||
if !sim3.WillReachOperator {
|
||||
t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode)
|
||||
}
|
||||
if sim3.FinalOutcomeCode != "assigned_to_operator" {
|
||||
t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode)
|
||||
}
|
||||
if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID {
|
||||
t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
|
||||
csUser := models.User{
|
||||
Username: "cs_agent",
|
||||
Nickname: "Support Agent",
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&csUser).Error; err != nil {
|
||||
t.Fatalf("create csUser error = %v", err)
|
||||
}
|
||||
|
||||
// Regular cs_user only has view permissions
|
||||
csPrincipal := &dto.AuthPrincipal{
|
||||
UserID: csUser.ID,
|
||||
Username: csUser.Username,
|
||||
Nickname: csUser.Nickname,
|
||||
Roles: []string{constants.RoleCodeCsUser},
|
||||
func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
|
||||
t.Helper()
|
||||
user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk}
|
||||
if err := db.Create(&user).Error; err != nil { t.Fatal(err) }
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: user.ID, Username: user.Username, Nickname: user.Nickname,
|
||||
Roles: []string{constants.RoleCodeAdmin},
|
||||
Permissions: []string{
|
||||
constants.PermissionAgentView.Code,
|
||||
constants.PermissionAgentTeamView.Code,
|
||||
constants.PermissionAgentTeamScheduleView.Code,
|
||||
constants.PermissionAIAgentView.Code,
|
||||
constants.PermissionChannelView.Code,
|
||||
constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code,
|
||||
constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code,
|
||||
constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code,
|
||||
constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code,
|
||||
constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code,
|
||||
constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions
|
||||
readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadiness error = %v", err)
|
||||
func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse {
|
||||
t.Helper()
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatalf("preview: %v", err) }
|
||||
if preview.Applied || preview.PreviewToken == "" { t.Fatalf("expected read-only preview: %+v", preview) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatalf("apply: %v", err) }
|
||||
if !applied.Applied { t.Fatal("reviewed changes were not applied") }
|
||||
return applied
|
||||
}
|
||||
|
||||
func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil { t.Fatal(err) }
|
||||
if count != want { t.Fatalf("%T count = %d, want %d", model, count, want) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if preview.Applied || len(preview.Changes) != 3 { t.Fatalf("expected three proposed draft setup records: %+v", preview) }
|
||||
for _, change := range preview.Changes {
|
||||
if change.EntityName == "" || len(change.After) == 0 { t.Fatalf("unnamed or unspecified change: %+v", change) }
|
||||
}
|
||||
for _, step := range readiness.Steps {
|
||||
if len(step.QuickFixActions) != 0 {
|
||||
t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions)
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) }
|
||||
req.Apply = true
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("apply without preview must fail") }
|
||||
req.PreviewToken = preview.PreviewToken
|
||||
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow { t.Fatalf("configuration must not claim shift coverage: %+v", applied) }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
if len(second.Changes) != 0 { t.Fatalf("existing configuration must be preserved: %+v", second.Changes) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("stale preview must fail") }
|
||||
assertSetupCount(t, db, &models.AgentTeam{}, 1)
|
||||
assertSetupCount(t, db, &models.AgentProfile{}, 0)
|
||||
assertSetupCount(t, db, &models.AIAgent{}, 0)
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
}
|
||||
|
||||
func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" { tx.AddError(errors.New("injected agent write failure")) }
|
||||
}); err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("expected injected transaction failure") }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} { assertSetupCount(t, db, model, 0) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
team := models.AgentTeam{Name: "Deleted team", Status: enums.StatusDeleted}
|
||||
disabledTeam := models.AgentTeam{Name: "Disabled team", Status: enums.StatusDisabled}
|
||||
profile := models.AgentProfile{UserID: principal.UserID, AgentCode: "deleted-profile", Status: enums.StatusDeleted}
|
||||
deletedAgent := models.AIAgent{Name: "Deleted agent", Status: enums.StatusDeleted}
|
||||
disabledAgent := models.AIAgent{Name: "Disabled agent", Status: enums.StatusDisabled, RolloutPercent: 100}
|
||||
zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100}
|
||||
deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted}
|
||||
disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100}
|
||||
for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
|
||||
if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil { t.Fatal(err) }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
if len(result.Changes) != 0 { t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes) }
|
||||
for _, req := range []request.SetupQuickFixRequest{
|
||||
{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID},
|
||||
{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID},
|
||||
{Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID},
|
||||
{Action: services.QuickFixActionEnableMyProfile},
|
||||
} { if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatalf("deleted target must not be restored: %+v", req) } }
|
||||
for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} {
|
||||
res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal)
|
||||
if len(res.Changes) != 0 { t.Fatalf("disabled/zero rollout changed: %+v", res) }
|
||||
}
|
||||
if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil { t.Fatal(err) }
|
||||
if zeroAgent.RolloutPercent != 0 { t.Fatal("zero rollout was overwritten") }
|
||||
if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil { t.Fatal(err) }
|
||||
if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 { t.Fatal("disabled channel was overwritten") }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
}
|
||||
|
||||
func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"}
|
||||
second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"}
|
||||
for _, agent := range []*models.AIAgent{&first, &second} {
|
||||
if err := db.Create(agent).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil { t.Fatal(err) }
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil { t.Fatal("existing agents require a named target") }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal)
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID { t.Fatalf("wrong scope: %+v", result.Changes) }
|
||||
if err := db.First(&first, first.ID).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.First(&second, second.ID).Error; err != nil { t.Fatal(err) }
|
||||
if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" { t.Fatal("selected agent was not minimally updated") }
|
||||
if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" { t.Fatal("unrelated agent was changed") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
now := time.Now()
|
||||
team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk}
|
||||
other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk}
|
||||
for _, model := range []any{&team, &other} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
|
||||
schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24*time.Hour), EndAt: now.Add(32*time.Hour), Status: enums.StatusOk}
|
||||
if err := db.Create(&schedule).Error; err != nil { t.Fatal(err) }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal)
|
||||
if len(result.Changes) != 0 { t.Fatal("off-hours team must not be mutated") }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1)
|
||||
if result.Readiness == nil { t.Fatal("missing readiness") }
|
||||
foundOffHours, foundMissing := false, false
|
||||
for _, step := range result.Readiness.Steps {
|
||||
if step.Key != "schedules" { continue }
|
||||
for _, issue := range step.Issues {
|
||||
if issue.QuickFixAction != "" {
|
||||
t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction)
|
||||
}
|
||||
if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true }
|
||||
if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true }
|
||||
if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched
|
||||
for _, action := range []string{
|
||||
services.QuickFixActionQuickSetupAll,
|
||||
services.QuickFixActionEnsureTeamSchedule,
|
||||
services.QuickFixActionEnableMyProfile,
|
||||
services.QuickFixActionFixAIAgentHandoff,
|
||||
services.QuickFixActionEnsureDefaultChannel,
|
||||
} {
|
||||
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: action,
|
||||
}, csPrincipal)
|
||||
if err == nil {
|
||||
t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action)
|
||||
}
|
||||
}
|
||||
|
||||
var teamCount, agentCount, channelCount, profileCount int64
|
||||
db.Model(&models.AgentTeam{}).Count(&teamCount)
|
||||
db.Model(&models.AIAgent{}).Count(&agentCount)
|
||||
db.Model(&models.Channel{}).Count(&channelCount)
|
||||
db.Model(&models.AgentProfile{}).Count(&profileCount)
|
||||
if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 {
|
||||
t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d",
|
||||
teamCount, agentCount, channelCount, profileCount)
|
||||
}
|
||||
|
||||
// 3. Seed an active team + active schedule + busy profile for csUser
|
||||
now := time.Now()
|
||||
team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk}
|
||||
db.Create(&team)
|
||||
db.Create(&models.AgentTeamSchedule{
|
||||
TeamID: team.ID,
|
||||
StartAt: now.Add(-1 * time.Hour),
|
||||
EndAt: now.Add(24 * time.Hour),
|
||||
Status: enums.StatusOk,
|
||||
})
|
||||
db.Create(&models.AgentProfile{
|
||||
UserID: csUser.ID,
|
||||
TeamID: team.ID,
|
||||
AgentCode: "AG-CS1",
|
||||
DisplayName: "Support Agent",
|
||||
ServiceStatus: enums.ServiceStatusBusy,
|
||||
MaxConcurrentCount: 5,
|
||||
AutoAssignEnabled: true,
|
||||
Status: enums.StatusOk,
|
||||
})
|
||||
|
||||
// Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle,
|
||||
// but still cannot modify AI agents or channels!
|
||||
statusOnlyPrincipal := &dto.AuthPrincipal{
|
||||
UserID: csUser.ID,
|
||||
Username: csUser.Username,
|
||||
Nickname: csUser.Nickname,
|
||||
Roles: []string{constants.RoleCodeCsUser},
|
||||
Permissions: []string{
|
||||
constants.PermissionAgentView.Code,
|
||||
constants.PermissionAgentUpdateStatus.Code,
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: services.QuickFixActionFixAIAgentHandoff,
|
||||
}, statusOnlyPrincipal); err == nil {
|
||||
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff")
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: services.QuickFixActionEnsureDefaultChannel,
|
||||
}, statusOnlyPrincipal); err == nil {
|
||||
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel")
|
||||
}
|
||||
|
||||
fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
|
||||
Action: services.QuickFixActionEnableMyProfile,
|
||||
}, statusOnlyPrincipal)
|
||||
if err != nil {
|
||||
t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err)
|
||||
}
|
||||
if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow {
|
||||
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes)
|
||||
}
|
||||
if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
principal.Permissions = nil
|
||||
for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} {
|
||||
principal.Roles = []string{role}
|
||||
for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} {
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) }
|
||||
}
|
||||
}
|
||||
assertSetupCount(t, db, &models.AgentTeam{}, 0)
|
||||
}
|
||||
|
||||
func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk}
|
||||
if err := db.Create(&team).Error; err != nil { t.Fatal(err) }
|
||||
profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5}
|
||||
if err := db.Create(&profile).Error; err != nil { t.Fatal(err) }
|
||||
principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code}
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal)
|
||||
if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 { t.Fatalf("status-only permission exceeded: %+v", result.Changes) }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) }
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
for _, channelID := range []int64{0, 9999} {
|
||||
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) }
|
||||
}
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
var team models.AgentTeam
|
||||
var agent models.AIAgent
|
||||
if err := db.First(&team).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
|
||||
channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk}
|
||||
if err := db.Create(&channel).Error; err != nil { t.Fatal(err) }
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
|
||||
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) }
|
||||
if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") }
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) }
|
||||
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" { t.Fatalf("AI route must not promise assignment: %+v", result) }
|
||||
if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil { t.Fatal(err) }
|
||||
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) }
|
||||
}); err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
result, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil { t.Fatalf("committed repair must not appear rolled back: %v", err) }
|
||||
if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") { t.Fatalf("dispatch failure must be visible: %+v", result) }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
}
|
||||
|
||||
func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
|
||||
if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") { t.Fatalf("draft agent must not be bound implicitly: %+v", result) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
var agent models.AIAgent
|
||||
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
|
||||
// This unit fixture supplies the publication marker; revision publication itself
|
||||
// belongs to the existing agent publishing service's tests.
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
|
||||
result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" { t.Fatalf("expected reviewed channel create: %+v", result.Changes) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 1)
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
}
|
||||
|
||||
func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
var profile models.AgentProfile
|
||||
if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil { t.Fatal(err) }
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil { t.Fatal("my-profile action must not silently substitute a different named target") }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user