rules_test.go
package rules
import (
"testing"
"github.com/readysite/readysite/website/models"
)
// =============================================================================
// Simple Comparisons
// =============================================================================
func TestEvaluateRule_SimpleComparisons(t *testing.T) {
ctx := &Context{
User: &models.User{
Name: "Test User",
Role: "admin",
},
}
ctx.User.ID = "user-123"
record := map[string]any{
"status": "published",
"priority": float64(5),
"count": float64(10),
"name": "hello",
}
tests := []struct {
name string
rule string
expected bool
}{
// Equality
{"string equal", "status = 'published'", true},
{"string not equal match", "status != 'draft'", true},
{"string equal mismatch", "status = 'draft'", false},
{"string not equal mismatch", "status != 'published'", false},
// Numeric equality
{"number equal", "priority = 5", true},
{"number not equal", "priority != 3", true},
{"number equal mismatch", "priority = 3", false},
// Greater than
{"greater than true", "count > 5", true},
{"greater than false", "count > 15", false},
{"greater than equal boundary", "count > 10", false},
// Greater than or equal
{"gte true above", "count >= 5", true},
{"gte true equal", "count >= 10", true},
{"gte false", "count >= 15", false},
// Less than
{"less than true", "count < 15", true},
{"less than false", "count < 5", false},
{"less than equal boundary", "count < 10", false},
// Less than or equal
{"lte true below", "count <= 15", true},
{"lte true equal", "count <= 10", true},
{"lte false", "count <= 5", false},
// Context variable comparisons
{"context auth id equal", "@request.auth.id = 'user-123'", true},
{"context auth id not equal", "@request.auth.id != 'user-456'", true},
{"context auth role equal", "@request.auth.role = 'admin'", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Boolean Operators
// =============================================================================
func TestEvaluateRule_BooleanOperators(t *testing.T) {
ctx := &Context{
User: &models.User{
Name: "Test User",
Role: "admin",
},
}
ctx.User.ID = "user-123"
record := map[string]any{
"status": "published",
"priority": float64(5),
"visible": true,
}
tests := []struct {
name string
rule string
expected bool
}{
// AND (&&)
{"and both true", "status = 'published' && priority = 5", true},
{"and left false", "status = 'draft' && priority = 5", false},
{"and right false", "status = 'published' && priority = 99", false},
{"and both false", "status = 'draft' && priority = 99", false},
// OR (||)
{"or both true", "status = 'published' || priority = 5", true},
{"or left true", "status = 'published' || priority = 99", true},
{"or right true", "status = 'draft' || priority = 5", true},
{"or both false", "status = 'draft' || priority = 99", false},
// Combined operators
{"and then or", "status = 'draft' && priority = 5 || visible = true", true},
{"or then and", "status = 'published' || priority = 99 && visible = false", true},
// Parenthesized groups
{"grouped or with and", "(status = 'published' || status = 'draft') && priority = 5", true},
{"grouped and fails", "(status = 'draft' || priority = 99) && visible = true", false},
// Context with boolean ops
{"auth check and field", "@request.auth.role = 'admin' && status = 'published'", true},
{"auth check or field", "@request.auth.id = 'wrong' || status = 'published'", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// String Operations
// =============================================================================
func TestEvaluateRule_StringOperations(t *testing.T) {
ctx := &Context{}
record := map[string]any{
"title": "Hello World",
"email": "user@example.com",
"category": "technology",
"tags": "go,rust,python",
}
tests := []struct {
name string
rule string
expected bool
}{
// LIKE matching (~)
{"like prefix", "title ~ 'Hello%'", true},
{"like suffix", "title ~ '%World'", true},
{"like contains", "title ~ '%lo Wo%'", true},
{"like full match", "title ~ 'Hello World'", true},
{"like no match", "title ~ 'Goodbye%'", false},
// NOT LIKE matching (!~)
{"not like no match", "title !~ 'Goodbye%'", true},
{"not like match", "title !~ 'Hello%'", false},
// Single character wildcard
{"like single char wildcard", "title ~ 'Hell_ World'", true},
{"like single char wildcard no match", "title ~ 'Hel_ World'", false},
// Case sensitive equality
{"case sensitive equal", "title = 'Hello World'", true},
{"case sensitive not equal", "title = 'hello world'", false},
// String contains via LIKE
{"email domain match", "email ~ '%@example.com'", true},
{"email domain mismatch", "email ~ '%@other.com'", false},
// Pattern matching
{"category match", "category ~ 'tech%'", true},
{"category no match", "category ~ 'sport%'", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Rule Types (Locked, Public, Expression)
// =============================================================================
func TestEvaluateRule_RuleTypes(t *testing.T) {
adminCtx := &Context{
User: &models.User{
Role: "admin",
},
}
userCtx := &Context{
User: &models.User{
Role: "user",
},
}
guestCtx := &Context{}
t.Run("PublicRule", func(t *testing.T) {
// Empty string = public
result, err := Evaluate("", guestCtx, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result {
t.Error("expected public rule to pass for guest")
}
})
t.Run("LockedRule_Admin", func(t *testing.T) {
result, err := Evaluate("locked", adminCtx, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result {
t.Error("expected locked rule to pass for admin")
}
})
t.Run("LockedRule_User", func(t *testing.T) {
result, err := Evaluate("locked", userCtx, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result {
t.Error("expected locked rule to fail for non-admin user")
}
})
t.Run("LockedRule_Guest", func(t *testing.T) {
result, err := Evaluate("locked", guestCtx, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result {
t.Error("expected locked rule to fail for guest")
}
})
t.Run("NullRule", func(t *testing.T) {
result, err := Evaluate("null", guestCtx, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result {
t.Error("expected null rule to fail for guest")
}
})
}
// =============================================================================
// Null Comparisons
// =============================================================================
func TestEvaluateRule_NullComparisons(t *testing.T) {
ctx := &Context{}
record := map[string]any{
"title": "Hello",
"subtitle": nil,
}
tests := []struct {
name string
rule string
expected bool
}{
{"field equals null", "subtitle = null", true},
{"field not equals null", "title != null", true},
{"non-null field equals null", "title = null", false},
{"null field not equals null", "subtitle != null", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Boolean Value Comparisons
// =============================================================================
func TestEvaluateRule_BooleanValues(t *testing.T) {
ctx := &Context{}
record := map[string]any{
"published": true,
"archived": false,
}
tests := []struct {
name string
rule string
expected bool
}{
{"bool true equals true", "published = true", true},
{"bool true equals false", "published = false", false},
{"bool false equals false", "archived = false", true},
{"bool false equals true", "archived = true", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Field Modifiers
// =============================================================================
func TestEvaluateRule_FieldModifiers(t *testing.T) {
ctx := &Context{}
record := map[string]any{
"title": "Hello",
"description": "",
"tags": []any{"go", "rust", "python"},
"category": "TECHNOLOGY",
}
tests := []struct {
name string
rule string
expected bool
}{
// :isset modifier
{"isset existing field", "title:isset = true", true},
{"isset missing field", "missing_field:isset = true", false},
// :length modifier
{"length of string", "title:length = 5", true},
{"length of array", "tags:length = 3", true},
{"length of empty string", "description:length = 0", true},
// :lower modifier
{"lower case match", "category:lower = 'technology'", true},
{"lower case mismatch", "category:lower = 'TECHNOLOGY'", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Invalid Expressions
// =============================================================================
func TestEvaluateRule_InvalidExpressions(t *testing.T) {
ctx := &Context{}
record := map[string]any{"title": "Hello"}
tests := []struct {
name string
rule string
}{
{"unterminated string", "title = 'hello"},
{"missing operator", "title 'hello'"},
{"missing right value", "title ="},
{"unexpected character", "title = #invalid"},
{"unmatched paren", "(title = 'hello'"},
{"double operator", "title = = 'hello'"},
{"empty expression after and", "title = 'hello' &&"},
{"empty expression after or", "title = 'hello' ||"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Evaluate(tt.rule, ctx, record)
if err == nil {
t.Errorf("expected error for invalid rule %q, but got nil", tt.rule)
}
})
}
}
// =============================================================================
// ParseRuleType
// =============================================================================
func TestParseRuleType(t *testing.T) {
tests := []struct {
rule string
expected RuleType
}{
{"", RuleTypePublic},
{"locked", RuleTypeLocked},
{"null", RuleTypeLocked},
{"status = 'published'", RuleTypeExpression},
{"@request.auth.id != ''", RuleTypeExpression},
}
for _, tt := range tests {
t.Run(tt.rule, func(t *testing.T) {
result := ParseRuleType(tt.rule)
if result != tt.expected {
t.Errorf("ParseRuleType(%q): expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Context Variables
// =============================================================================
func TestEvaluateRule_ContextVariables(t *testing.T) {
ctx := &Context{
User: &models.User{
Email: "admin@example.com",
Name: "Admin User",
Role: "admin",
Verified: true,
},
Method: "POST",
Query: map[string]string{
"page": "1",
"sort": "name",
},
Headers: map[string]string{
"content_type": "application/json",
},
Body: map[string]any{
"title": "New Post",
},
}
ctx.User.ID = "admin-123"
record := map[string]any{}
tests := []struct {
name string
rule string
expected bool
}{
{"auth id", "@request.auth.id = 'admin-123'", true},
{"auth email", "@request.auth.email = 'admin@example.com'", true},
{"auth name", "@request.auth.name = 'Admin User'", true},
{"auth role", "@request.auth.role = 'admin'", true},
{"method", "@request.method = 'POST'", true},
{"method mismatch", "@request.method = 'GET'", false},
{"query param", "@request.query.page = '1'", true},
{"query param mismatch", "@request.query.page = '2'", false},
{"header", "@request.headers.content_type = 'application/json'", true},
{"body field", "@request.body.title = 'New Post'", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Guest Context (no user)
// =============================================================================
func TestEvaluateRule_GuestContext(t *testing.T) {
ctx := &Context{
Method: "GET",
Query: map[string]string{},
Headers: map[string]string{},
}
record := map[string]any{
"published": true,
}
tests := []struct {
name string
rule string
expected bool
}{
{"guest auth id is empty", "@request.auth.id = ''", true},
{"guest auth id not equal to value", "@request.auth.id != 'some-id'", true},
{"field check still works", "published = true", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Evaluate(tt.rule, ctx, record)
if err != nil {
t.Fatalf("unexpected error evaluating %q: %v", tt.rule, err)
}
if result != tt.expected {
t.Errorf("rule %q: expected %v, got %v", tt.rule, tt.expected, result)
}
})
}
}
// =============================================================================
// Sensitive Auth Fields
// =============================================================================
func TestEvaluateRule_SensitiveFields(t *testing.T) {
ctx := &Context{
User: &models.User{
Role: "admin",
},
}
record := map[string]any{}
sensitiveFields := []string{
"@request.auth.passwordHash",
"@request.auth.password",
"@request.auth.tokenKey",
}
for _, field := range sensitiveFields {
t.Run(field, func(t *testing.T) {
rule := field + " = 'anything'"
_, err := Evaluate(rule, ctx, record)
if err == nil {
t.Errorf("expected error accessing sensitive field %q", field)
}
})
}
}
// =============================================================================
// ToSQLFilter
// =============================================================================
func TestToSQLFilter(t *testing.T) {
ctx := &Context{
User: &models.User{
Role: "admin",
},
}
ctx.User.ID = "user-123"
t.Run("PublicRule", func(t *testing.T) {
sql, params, err := ToSQLFilter("", ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sql != "" {
t.Errorf("expected empty SQL for public rule, got %q", sql)
}
if params != nil {
t.Errorf("expected nil params for public rule, got %v", params)
}
})
t.Run("LockedRule", func(t *testing.T) {
_, _, err := ToSQLFilter("locked", ctx)
if err == nil {
t.Error("expected error for locked rule SQL conversion")
}
})
t.Run("ExpressionRule", func(t *testing.T) {
sql, params, err := ToSQLFilter("status = 'published'", ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sql == "" {
t.Error("expected non-empty SQL for expression rule")
}
if len(params) == 0 {
t.Error("expected params for expression rule")
}
})
}