e74cb454c8
Turn the rule_run_actions ledger into operator intelligence. A new ActivityService rolls the ledger up by time window (24h / 7d / all-time) into syncs, operations and removals with success/failure counts and the number of distinct objects affected, plus all-time totals per action type and a most-active-rules ranking. A companion filtered, paginated feed answers the operational questions directly: what action ran, what happened, to which object, triggered by whom, and when. Backend: - ActivityService.Summary() and ListActions(filter) over the joined rule_run_actions / rule_runs / rules tables, categorising each action type as Sync (AddToGroup/AddGroupToGroup), Removal (RemoveFromGroupIfNoLongerMatched) or Operation (everything else). - GET /api/v1/activity/summary and GET /api/v1/activity (category, actionType, status, ruleId, search, pagination). - Table-driven test seeding a run with mixed action types/statuses and asserting window roll-ups, distinct-object counts, top rules, and the category/status feed filters. Frontend: - New Activity page: window summary cards, by-action-type and most-active-rules tables, and a drill-in feed with category/result/ object filters and a detail dialog that pretty-prints the action's details JSON and error. - Wired into the vertical sidebar and horizontal navbar under Automation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
4.4 KiB
Go
132 lines
4.4 KiB
Go
package services
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/db"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
|
)
|
|
|
|
func TestActivityServiceSummaryAndFeed(t *testing.T) {
|
|
database, err := db.New(config.DatabaseConfig{
|
|
Path: filepath.Join(t.TempDir(), "activity_test.db"),
|
|
MaxOpenConns: 1,
|
|
MaxIdleConns: 1,
|
|
WALMode: true,
|
|
ForeignKeys: true,
|
|
}, logging.Default())
|
|
if err != nil {
|
|
t.Fatalf("db.New: %v", err)
|
|
}
|
|
defer database.Close()
|
|
if err := database.Migrate(); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
conn := database.Conn()
|
|
|
|
// A connection satisfies the rules.ad_connection_id FK.
|
|
connRepo := repository.NewConnectionRepository(conn)
|
|
adc := &models.ADConnection{
|
|
Name: "act-conn", Hosts: "dc1", Port: 389, RootDN: "DC=y",
|
|
DefaultSearchScope: "Subtree", TimeoutSeconds: 15, PageSize: 1000, IsEnabled: true,
|
|
}
|
|
if err := connRepo.Create(adc); err != nil {
|
|
t.Fatalf("create connection: %v", err)
|
|
}
|
|
|
|
ruleRepo := repository.NewRuleRepository(conn)
|
|
rule := &models.Rule{Name: "act-rule", ObjectType: "User", ADConnectionID: adc.ID, ExecutionMode: "Automatic", GroupJoinOperator: "AND"}
|
|
if err := ruleRepo.Create(rule); err != nil {
|
|
t.Fatalf("create rule: %v", err)
|
|
}
|
|
|
|
// A rule_action satisfies the rule_run_actions.rule_action_id FK.
|
|
actionID := "act-action-1"
|
|
if _, err := conn.Exec(`
|
|
INSERT INTO rule_actions (id, rule_id, action_type, is_enabled, sort_order, configuration_json, rollback_mode, created_utc, updated_utc)
|
|
VALUES (?, ?, 'AddToGroup', 1, 0, '{}', 'None', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
|
|
actionID, rule.ID); err != nil {
|
|
t.Fatalf("insert rule_action: %v", err)
|
|
}
|
|
|
|
runRepo := repository.NewRuleRunRepository(conn)
|
|
who := "tester"
|
|
run := &models.RuleRun{RuleID: rule.ID, Status: "Success", ExecutionMode: "Automatic", TriggeredBy: &who}
|
|
if err := runRepo.Create(run); err != nil {
|
|
t.Fatalf("create run: %v", err)
|
|
}
|
|
|
|
// Two syncs (one failed), one operation, one removal.
|
|
actions := []struct {
|
|
actionType, status, dn string
|
|
}{
|
|
{"AddToGroup", "Succeeded", "CN=Alice,OU=x,DC=y"},
|
|
{"AddToGroup", "Failed", "CN=Bob,OU=x,DC=y"},
|
|
{"MoveToOu", "Succeeded", "CN=Alice,OU=x,DC=y"},
|
|
{"RemoveFromGroupIfNoLongerMatched", "Succeeded", "CN=Carol,OU=x,DC=y"},
|
|
}
|
|
for _, a := range actions {
|
|
if err := runRepo.CreateAction(&models.RuleRunAction{
|
|
RuleRunID: run.ID, RuleActionID: actionID, ObjectDN: a.dn, ActionType: a.actionType, Status: a.status,
|
|
}); err != nil {
|
|
t.Fatalf("create action: %v", err)
|
|
}
|
|
}
|
|
|
|
svc := NewActivityService(conn, logging.Default())
|
|
|
|
sum, err := svc.Summary()
|
|
if err != nil {
|
|
t.Fatalf("summary: %v", err)
|
|
}
|
|
all := findWindow(t, sum.Windows, "all")
|
|
if all.Total != 4 || all.Syncs != 2 || all.Operations != 1 || all.Removals != 1 {
|
|
t.Errorf("window totals wrong: %+v", all)
|
|
}
|
|
if all.Failed != 1 || all.Success != 3 {
|
|
t.Errorf("window success/failed wrong: %+v", all)
|
|
}
|
|
if all.ObjectsAffected != 3 { // Alice, Bob, Carol
|
|
t.Errorf("objectsAffected = %d, want 3", all.ObjectsAffected)
|
|
}
|
|
if len(sum.TopRules) != 1 || sum.TopRules[0].Total != 4 || sum.TopRules[0].Failed != 1 {
|
|
t.Errorf("topRules wrong: %+v", sum.TopRules)
|
|
}
|
|
|
|
// Removal category filter should return exactly the one removal action.
|
|
recs, total, err := svc.ListActions(ActivityFilter{Category: CategoryRemoval, Limit: 50})
|
|
if err != nil {
|
|
t.Fatalf("list removals: %v", err)
|
|
}
|
|
if total != 1 || len(recs) != 1 || recs[0].ActionType != "RemoveFromGroupIfNoLongerMatched" {
|
|
t.Errorf("removal filter wrong: total=%d recs=%+v", total, recs)
|
|
}
|
|
if recs[0].TriggeredBy == nil || *recs[0].TriggeredBy != who {
|
|
t.Errorf("triggeredBy not threaded through: %+v", recs[0].TriggeredBy)
|
|
}
|
|
|
|
// Failed status filter should surface the single failed sync.
|
|
failed, ftotal, err := svc.ListActions(ActivityFilter{Status: "Failed", Limit: 50})
|
|
if err != nil {
|
|
t.Fatalf("list failed: %v", err)
|
|
}
|
|
if ftotal != 1 || len(failed) != 1 || failed[0].Category != CategorySync {
|
|
t.Errorf("failed filter wrong: total=%d recs=%+v", ftotal, failed)
|
|
}
|
|
}
|
|
|
|
func findWindow(t *testing.T, windows []ActivityWindow, key string) ActivityWindow {
|
|
t.Helper()
|
|
for _, w := range windows {
|
|
if w.Key == key {
|
|
return w
|
|
}
|
|
}
|
|
t.Fatalf("window %q not found", key)
|
|
return ActivityWindow{}
|
|
}
|