package validator import ( "context" "errors" "regexp" "testing" ) func TestRuleValidator_Empty(t *testing.T) { v := NewRuleValidator("test", "v1") got, err := v.Validate(context.Background(), DiffInput{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !got.Approved { t.Errorf("empty rules should approve, got %+v", got) } if got.Score != 1.0 { t.Errorf("empty rules score should be 1.0, got %v", got.Score) } if got.ValidatorName != "test" { t.Errorf("ValidatorName mismatch: got %q want %q", got.ValidatorName, "test") } if got.PolicyVersion != "v1" { t.Errorf("PolicyVersion mismatch: got %q want %q", got.PolicyVersion, "v1") } } func TestRuleValidator_AllApprove(t *testing.T) { v := NewRuleValidator("test", "v1", &fakeRule{name: "a", approved: true}, &fakeRule{name: "b", approved: true}, ) got, err := v.Validate(context.Background(), DiffInput{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !got.Approved { t.Errorf("all approved should approve overall, got %+v", got) } if got.Score != 1.0 { t.Errorf("score should be 1.0, got %v", got.Score) } if got.Reason != "" { t.Errorf("approved reason should be empty, got %q", got.Reason) } } func TestRuleValidator_OneBlocks(t *testing.T) { v := NewRuleValidator("test", "v1", &fakeRule{name: "a", approved: true}, &fakeRule{name: "b", approved: false, severity: SeverityBlock, reason: "boom"}, ) got, err := v.Validate(context.Background(), DiffInput{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if got.Approved { t.Errorf("block should not approve overall, got %+v", got) } if got.Severity != SeverityBlock { t.Errorf("severity should be Block, got %v", got.Severity) } if got.Score != 0.5 { t.Errorf("score should be 0.5 (1/2 passed), got %v", got.Score) } if got.Reason != "b: boom" { t.Errorf("reason mismatch: got %q", got.Reason) } } func TestRuleValidator_WarnDoesNotEscalate(t *testing.T) { v := NewRuleValidator("test", "v1", &fakeRule{name: "a", approved: false, severity: SeverityWarn, reason: "minor"}, &fakeRule{name: "b", approved: true}, ) got, err := v.Validate(context.Background(), DiffInput{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if got.Approved { t.Errorf("warn should not approve overall, got %+v", got) } if got.Severity != SeverityWarn { t.Errorf("severity should stay Warn when no Block rules fail, got %v", got.Severity) } } func TestRuleValidator_BlockBeatsWarn(t *testing.T) { v := NewRuleValidator("test", "v1", &fakeRule{name: "warn", approved: false, severity: SeverityWarn, reason: "minor"}, &fakeRule{name: "block", approved: false, severity: SeverityBlock, reason: "major"}, ) got, _ := v.Validate(context.Background(), DiffInput{}) if got.Severity != SeverityBlock { t.Errorf("Block should win over Warn, got %v", got.Severity) } if got.Score != 0.0 { t.Errorf("0/2 passed should give Score=0, got %v", got.Score) } } func TestRuleValidator_CancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() v := NewRuleValidator("test", "v1", &fakeRule{name: "a", approved: true}) got, err := v.Validate(ctx, DiffInput{}) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } if got.Approved { t.Errorf("cancelled should Block, got %+v", got) } if got.Severity != SeverityBlock { t.Errorf("severity on cancel should be Block, got %v", got.Severity) } if got.ValidatorName != "test" { t.Errorf("cancelled verdict should still set ValidatorName, got %q", got.ValidatorName) } } func TestRuleValidator_DetailsBreakdown(t *testing.T) { v := NewRuleValidator("test", "v1", &fakeRule{name: "size", approved: false, severity: SeverityBlock, reason: "too big"}, &fakeRule{name: "whitelist", approved: true}, ) got, _ := v.Validate(context.Background(), DiffInput{}) if _, ok := got.Details["size"]; !ok { t.Errorf("details should include 'size' key, got %v", got.Details) } if _, ok := got.Details["whitelist"]; !ok { t.Errorf("details should include 'whitelist' key, got %v", got.Details) } sizeDetail, _ := got.Details["size"].(map[string]any) if sizeDetail["approved"] != false { t.Errorf("size approved should be false in details, got %v", sizeDetail["approved"]) } if sizeDetail["reason"] != "too big" { t.Errorf("size reason should be 'too big' in details, got %v", sizeDetail["reason"]) } } // -- DiffSizeRule -- func TestDiffSizeRule(t *testing.T) { rule := &DiffSizeRule{MaxRows: 100} t.Run("below limit (int)", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"affected_rows": 50}}) if !res.Approved { t.Errorf("50 rows should pass, got %+v", res) } }) t.Run("at limit", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"affected_rows": 100}}) if !res.Approved { t.Errorf("exactly 100 (limit) should pass, got %+v", res) } }) t.Run("over limit", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"affected_rows": 200}}) if res.Approved { t.Errorf("200 rows should block, got %+v", res) } if res.Severity != SeverityBlock { t.Errorf("over-limit should be Block, got %v", res.Severity) } }) t.Run("missing metadata", func(t *testing.T) { res := rule.Apply(DiffInput{}) if !res.Approved { t.Errorf("missing metadata should skip-approve, got %+v", res) } if res.Severity != SeverityWarn { t.Errorf("skip should be Warn, got %v", res.Severity) } }) t.Run("non-numeric type", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"affected_rows": "fifty"}}) if res.Approved { t.Errorf("string affected_rows should not approve, got %+v", res) } }) t.Run("float64 coercion (JSON default)", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"affected_rows": float64(150)}}) if res.Approved { t.Errorf("150.0 rows should block, got %+v", res) } }) } // -- TableWhitelistRule -- func TestTableWhitelistRule(t *testing.T) { rule := &TableWhitelistRule{Allowed: []string{"orders", "items"}} t.Run("in whitelist", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"table_name": "orders"}}) if !res.Approved { t.Errorf("orders in whitelist should pass, got %+v", res) } }) t.Run("not in whitelist", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"table_name": "users"}}) if res.Approved { t.Errorf("users not in whitelist should block, got %+v", res) } if res.Severity != SeverityBlock { t.Errorf("not whitelisted should be Block, got %v", res.Severity) } }) t.Run("missing metadata", func(t *testing.T) { res := rule.Apply(DiffInput{}) if !res.Approved { t.Errorf("missing metadata should skip-approve, got %+v", res) } }) t.Run("non-string type", func(t *testing.T) { res := rule.Apply(DiffInput{Metadata: map[string]any{"table_name": 42}}) if res.Approved { t.Errorf("int table_name should block, got %+v", res) } }) t.Run("empty whitelist", func(t *testing.T) { r := &TableWhitelistRule{} res := r.Apply(DiffInput{Metadata: map[string]any{"table_name": "anything"}}) if res.Approved { t.Errorf("empty whitelist should block all tables, got %+v", res) } }) } // -- PatternRule -- func TestPatternRule(t *testing.T) { rule := &PatternRule{Patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)\bDROP\s+TABLE\b`), regexp.MustCompile(`(?i)\bTRUNCATE\b`), regexp.MustCompile(`(?i)\bGRANT\b`), }} t.Run("safe diff", func(t *testing.T) { res := rule.Apply(DiffInput{Raw: []byte(`UPDATE orders SET status = 'pending' WHERE id = 1`)}) if !res.Approved { t.Errorf("safe diff should pass, got %+v", res) } }) t.Run("DROP TABLE caught", func(t *testing.T) { res := rule.Apply(DiffInput{Raw: []byte(`DROP TABLE orders`)}) if res.Approved { t.Errorf("DROP TABLE should block, got %+v", res) } if res.Severity != SeverityBlock { t.Errorf("DROP TABLE should be Block, got %v", res.Severity) } }) t.Run("lowercase also caught", func(t *testing.T) { res := rule.Apply(DiffInput{Raw: []byte(`truncate orders`)}) if res.Approved { t.Errorf("lowercase truncate should block, got %+v", res) } }) t.Run("no patterns", func(t *testing.T) { r := &PatternRule{} res := r.Apply(DiffInput{Raw: []byte(`DROP TABLE orders`)}) if !res.Approved { t.Errorf("no patterns should always approve, got %+v", res) } }) } // -- toInt helper -- func TestToInt(t *testing.T) { tests := []struct { name string v any want int ok bool }{ {"int", 42, 42, true}, {"int32", int32(42), 42, true}, {"int64", int64(42), 42, true}, {"float32", float32(42.0), 42, true}, {"float64 exact", float64(42), 42, true}, {"float64 truncate", float64(42.7), 42, true}, {"string", "42", 0, false}, {"bool", true, 0, false}, {"nil", nil, 0, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, ok := toInt(tc.v) if ok != tc.ok || got != tc.want { t.Errorf("toInt(%v): got (%d, %v), want (%d, %v)", tc.v, got, ok, tc.want, tc.ok) } }) } } // -- helpers -- type fakeRule struct { name string approved bool severity Severity reason string } func (r *fakeRule) Name() string { return r.name } func (r *fakeRule) Apply(_ DiffInput) RuleResult { return RuleResult{ Approved: r.approved, Severity: r.severity, Reason: r.reason, } }