feat(audit): clickable column headers with asc/desc sort
This commit is contained in:
+31
-1
@@ -21,6 +21,24 @@ type AuditFilter struct {
|
||||
Since time.Time // zero = no lower bound
|
||||
Until time.Time // zero = no upper bound
|
||||
Limit int // 0 = no limit
|
||||
// OrderBy is one of "ts" | "actor" | "user_id" | "action" |
|
||||
// "target_kind". Empty / unknown falls back to "ts". The
|
||||
// allowlist is enforced inside ListAudit so callers can't
|
||||
// inject SQL via this field.
|
||||
OrderBy string
|
||||
OrderAsc bool // false = DESC (default — newest first)
|
||||
}
|
||||
|
||||
// auditOrderColumn validates f.OrderBy against the column allowlist
|
||||
// and returns the SQL fragment. Unknown / empty → "ts" so callers
|
||||
// always get a deterministic order.
|
||||
func auditOrderColumn(s string) string {
|
||||
switch s {
|
||||
case "actor", "user_id", "action", "target_kind":
|
||||
return s
|
||||
default:
|
||||
return "ts"
|
||||
}
|
||||
}
|
||||
|
||||
// ListAudit returns audit_log rows ordered by ts DESC.
|
||||
@@ -63,7 +81,19 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, err
|
||||
if len(conds) > 0 {
|
||||
q += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
q += ` ORDER BY ts DESC`
|
||||
col := auditOrderColumn(f.OrderBy)
|
||||
dir := "DESC"
|
||||
if f.OrderAsc {
|
||||
dir = "ASC"
|
||||
}
|
||||
// Always tie-break on ts DESC so equal sort keys (e.g. dozens
|
||||
// of rows with action='alert.resolve') still come back in a
|
||||
// deterministic, time-meaningful order.
|
||||
if col == "ts" {
|
||||
q += fmt.Sprintf(" ORDER BY ts %s", dir)
|
||||
} else {
|
||||
q += fmt.Sprintf(" ORDER BY %s %s, ts DESC", col, dir)
|
||||
}
|
||||
if f.Limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, f.Limit)
|
||||
|
||||
@@ -109,6 +109,54 @@ func TestListAuditFiltersAndOrdering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditSort(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, uid := newAuditTestStore(t)
|
||||
t0 := time.Now().UTC()
|
||||
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h1", t0.Add(-3*time.Hour))
|
||||
appendAudit(t, st, uid, "user", "alert.acknowledge", "alert", "a1", t0.Add(-time.Hour))
|
||||
appendAudit(t, st, "", "system", "host.auto_init", "host", "h1", t0.Add(-30*time.Minute))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Sort by action ASC.
|
||||
got, err := st.ListAudit(ctx, AuditFilter{OrderBy: "action", OrderAsc: true})
|
||||
if err != nil {
|
||||
t.Fatalf("sort action asc: %v", err)
|
||||
}
|
||||
wantActions := []string{"alert.acknowledge", "host.auto_init", "host.enrolled"}
|
||||
for i, w := range wantActions {
|
||||
if got[i].Action != w {
|
||||
t.Errorf("[%d] action: got %q want %q", i, got[i].Action, w)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by action DESC.
|
||||
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "action", OrderAsc: false})
|
||||
if got[0].Action != "host.enrolled" {
|
||||
t.Errorf("desc head: got %q want host.enrolled", got[0].Action)
|
||||
}
|
||||
|
||||
// Unknown OrderBy → falls back to ts DESC.
|
||||
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "DROP TABLE; --"})
|
||||
if got[0].Action != "host.auto_init" {
|
||||
t.Errorf("unknown OrderBy should fall back to ts DESC; got head %q", got[0].Action)
|
||||
}
|
||||
|
||||
// Sort by actor — ties tie-break on ts DESC, so 'user' rows
|
||||
// should come back newest-first within the actor group.
|
||||
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "actor", OrderAsc: true})
|
||||
// First two are 'system' (1 row) and 'user' (2 rows newest-first):
|
||||
// expect system → user(ack) → user(enrolled)
|
||||
if got[0].Actor != "system" {
|
||||
t.Errorf("actor asc head: got %q want system", got[0].Actor)
|
||||
}
|
||||
if got[1].Action != "alert.acknowledge" {
|
||||
t.Errorf("actor asc tie-break should be ts DESC; got [1]=%q", got[1].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctAuditActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, uid := newAuditTestStore(t)
|
||||
|
||||
Reference in New Issue
Block a user