Files
OrchestrAD/backend/internal/services/activity_service_test.go
Alphaeus Mote e74cb454c8 feat(activity): add action-ledger intelligence and drill-in feed
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>
2026-09-02 14:09:04 -04:00

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{}
}