readysite / website / internal / content / rules / rules_test.go
16.7 KB
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")
		}
	})
}
← Back