Fix some blocked/allow laggards after migrating. Add DuckDB for outstanding analyitcs performance. Start adding an import for all bot networks
This commit is contained in:
65
test/controllers/rules_controller_test.rb
Normal file
65
test/controllers/rules_controller_test.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
require "test_helper"
|
||||
|
||||
class RulesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:one)
|
||||
sign_in_as(@user)
|
||||
end
|
||||
|
||||
test "should create network rule with add_header action" do
|
||||
assert_difference('Rule.count') do
|
||||
post rules_path, params: {
|
||||
rule: {
|
||||
waf_rule_type: "network",
|
||||
waf_action: "add_header",
|
||||
network_range_id: "",
|
||||
conditions: "{}",
|
||||
metadata: "{}",
|
||||
source: "manual",
|
||||
expires_at: "",
|
||||
enabled: "1"
|
||||
},
|
||||
new_cidr: "52.167.145.0/24",
|
||||
path_pattern: "",
|
||||
match_type: "exact",
|
||||
header_name: "X-Bot-Agent",
|
||||
header_value: "Blah"
|
||||
}
|
||||
end
|
||||
|
||||
rule = Rule.last
|
||||
assert_equal "network", rule.waf_rule_type
|
||||
assert_equal "add_header", rule.waf_action, "waf_action should be 'add_header' but was #{rule.waf_action.inspect}"
|
||||
assert_equal "X-Bot-Agent", rule.metadata["header_name"]
|
||||
assert_equal "Blah", rule.metadata["header_value"]
|
||||
assert_not_nil rule.network_range
|
||||
# Network range stores as /32 if no prefix given
|
||||
assert_match /52\.167\.145\./, rule.network_range.network.to_s
|
||||
|
||||
# Verify metadata JSON doesn't have duplicate keys
|
||||
metadata_json = rule.metadata.to_json
|
||||
refute_includes metadata_json, '"header_name":"X-Bot-Agent","header_value":"Blah","reason":"{}","header_name"',
|
||||
"Metadata should not have duplicate keys"
|
||||
end
|
||||
|
||||
test "should create rule with waf_action properly set from string parameter" do
|
||||
assert_difference('Rule.count') do
|
||||
post rules_path, params: {
|
||||
rule: {
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny", # Test with different action
|
||||
network_range_id: "",
|
||||
conditions: "{}",
|
||||
metadata: '{"reason": "test"}',
|
||||
source: "manual",
|
||||
enabled: "1"
|
||||
},
|
||||
new_cidr: "10.0.0.1/32"
|
||||
}
|
||||
end
|
||||
|
||||
rule = Rule.last
|
||||
assert_equal "deny", rule.waf_action, "waf_action should be 'deny'"
|
||||
assert_equal "network", rule.waf_rule_type
|
||||
end
|
||||
end
|
||||
66
test/fixtures/files/ipapi_91_84_96_0.json
vendored
Normal file
66
test/fixtures/files/ipapi_91_84_96_0.json
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ip": "91.84.96.0",
|
||||
"rir": "RIPE",
|
||||
"is_bogon": false,
|
||||
"is_mobile": false,
|
||||
"is_satellite": false,
|
||||
"is_crawler": false,
|
||||
"is_datacenter": true,
|
||||
"is_tor": false,
|
||||
"is_proxy": false,
|
||||
"is_vpn": false,
|
||||
"is_abuser": false,
|
||||
"datacenter": {
|
||||
"datacenter": "SERVERS TECH FZCO",
|
||||
"domain": "vdsina.com",
|
||||
"network": "91.84.96.0 - 91.84.127.255"
|
||||
},
|
||||
"company": {
|
||||
"name": "SERVERS TECH FZCO",
|
||||
"abuser_score": "0.0162 (Elevated)",
|
||||
"domain": "vdsina.com",
|
||||
"type": "hosting",
|
||||
"network": "91.84.96.0 - 91.84.127.255",
|
||||
"whois": "https://api.ipapi.is/?whois=91.84.96.0"
|
||||
},
|
||||
"abuse": {
|
||||
"name": "SERVERS TECH FZCO",
|
||||
"address": "UNITED ARAB EMIRATES, Dubai, 336469, Ifza Business Park DDP, Building 1, office number 36298-001",
|
||||
"email": "abuse@vdsina.com",
|
||||
"phone": "+971525386329"
|
||||
},
|
||||
"asn": {
|
||||
"asn": 216071,
|
||||
"abuser_score": "0.0181 (Elevated)",
|
||||
"route": "91.84.96.0/24",
|
||||
"descr": "VDSINA, AE",
|
||||
"country": "ae",
|
||||
"active": true,
|
||||
"org": "SERVERS TECH FZCO",
|
||||
"domain": "vdsina.com",
|
||||
"abuse": "abuse@vdsina.com",
|
||||
"type": "hosting",
|
||||
"created": "2023-10-30",
|
||||
"updated": "2023-10-30",
|
||||
"rir": "RIPE",
|
||||
"whois": "https://api.ipapi.is/?whois=AS216071"
|
||||
},
|
||||
"location": {
|
||||
"is_eu_member": true,
|
||||
"calling_code": "31",
|
||||
"currency_code": "EUR",
|
||||
"continent": "EU",
|
||||
"country": "The Netherlands",
|
||||
"country_code": "NL",
|
||||
"state": "North Holland",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.37403,
|
||||
"longitude": 4.88969,
|
||||
"zip": "1384",
|
||||
"timezone": "Europe/Brussels",
|
||||
"local_time": "2025-11-17T22:21:06+01:00",
|
||||
"local_time_unix": 1763414466,
|
||||
"is_dst": false
|
||||
},
|
||||
"elapsed_ms": 0.5
|
||||
}
|
||||
195
test/jobs/cleanup_old_events_job_test.rb
Normal file
195
test/jobs/cleanup_old_events_job_test.rb
Normal file
@@ -0,0 +1,195 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class CleanupOldEventsJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
# Clear any existing events
|
||||
Event.delete_all
|
||||
# Set default retention to 90 days
|
||||
Setting.set('event_retention_days', '90')
|
||||
end
|
||||
|
||||
test "deletes events older than retention period" do
|
||||
# Create old event (100 days ago - should be deleted)
|
||||
old_event = Event.create!(
|
||||
request_id: "old-request-#{SecureRandom.uuid}",
|
||||
timestamp: 100.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
# Create recent event (30 days ago - should be kept)
|
||||
recent_event = Event.create!(
|
||||
request_id: "recent-request-#{SecureRandom.uuid}",
|
||||
timestamp: 30.days.ago,
|
||||
ip_address: "5.6.7.8",
|
||||
payload: { request: { ip: "5.6.7.8" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
assert_raises(ActiveRecord::RecordNotFound) { old_event.reload }
|
||||
assert_nothing_raised { recent_event.reload }
|
||||
end
|
||||
|
||||
test "respects custom retention period" do
|
||||
# Set retention to 30 days
|
||||
Setting.set('event_retention_days', '30')
|
||||
|
||||
# Create event that's 40 days old (should be deleted with 30-day retention)
|
||||
old_event = Event.create!(
|
||||
request_id: "old-request-#{SecureRandom.uuid}",
|
||||
timestamp: 40.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
# Create event that's 20 days old (should be kept)
|
||||
recent_event = Event.create!(
|
||||
request_id: "recent-request-#{SecureRandom.uuid}",
|
||||
timestamp: 20.days.ago,
|
||||
ip_address: "5.6.7.8",
|
||||
payload: { request: { ip: "5.6.7.8" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
assert_raises(ActiveRecord::RecordNotFound) { old_event.reload }
|
||||
assert_nothing_raised { recent_event.reload }
|
||||
end
|
||||
|
||||
test "does not delete when retention is zero" do
|
||||
Setting.set('event_retention_days', '0')
|
||||
|
||||
old_event = Event.create!(
|
||||
request_id: "old-request-#{SecureRandom.uuid}",
|
||||
timestamp: 100.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
assert_nothing_raised { old_event.reload }
|
||||
end
|
||||
|
||||
test "does not delete when retention is negative" do
|
||||
Setting.set('event_retention_days', '-1')
|
||||
|
||||
old_event = Event.create!(
|
||||
request_id: "old-request-#{SecureRandom.uuid}",
|
||||
timestamp: 100.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
assert_nothing_raised { old_event.reload }
|
||||
end
|
||||
|
||||
test "returns zero when no old events exist" do
|
||||
# Create only recent events
|
||||
Event.create!(
|
||||
request_id: "recent-request-#{SecureRandom.uuid}",
|
||||
timestamp: 30.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
end
|
||||
|
||||
test "returns zero when no events exist" do
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
end
|
||||
|
||||
test "deletes multiple old events" do
|
||||
# Create 5 old events
|
||||
5.times do |i|
|
||||
Event.create!(
|
||||
request_id: "old-request-#{i}-#{SecureRandom.uuid}",
|
||||
timestamp: 100.days.ago,
|
||||
ip_address: "1.2.3.#{i}",
|
||||
payload: { request: { ip: "1.2.3.#{i}" } }
|
||||
)
|
||||
end
|
||||
|
||||
# Create 3 recent events
|
||||
3.times do |i|
|
||||
Event.create!(
|
||||
request_id: "recent-request-#{i}-#{SecureRandom.uuid}",
|
||||
timestamp: 30.days.ago,
|
||||
ip_address: "5.6.7.#{i}",
|
||||
payload: { request: { ip: "5.6.7.#{i}" } }
|
||||
)
|
||||
end
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 5, count
|
||||
assert_equal 3, Event.count
|
||||
end
|
||||
|
||||
test "uses default retention when setting not configured" do
|
||||
# Remove the setting
|
||||
Setting.find_by(key: 'event_retention_days')&.destroy
|
||||
|
||||
# Create event that's 100 days old (should be deleted with default 90-day retention)
|
||||
old_event = Event.create!(
|
||||
request_id: "old-request-#{SecureRandom.uuid}",
|
||||
timestamp: 100.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
# Create event that's 80 days old (should be kept with default 90-day retention)
|
||||
recent_event = Event.create!(
|
||||
request_id: "recent-request-#{SecureRandom.uuid}",
|
||||
timestamp: 80.days.ago,
|
||||
ip_address: "5.6.7.8",
|
||||
payload: { request: { ip: "5.6.7.8" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
assert_raises(ActiveRecord::RecordNotFound) { old_event.reload }
|
||||
assert_nothing_raised { recent_event.reload }
|
||||
end
|
||||
|
||||
test "handles events at exact cutoff boundary correctly" do
|
||||
Setting.set('event_retention_days', '90')
|
||||
|
||||
# Create event exactly at cutoff (should be deleted - uses < comparison)
|
||||
cutoff_event = Event.create!(
|
||||
request_id: "cutoff-request-#{SecureRandom.uuid}",
|
||||
timestamp: 90.days.ago,
|
||||
ip_address: "1.2.3.4",
|
||||
payload: { request: { ip: "1.2.3.4" } }
|
||||
)
|
||||
|
||||
# Create event just inside cutoff (should be kept)
|
||||
inside_event = Event.create!(
|
||||
request_id: "inside-request-#{SecureRandom.uuid}",
|
||||
timestamp: 89.days.ago,
|
||||
ip_address: "5.6.7.8",
|
||||
payload: { request: { ip: "5.6.7.8" } }
|
||||
)
|
||||
|
||||
count = CleanupOldEventsJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
assert_raises(ActiveRecord::RecordNotFound) { cutoff_event.reload }
|
||||
assert_nothing_raised { inside_event.reload }
|
||||
end
|
||||
end
|
||||
@@ -228,7 +228,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
assert_equal "post", event.request_method
|
||||
assert_equal "deny", event.waf_action
|
||||
assert_equal 1, event.request_method_before_type_cast # POST = 1
|
||||
assert_equal 1, event.waf_action_before_type_cast # DENY = 1
|
||||
assert_equal 0, event.waf_action_before_type_cast # DENY = 0
|
||||
end
|
||||
|
||||
test "payload extraction methods work correctly" do
|
||||
|
||||
233
test/models/rule_path_pattern_test.rb
Normal file
233
test/models/rule_path_pattern_test.rb
Normal file
@@ -0,0 +1,233 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class RulePathPatternTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = User.create!(email_address: "test@example.com", password: "password123")
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule creates valid rule" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin/users",
|
||||
match_type: "exact",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert rule.persisted?, "Rule should be persisted"
|
||||
assert_equal "path_pattern", rule.waf_rule_type
|
||||
assert_equal "deny", rule.waf_action
|
||||
assert_equal "exact", rule.path_match_type
|
||||
assert_equal 2, rule.path_segment_ids.length
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule auto-creates PathSegments" do
|
||||
initial_count = PathSegment.count
|
||||
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/new/path/here",
|
||||
match_type: "prefix",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal initial_count + 3, PathSegment.count, "Should create 3 new segments"
|
||||
assert_equal 3, rule.path_segment_ids.length
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule normalizes to lowercase" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/Admin/Users",
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
|
||||
segments = rule.path_segments_text
|
||||
assert_equal ["admin", "users"], segments, "Segments should be lowercase"
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule reuses existing PathSegments" do
|
||||
# Create segment first
|
||||
PathSegment.find_or_create_segment("admin")
|
||||
initial_count = PathSegment.count
|
||||
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal initial_count, PathSegment.count, "Should not create duplicate segment"
|
||||
assert_equal 1, rule.path_segment_ids.length
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule validates match_type" do
|
||||
assert_raises(ArgumentError, "Should raise for invalid match_type") do
|
||||
Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "invalid",
|
||||
user: @user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "create_path_pattern_rule validates pattern not empty" do
|
||||
assert_raises(ArgumentError, "Should raise for empty pattern") do
|
||||
Rule.create_path_pattern_rule(
|
||||
pattern: "/",
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "validation requires segment_ids for path_pattern rules" do
|
||||
rule = Rule.new(
|
||||
waf_rule_type: "path_pattern",
|
||||
waf_action: "deny",
|
||||
conditions: { match_type: "exact" }, # Missing segment_ids
|
||||
user: @user
|
||||
)
|
||||
|
||||
refute rule.valid?, "Rule should be invalid without segment_ids"
|
||||
assert_includes rule.errors[:conditions], "must include 'segment_ids' array for path_pattern rules"
|
||||
end
|
||||
|
||||
test "validation requires match_type for path_pattern rules" do
|
||||
admin_seg = PathSegment.find_or_create_segment("admin")
|
||||
|
||||
rule = Rule.new(
|
||||
waf_rule_type: "path_pattern",
|
||||
waf_action: "deny",
|
||||
conditions: { segment_ids: [admin_seg.id] }, # Missing match_type
|
||||
user: @user
|
||||
)
|
||||
|
||||
refute rule.valid?, "Rule should be invalid without match_type"
|
||||
assert_includes rule.errors[:conditions], "match_type must be one of: exact, prefix, suffix, contains"
|
||||
end
|
||||
|
||||
test "validation checks match_type is valid" do
|
||||
admin_seg = PathSegment.find_or_create_segment("admin")
|
||||
|
||||
rule = Rule.new(
|
||||
waf_rule_type: "path_pattern",
|
||||
waf_action: "deny",
|
||||
conditions: { segment_ids: [admin_seg.id], match_type: "invalid" },
|
||||
user: @user
|
||||
)
|
||||
|
||||
refute rule.valid?, "Rule should be invalid with invalid match_type"
|
||||
assert_includes rule.errors[:conditions], "match_type must be one of: exact, prefix, suffix, contains"
|
||||
end
|
||||
|
||||
test "validation checks segment IDs exist" do
|
||||
rule = Rule.new(
|
||||
waf_rule_type: "path_pattern",
|
||||
waf_action: "deny",
|
||||
conditions: { segment_ids: [99999], match_type: "exact" }, # Non-existent ID
|
||||
user: @user
|
||||
)
|
||||
|
||||
refute rule.valid?, "Rule should be invalid with non-existent segment IDs"
|
||||
assert_match(/non-existent path segment IDs/, rule.errors[:conditions].first)
|
||||
end
|
||||
|
||||
test "path_pattern_display returns human-readable path" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin/users",
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal "/admin/users", rule.path_pattern_display
|
||||
end
|
||||
|
||||
test "path_segments_text returns segment text array" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/api/v1/users",
|
||||
match_type: "prefix",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal ["api", "v1", "users"], rule.path_segments_text
|
||||
end
|
||||
|
||||
test "to_agent_format includes segment_ids and match_type for path rules" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "prefix",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
agent_format = rule.to_agent_format
|
||||
|
||||
assert_equal "path_pattern", agent_format[:waf_rule_type]
|
||||
assert_equal "deny", agent_format[:waf_action]
|
||||
assert agent_format[:conditions].key?(:segment_ids), "Should include segment_ids"
|
||||
assert_equal "prefix", agent_format[:conditions][:match_type]
|
||||
assert_kind_of Array, agent_format[:conditions][:segment_ids]
|
||||
end
|
||||
|
||||
test "supports all four match types" do
|
||||
%w[exact prefix suffix contains].each do |match_type|
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: match_type,
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert rule.persisted?, "Should create rule with #{match_type} match type"
|
||||
assert_equal match_type, rule.path_match_type
|
||||
end
|
||||
end
|
||||
|
||||
test "supports all action types" do
|
||||
%w[allow deny challenge].each do |action|
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "exact",
|
||||
action: action,
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert rule.persisted?, "Should create rule with #{action} action"
|
||||
assert_equal action, rule.waf_action
|
||||
end
|
||||
end
|
||||
|
||||
test "supports redirect action with metadata" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "exact",
|
||||
action: "redirect",
|
||||
user: @user,
|
||||
metadata: { redirect_url: "https://example.com" }
|
||||
)
|
||||
|
||||
assert rule.persisted?, "Should create rule with redirect action"
|
||||
assert_equal "redirect", rule.waf_action
|
||||
end
|
||||
|
||||
test "stores metadata with human-readable segments" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin/users",
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal ["admin", "users"], rule.metadata["segments"]
|
||||
assert_equal "/admin/users", rule.metadata["pattern_display"]
|
||||
end
|
||||
|
||||
test "stores original pattern in conditions" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/Admin/Users", # Mixed case
|
||||
match_type: "exact",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert_equal "/Admin/Users", rule.conditions["original_pattern"]
|
||||
end
|
||||
end
|
||||
134
test/services/ipapi_test.rb
Normal file
134
test/services/ipapi_test.rb
Normal file
@@ -0,0 +1,134 @@
|
||||
require "test_helper"
|
||||
|
||||
class IpapiTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@ipapi_data = JSON.parse(
|
||||
File.read(Rails.root.join("test/fixtures/files/ipapi_91_84_96_0.json"))
|
||||
)
|
||||
end
|
||||
|
||||
test "parse_company_network_range extracts and converts IP range to CIDR" do
|
||||
cidr = Ipapi.parse_company_network_range(@ipapi_data)
|
||||
|
||||
assert_equal "91.84.96.0/19", cidr
|
||||
end
|
||||
|
||||
test "parse_company_network_range handles already formatted CIDR" do
|
||||
data = { "company" => { "network" => "1.2.3.0/24" } }
|
||||
cidr = Ipapi.parse_company_network_range(data)
|
||||
|
||||
assert_equal "1.2.3.0/24", cidr
|
||||
end
|
||||
|
||||
test "parse_company_network_range returns nil for invalid range" do
|
||||
data = { "company" => { "network" => "invalid" } }
|
||||
cidr = Ipapi.parse_company_network_range(data)
|
||||
|
||||
assert_nil cidr
|
||||
end
|
||||
|
||||
test "parse_company_network_range returns nil when no network data present" do
|
||||
data = { "company" => {} }
|
||||
cidr = Ipapi.parse_company_network_range(data)
|
||||
|
||||
assert_nil cidr
|
||||
end
|
||||
|
||||
test "parse_company_network_range falls back to datacenter.network" do
|
||||
data = { "datacenter" => { "network" => "1.2.3.0 - 1.2.3.255" } }
|
||||
cidr = Ipapi.parse_company_network_range(data)
|
||||
|
||||
assert_equal "1.2.3.0/24", cidr
|
||||
end
|
||||
|
||||
test "populate_network_attributes sets all network attributes" do
|
||||
network_range = NetworkRange.new(network: "91.84.96.0/24")
|
||||
Ipapi.populate_network_attributes(network_range, @ipapi_data)
|
||||
|
||||
assert_equal 216071, network_range.asn
|
||||
assert_equal "SERVERS TECH FZCO", network_range.asn_org
|
||||
assert_equal "SERVERS TECH FZCO", network_range.company
|
||||
assert_equal "NL", network_range.country
|
||||
assert network_range.is_datacenter
|
||||
refute network_range.is_vpn
|
||||
refute network_range.is_proxy
|
||||
end
|
||||
|
||||
test "process_ipapi_data creates both company and BGP route networks" do
|
||||
# Use a different tracking network so BGP route gets created
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "91.84.97.0/24",
|
||||
source: "auto_generated"
|
||||
)
|
||||
|
||||
assert_difference "NetworkRange.count", 2 do
|
||||
result = Ipapi.process_ipapi_data(@ipapi_data, tracking_network)
|
||||
|
||||
assert_equal 2, result[:networks].length
|
||||
assert_equal "91.84.96.0/19", result[:broadest_cidr]
|
||||
end
|
||||
|
||||
# Verify company network was created
|
||||
company_network = NetworkRange.find_by(network: "91.84.96.0/19")
|
||||
assert_not_nil company_network
|
||||
assert_equal "api_imported", company_network.source
|
||||
assert_equal "SERVERS TECH FZCO", company_network.company
|
||||
assert company_network.is_datacenter
|
||||
|
||||
# Verify BGP route network was created
|
||||
bgp_network = NetworkRange.find_by(network: "91.84.96.0/24")
|
||||
assert_not_nil bgp_network
|
||||
assert_equal "SERVERS TECH FZCO", bgp_network.company
|
||||
end
|
||||
|
||||
test "process_ipapi_data handles missing company network gracefully" do
|
||||
# Create data without company network range
|
||||
data = @ipapi_data.deep_dup
|
||||
data["company"].delete("network")
|
||||
data["datacenter"].delete("network")
|
||||
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "91.84.96.0/24",
|
||||
source: "auto_generated"
|
||||
)
|
||||
|
||||
# Should only create the BGP route network (which matches tracking, so 0 new)
|
||||
assert_no_difference "NetworkRange.count" do
|
||||
result = Ipapi.process_ipapi_data(data, tracking_network)
|
||||
|
||||
assert_equal 0, result[:networks].length
|
||||
assert_equal "91.84.96.0/24", result[:broadest_cidr]
|
||||
end
|
||||
end
|
||||
|
||||
test "process_ipapi_data updates existing networks instead of creating duplicates" do
|
||||
# Pre-create both networks
|
||||
company_network = NetworkRange.create!(
|
||||
network: "91.84.96.0/19",
|
||||
source: "manual",
|
||||
company: "Old Company"
|
||||
)
|
||||
|
||||
bgp_network = NetworkRange.create!(
|
||||
network: "91.84.96.0/24",
|
||||
source: "manual"
|
||||
)
|
||||
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "91.84.97.0/24",
|
||||
source: "auto_generated"
|
||||
)
|
||||
|
||||
# Should not create new networks, just update existing ones
|
||||
assert_no_difference "NetworkRange.count" do
|
||||
result = Ipapi.process_ipapi_data(@ipapi_data, tracking_network)
|
||||
|
||||
assert_equal 2, result[:networks].length
|
||||
end
|
||||
|
||||
# Verify updates
|
||||
company_network.reload
|
||||
assert_equal "SERVERS TECH FZCO", company_network.company
|
||||
assert company_network.network_data.key?("ipapi")
|
||||
end
|
||||
end
|
||||
216
test/services/path_rule_matcher_test.rb
Normal file
216
test/services/path_rule_matcher_test.rb
Normal file
@@ -0,0 +1,216 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class PathRuleMatcherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = User.create!(email_address: "test@example.com", password: "password123")
|
||||
|
||||
# Create path segments
|
||||
@admin_segment = PathSegment.find_or_create_segment("admin")
|
||||
@wp_login_segment = PathSegment.find_or_create_segment("wp-login.php")
|
||||
@api_segment = PathSegment.find_or_create_segment("api")
|
||||
@v1_segment = PathSegment.find_or_create_segment("v1")
|
||||
@users_segment = PathSegment.find_or_create_segment("users")
|
||||
@dashboard_segment = PathSegment.find_or_create_segment("dashboard")
|
||||
end
|
||||
|
||||
test "exact match - matches exact path only" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/wp-login.php",
|
||||
match_type: "exact",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Create matching event
|
||||
matching_event = create_event_with_segments([@wp_login_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, matching_event), "Should match exact path"
|
||||
|
||||
# Create non-matching event (extra segment)
|
||||
non_matching_event = create_event_with_segments([@admin_segment.id, @wp_login_segment.id])
|
||||
refute PathRuleMatcher.matches?(rule, non_matching_event), "Should not match path with extra segments"
|
||||
end
|
||||
|
||||
test "prefix match - matches paths starting with pattern" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "prefix",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Should match /admin
|
||||
event1 = create_event_with_segments([@admin_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event1), "Should match exact prefix"
|
||||
|
||||
# Should match /admin/dashboard
|
||||
event2 = create_event_with_segments([@admin_segment.id, @dashboard_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event2), "Should match prefix with additional segments"
|
||||
|
||||
# Should match /admin/users/123
|
||||
event3 = create_event_with_segments([@admin_segment.id, @users_segment.id, create_segment("123").id])
|
||||
assert PathRuleMatcher.matches?(rule, event3), "Should match prefix with multiple additional segments"
|
||||
|
||||
# Should NOT match /api/admin (admin not at start)
|
||||
event4 = create_event_with_segments([@api_segment.id, @admin_segment.id])
|
||||
refute PathRuleMatcher.matches?(rule, event4), "Should not match when pattern not at start"
|
||||
end
|
||||
|
||||
test "suffix match - matches paths ending with pattern" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/wp-login.php",
|
||||
match_type: "suffix",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Should match /wp-login.php
|
||||
event1 = create_event_with_segments([@wp_login_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event1), "Should match exact suffix"
|
||||
|
||||
# Should match /admin/wp-login.php
|
||||
event2 = create_event_with_segments([@admin_segment.id, @wp_login_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event2), "Should match suffix with prefix segments"
|
||||
|
||||
# Should match /backup/admin/wp-login.php
|
||||
backup_seg = create_segment("backup")
|
||||
event3 = create_event_with_segments([backup_seg.id, @admin_segment.id, @wp_login_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event3), "Should match suffix with multiple prefix segments"
|
||||
|
||||
# Should NOT match /wp-login.php/test (suffix has extra segment)
|
||||
test_seg = create_segment("test")
|
||||
event4 = create_event_with_segments([@wp_login_segment.id, test_seg.id])
|
||||
refute PathRuleMatcher.matches?(rule, event4), "Should not match when pattern not at end"
|
||||
end
|
||||
|
||||
test "contains match - matches paths containing pattern" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/admin",
|
||||
match_type: "contains",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Should match /admin
|
||||
event1 = create_event_with_segments([@admin_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event1), "Should match exact contains"
|
||||
|
||||
# Should match /api/admin/users
|
||||
event2 = create_event_with_segments([@api_segment.id, @admin_segment.id, @users_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event2), "Should match contains in middle"
|
||||
|
||||
# Should match /super/secret/admin/panel
|
||||
super_seg = create_segment("super")
|
||||
secret_seg = create_segment("secret")
|
||||
panel_seg = create_segment("panel")
|
||||
event3 = create_event_with_segments([super_seg.id, secret_seg.id, @admin_segment.id, panel_seg.id])
|
||||
assert PathRuleMatcher.matches?(rule, event3), "Should match contains with prefix and suffix"
|
||||
|
||||
# Should NOT match /administrator (different segment)
|
||||
administrator_seg = create_segment("administrator")
|
||||
event4 = create_event_with_segments([administrator_seg.id])
|
||||
refute PathRuleMatcher.matches?(rule, event4), "Should not match different segment"
|
||||
end
|
||||
|
||||
test "contains match with multi-segment pattern" do
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/api/admin",
|
||||
match_type: "contains",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Should match /api/admin
|
||||
event1 = create_event_with_segments([@api_segment.id, @admin_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event1), "Should match exact contains"
|
||||
|
||||
# Should match /v1/api/admin/users
|
||||
event2 = create_event_with_segments([@v1_segment.id, @api_segment.id, @admin_segment.id, @users_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event2), "Should match consecutive segments in middle"
|
||||
|
||||
# Should NOT match /api/v1/admin (segments not consecutive)
|
||||
event3 = create_event_with_segments([@api_segment.id, @v1_segment.id, @admin_segment.id])
|
||||
refute PathRuleMatcher.matches?(rule, event3), "Should not match non-consecutive segments"
|
||||
end
|
||||
|
||||
test "case insensitive matching through PathSegment normalization" do
|
||||
# PathSegment.find_or_create_segment normalizes to lowercase
|
||||
rule = Rule.create_path_pattern_rule(
|
||||
pattern: "/Admin/Users", # Mixed case
|
||||
match_type: "exact",
|
||||
action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
# Event with lowercase path should match
|
||||
event = create_event_with_segments([@admin_segment.id, @users_segment.id])
|
||||
assert PathRuleMatcher.matches?(rule, event), "Should match case-insensitively"
|
||||
end
|
||||
|
||||
test "matching_rules returns all matching rules" do
|
||||
rule1 = Rule.create_path_pattern_rule(pattern: "/admin", match_type: "prefix", action: "deny", user: @user)
|
||||
rule2 = Rule.create_path_pattern_rule(pattern: "/admin/users", match_type: "exact", action: "allow", user: @user)
|
||||
rule3 = Rule.create_path_pattern_rule(pattern: "/api", match_type: "prefix", action: "deny", user: @user)
|
||||
|
||||
event = create_event_with_segments([@admin_segment.id, @users_segment.id])
|
||||
|
||||
matching = PathRuleMatcher.matching_rules(event)
|
||||
assert_includes matching, rule1, "Should include prefix rule"
|
||||
assert_includes matching, rule2, "Should include exact rule"
|
||||
refute_includes matching, rule3, "Should not include non-matching rule"
|
||||
end
|
||||
|
||||
test "evaluate returns first matching action" do
|
||||
Rule.create_path_pattern_rule(pattern: "/admin", match_type: "prefix", action: "deny", user: @user)
|
||||
|
||||
event = create_event_with_segments([@admin_segment.id, @dashboard_segment.id])
|
||||
|
||||
action = PathRuleMatcher.evaluate(event)
|
||||
assert_equal "deny", action, "Should return deny action"
|
||||
end
|
||||
|
||||
test "evaluate returns allow for non-matching event" do
|
||||
Rule.create_path_pattern_rule(pattern: "/admin", match_type: "exact", action: "deny", user: @user)
|
||||
|
||||
event = create_event_with_segments([@api_segment.id])
|
||||
|
||||
action = PathRuleMatcher.evaluate(event)
|
||||
assert_equal "allow", action, "Should return allow for non-matching event"
|
||||
end
|
||||
|
||||
test "does not match disabled rules" do
|
||||
rule = Rule.create_path_pattern_rule(pattern: "/admin", match_type: "exact", action: "deny", user: @user)
|
||||
rule.update!(enabled: false)
|
||||
|
||||
event = create_event_with_segments([@admin_segment.id])
|
||||
|
||||
matching = PathRuleMatcher.matching_rules(event)
|
||||
assert_empty matching, "Should not match disabled rules"
|
||||
end
|
||||
|
||||
test "does not match expired rules" do
|
||||
rule = Rule.create_path_pattern_rule(pattern: "/admin", match_type: "exact", action: "deny", user: @user)
|
||||
rule.update!(expires_at: 1.hour.ago)
|
||||
|
||||
event = create_event_with_segments([@admin_segment.id])
|
||||
|
||||
matching = PathRuleMatcher.matching_rules(event)
|
||||
assert_empty matching, "Should not match expired rules"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_event_with_segments(segment_ids)
|
||||
Event.new(
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
request_segment_ids: segment_ids,
|
||||
ip_address: "1.2.3.4"
|
||||
)
|
||||
end
|
||||
|
||||
def create_segment(text)
|
||||
PathSegment.find_or_create_segment(text)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user