Files
agent-desk-ng/internal/services/setup_readiness_service_test.go
mono 63e0457886 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.
2026-10-16 12:15:20 +00:00

338 lines
21 KiB
Go

// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services_test
import (
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
"agent-desk/internal/models"
"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"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(
&models.User{},
&models.Customer{},
&models.CustomerIdentity{},
&models.AIAgent{},
&models.AgentTeam{},
&models.AgentTeamSchedule{},
&models.AgentProfile{},
&models.Channel{},
&models.Conversation{},
&models.ConversationParticipant{},
&models.ConversationAssignment{},
&models.ConversationEventLog{},
&models.ConversationReadState{},
&models.Message{},
&models.ChannelMessageOutbox{},
); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
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.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,
},
}
}
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 _, 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.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") }
}
}
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") }
}