mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
277 lines
8.5 KiB
Go
277 lines
8.5 KiB
Go
package audit
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// TestQueryPlansUseIndexes validates that critical audit log SQL queries use
|
|
// indexed lookups rather than full table scans. The audit_events table grows
|
|
// continuously, so unindexed queries would degrade over time.
|
|
//
|
|
// NOTE: Schema and SQL are intentionally duplicated from sqlite_logger.go.
|
|
// If the production schema or queries change, these tests must be updated
|
|
// in lockstep.
|
|
func TestQueryPlansUseIndexes(t *testing.T) {
|
|
db := newAuditPlanTestDB(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
args []any
|
|
// wantIndex, if non-empty, asserts this index name appears on the
|
|
// same plan line as a SEARCH on audit_events.
|
|
wantIndex string
|
|
// allowCoveringIndexScan permits a covering-index scan
|
|
// but still rejects a bare full table scan.
|
|
allowCoveringIndexScan bool
|
|
// allowScan permits any plan including bare full table scans.
|
|
allowScan bool
|
|
}{
|
|
// --- Query paths (exact SQL shapes from sqlite_logger.go Query()) ---
|
|
{
|
|
name: "query by timestamp range (most common filter)",
|
|
query: `SELECT id, timestamp, event_type, user, ip, path, success, details, signature
|
|
FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?`,
|
|
args: []any{int64(0), int64(9999999999), 100},
|
|
wantIndex: "idx_audit_timestamp",
|
|
},
|
|
{
|
|
name: "query by event_type + timestamp",
|
|
query: `SELECT id, timestamp, event_type, user, ip, path, success, details, signature
|
|
FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?
|
|
AND event_type = ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?`,
|
|
args: []any{int64(0), int64(9999999999), "auth_login", 100},
|
|
// Planner may use idx_audit_event_type or idx_audit_timestamp —
|
|
// either avoids a full table scan.
|
|
},
|
|
{
|
|
name: "query by user + timestamp",
|
|
query: `SELECT id, timestamp, event_type, user, ip, path, success, details, signature
|
|
FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?
|
|
AND user = ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?`,
|
|
args: []any{int64(0), int64(9999999999), "admin", 100},
|
|
// Planner may use idx_audit_user or idx_audit_timestamp.
|
|
},
|
|
// --- Count paths (exact SQL shapes from sqlite_logger.go Count()) ---
|
|
{
|
|
name: "count by timestamp range",
|
|
query: `SELECT COUNT(*) FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?`,
|
|
args: []any{int64(0), int64(9999999999)},
|
|
wantIndex: "idx_audit_timestamp",
|
|
},
|
|
{
|
|
name: "count by event_type + timestamp",
|
|
query: `SELECT COUNT(*) FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?
|
|
AND event_type = ?`,
|
|
args: []any{int64(0), int64(9999999999), "auth_login"},
|
|
},
|
|
{
|
|
name: "count by success + timestamp",
|
|
query: `SELECT COUNT(*) FROM audit_events WHERE 1=1
|
|
AND timestamp >= ? AND timestamp <= ?
|
|
AND success = ?`,
|
|
args: []any{int64(0), int64(9999999999), int64(1)},
|
|
// success is a binary column (0/1) with very low selectivity.
|
|
// The planner correctly prefers a full scan over index lookup
|
|
// when most rows match. This is acceptable for a low-frequency
|
|
// admin query.
|
|
allowScan: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
plan := auditExplainQueryPlan(t, db, tt.query, tt.args)
|
|
|
|
if tt.allowScan {
|
|
t.Logf("Plan (allowed scan):\n%s", plan)
|
|
return
|
|
}
|
|
|
|
// Reject bare full table scan on audit_events.
|
|
if auditContainsFullTableScan(plan) {
|
|
t.Errorf("query uses full table scan on audit_events; expected indexed access\nPlan:\n%s", plan)
|
|
}
|
|
|
|
if tt.allowCoveringIndexScan {
|
|
if !auditContainsSearch(plan) && !auditContainsCoveringIndexScan(plan) {
|
|
t.Errorf("query plan has neither SEARCH nor covering-index scan on audit_events\nPlan:\n%s", plan)
|
|
}
|
|
} else {
|
|
if !auditContainsSearch(plan) {
|
|
t.Errorf("query plan does not contain SEARCH on audit_events; may not be using an index\nPlan:\n%s", plan)
|
|
}
|
|
}
|
|
|
|
if tt.wantIndex != "" && !auditContainsSearchWithIndex(plan, tt.wantIndex) {
|
|
t.Errorf("expected SEARCH on audit_events using index %q not found in plan\nPlan:\n%s", tt.wantIndex, plan)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newAuditPlanTestDB creates an in-memory SQLite database with the audit schema.
|
|
func newAuditPlanTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
dsn := ":memory:?" + url.Values{
|
|
"_pragma": []string{
|
|
"busy_timeout(5000)",
|
|
"journal_mode(WAL)",
|
|
},
|
|
}.Encode()
|
|
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Schema matches sqlite_logger.go initSchema()
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp INTEGER NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
user TEXT,
|
|
ip TEXT,
|
|
path TEXT,
|
|
success INTEGER NOT NULL,
|
|
details TEXT,
|
|
signature TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_events(event_type);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_events(user) WHERE user != '';
|
|
CREATE INDEX IF NOT EXISTS idx_audit_success ON audit_events(success);
|
|
`
|
|
if _, err := db.Exec(schema); err != nil {
|
|
t.Fatalf("create schema: %v", err)
|
|
}
|
|
|
|
// Seed data so the query planner has real statistics. Errors are checked
|
|
// to catch schema drift or constraint violations early.
|
|
eventTypes := []string{"auth_login", "auth_logout", "settings_change", "node_added", "alert_fired"}
|
|
users := []string{"admin", "operator", "viewer", "", ""}
|
|
for i := 0; i < 200; i++ {
|
|
if _, err := db.Exec(
|
|
`INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, '127.0.0.1', '/api/test', 1, '{}', 'sig')`,
|
|
fmt.Sprintf("evt-%03d", i),
|
|
int64(1700000000+i*5),
|
|
eventTypes[i%len(eventTypes)],
|
|
users[i%len(users)],
|
|
); err != nil {
|
|
t.Fatalf("seed event %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
if _, err := db.Exec("ANALYZE"); err != nil {
|
|
t.Fatalf("analyze: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// auditExplainQueryPlan runs EXPLAIN QUERY PLAN and returns the full plan.
|
|
func auditExplainQueryPlan(t *testing.T, db *sql.DB, query string, args []any) string {
|
|
t.Helper()
|
|
|
|
rows, err := db.Query("EXPLAIN QUERY PLAN "+query, args...)
|
|
if err != nil {
|
|
t.Fatalf("EXPLAIN QUERY PLAN: %v\nQuery: %s", err, query)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var lines []string
|
|
for rows.Next() {
|
|
var id, parent, notused int
|
|
var detail string
|
|
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
|
t.Fatalf("scan explain row: %v", err)
|
|
}
|
|
lines = append(lines, detail)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("explain rows: %v", err)
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// auditLineRefersToEvents returns true if a plan line references audit_events
|
|
// (but not audit_config or other tables).
|
|
func auditLineRefersToEvents(line string) bool {
|
|
for _, sep := range []string{" ", "(", ")"} {
|
|
if strings.Contains(line, "audit_events"+sep) {
|
|
return true
|
|
}
|
|
}
|
|
return strings.HasSuffix(strings.TrimSpace(line), "audit_events")
|
|
}
|
|
|
|
// auditContainsSearch returns true if any plan line has SEARCH on audit_events.
|
|
func auditContainsSearch(plan string) bool {
|
|
for _, line := range strings.Split(plan, "\n") {
|
|
if strings.Contains(line, "SEARCH") && auditLineRefersToEvents(line) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// auditContainsSearchWithIndex returns true if a plan line has SEARCH on
|
|
// audit_events with the given index name.
|
|
func auditContainsSearchWithIndex(plan, indexName string) bool {
|
|
for _, line := range strings.Split(plan, "\n") {
|
|
if strings.Contains(line, "SEARCH") && auditLineRefersToEvents(line) && strings.Contains(line, indexName) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// auditContainsCoveringIndexScan returns true if any plan line shows a
|
|
// covering-index scan on audit_events.
|
|
func auditContainsCoveringIndexScan(plan string) bool {
|
|
for _, line := range strings.Split(plan, "\n") {
|
|
if strings.Contains(line, "SCAN") && auditLineRefersToEvents(line) && strings.Contains(line, "INDEX") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// auditContainsFullTableScan returns true if the plan has a bare full table
|
|
// scan on audit_events (SCAN without USING INDEX).
|
|
func auditContainsFullTableScan(plan string) bool {
|
|
for _, line := range strings.Split(plan, "\n") {
|
|
if !strings.Contains(line, "SCAN") || !auditLineRefersToEvents(line) {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "USING") && strings.Contains(line, "INDEX") {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|