Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"
This commit is contained in:
195
test/controllers/api/rules_controller_test.rb
Normal file
195
test/controllers/api/rules_controller_test.rb
Normal file
@@ -0,0 +1,195 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module Api
|
||||
class RulesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@project = Project.create!(
|
||||
name: "Test Project",
|
||||
slug: "test-project",
|
||||
public_key: "test-key-#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@rule1 = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
source: "manual"
|
||||
)
|
||||
|
||||
@rule2 = Rule.create!(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100, window: 60 }
|
||||
)
|
||||
end
|
||||
|
||||
test "version endpoint returns correct structure" do
|
||||
get "/api/#{@project.public_key}/rules/version"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
assert json["version"].present?
|
||||
assert_equal 2, json["count"]
|
||||
assert json["sampling"].present?
|
||||
assert json["sampling"]["allowed_requests"].present?
|
||||
assert json["sampling"]["blocked_requests"].present?
|
||||
assert json["sampling"]["load_level"].present?
|
||||
end
|
||||
|
||||
test "version endpoint requires valid project key" do
|
||||
get "/api/invalid-key/rules/version"
|
||||
|
||||
assert_response :unauthorized
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal "Invalid project key", json["error"]
|
||||
end
|
||||
|
||||
test "version endpoint rejects disabled projects" do
|
||||
@project.update!(enabled: false)
|
||||
|
||||
get "/api/#{@project.public_key}/rules/version"
|
||||
|
||||
assert_response :forbidden
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal "Project is disabled", json["error"]
|
||||
end
|
||||
|
||||
test "index endpoint returns all active rules" do
|
||||
get "/api/#{@project.public_key}/rules"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
assert json["version"].present?
|
||||
assert json["sampling"].present?
|
||||
assert_equal 2, json["rules"].length
|
||||
|
||||
rule = json["rules"].find { |r| r["id"] == @rule1.id }
|
||||
assert_equal "network_v4", rule["rule_type"]
|
||||
assert_equal "deny", rule["action"]
|
||||
assert_equal({ "cidr" => "10.0.0.0/8" }, rule["conditions"])
|
||||
assert_equal 8, rule["priority"]
|
||||
end
|
||||
|
||||
test "index endpoint excludes disabled rules" do
|
||||
@rule1.update!(enabled: false)
|
||||
|
||||
get "/api/#{@project.public_key}/rules"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal 1, json["rules"].length
|
||||
assert_equal @rule2.id, json["rules"].first["id"]
|
||||
end
|
||||
|
||||
test "index endpoint excludes expired rules" do
|
||||
@rule1.update!(expires_at: 1.hour.ago)
|
||||
|
||||
get "/api/#{@project.public_key}/rules"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal 1, json["rules"].length
|
||||
assert_equal @rule2.id, json["rules"].first["id"]
|
||||
end
|
||||
|
||||
test "index endpoint with since parameter returns recent rules" do
|
||||
# Update rule1 to be older
|
||||
@rule1.update_column(:updated_at, 2.hours.ago)
|
||||
|
||||
since_time = 1.hour.ago.iso8601
|
||||
get "/api/#{@project.public_key}/rules?since=#{since_time}"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal 1, json["rules"].length
|
||||
assert_equal @rule2.id, json["rules"].first["id"]
|
||||
end
|
||||
|
||||
test "index endpoint with since parameter includes disabled rules" do
|
||||
@rule1.update!(enabled: false) # This updates updated_at
|
||||
|
||||
since_time = 1.minute.ago.iso8601
|
||||
get "/api/#{@project.public_key}/rules?since=#{since_time}"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
# Should include the disabled rule for agent to remove it
|
||||
disabled_rule = json["rules"].find { |r| r["id"] == @rule1.id }
|
||||
assert disabled_rule.present?
|
||||
assert_equal false, disabled_rule["enabled"]
|
||||
end
|
||||
|
||||
test "index endpoint with invalid timestamp returns error" do
|
||||
get "/api/#{@project.public_key}/rules?since=invalid-timestamp"
|
||||
|
||||
assert_response :bad_request
|
||||
json = JSON.parse(response.body)
|
||||
assert json["error"].include?("Invalid timestamp format")
|
||||
end
|
||||
|
||||
test "index endpoint requires authentication" do
|
||||
get "/api/invalid-key/rules"
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "index endpoint includes sampling information" do
|
||||
get "/api/#{@project.public_key}/rules"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
sampling = json["sampling"]
|
||||
|
||||
assert_equal 1.0, sampling["allowed_requests"]
|
||||
assert_equal 1.0, sampling["blocked_requests"]
|
||||
assert_equal 1.0, sampling["rate_limited_requests"]
|
||||
assert sampling["effective_until"].present?
|
||||
assert_equal "normal", sampling["load_level"]
|
||||
end
|
||||
|
||||
test "rules are ordered by updated_at for sync" do
|
||||
# Create rules with different timestamps
|
||||
oldest = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.1.0/24" }
|
||||
)
|
||||
oldest.update_column(:updated_at, 3.hours.ago)
|
||||
|
||||
middle = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.2.0/24" }
|
||||
)
|
||||
middle.update_column(:updated_at, 2.hours.ago)
|
||||
|
||||
newest = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.3.0/24" }
|
||||
)
|
||||
|
||||
get "/api/#{@project.public_key}/rules?since=#{4.hours.ago.iso8601}"
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
ids = json["rules"].map { |r| r["id"] }
|
||||
|
||||
# Should be ordered oldest to newest by updated_at
|
||||
assert_equal [oldest.id, middle.id], ids.first(2)
|
||||
assert_equal newest.id, ids.last
|
||||
end
|
||||
end
|
||||
end
|
||||
2
test/fixtures/ipv4_ranges.yml
vendored
Normal file
2
test/fixtures/ipv4_ranges.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
# Empty fixtures - tests create their own data
|
||||
2
test/fixtures/ipv6_ranges.yml
vendored
Normal file
2
test/fixtures/ipv6_ranges.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
# Empty fixtures - tests create their own data
|
||||
37
test/fixtures/network_ranges.yml
vendored
37
test/fixtures/network_ranges.yml
vendored
@@ -1,37 +0,0 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
ip_address:
|
||||
network_prefix: 1
|
||||
ip_version: 1
|
||||
company: MyString
|
||||
asn: 1
|
||||
asn_org: MyString
|
||||
is_datacenter: false
|
||||
is_proxy: false
|
||||
is_vpn: false
|
||||
ip_api_country: MyString
|
||||
geo2_country: MyString
|
||||
abuser_scores: MyText
|
||||
additional_data: MyText
|
||||
created_at: 2025-11-02 14:01:11
|
||||
updated_at: 2025-11-02 14:01:11
|
||||
last_api_fetch: 2025-11-02 14:01:11
|
||||
|
||||
two:
|
||||
ip_address:
|
||||
network_prefix: 1
|
||||
ip_version: 1
|
||||
company: MyString
|
||||
asn: 1
|
||||
asn_org: MyString
|
||||
is_datacenter: false
|
||||
is_proxy: false
|
||||
is_vpn: false
|
||||
ip_api_country: MyString
|
||||
geo2_country: MyString
|
||||
abuser_scores: MyText
|
||||
additional_data: MyText
|
||||
created_at: 2025-11-02 14:01:11
|
||||
updated_at: 2025-11-02 14:01:11
|
||||
last_api_fetch: 2025-11-02 14:01:11
|
||||
12
test/fixtures/path_segments.yml
vendored
12
test/fixtures/path_segments.yml
vendored
@@ -1,11 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
segment: MyString
|
||||
usage_count: 1
|
||||
first_seen_at: 2025-11-03 10:24:38
|
||||
|
||||
two:
|
||||
segment: MyString
|
||||
usage_count: 1
|
||||
first_seen_at: 2025-11-03 10:24:38
|
||||
# Empty fixtures
|
||||
|
||||
8
test/fixtures/request_actions.yml
vendored
8
test/fixtures/request_actions.yml
vendored
@@ -1,7 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
action: MyString
|
||||
|
||||
two:
|
||||
action: MyString
|
||||
# Empty fixtures
|
||||
|
||||
12
test/fixtures/request_hosts.yml
vendored
12
test/fixtures/request_hosts.yml
vendored
@@ -1,11 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
hostname: MyString
|
||||
usage_count: 1
|
||||
first_seen_at: 2025-11-03 10:24:29
|
||||
|
||||
two:
|
||||
hostname: MyString
|
||||
usage_count: 1
|
||||
first_seen_at: 2025-11-03 10:24:29
|
||||
# Empty fixtures
|
||||
|
||||
8
test/fixtures/request_methods.yml
vendored
8
test/fixtures/request_methods.yml
vendored
@@ -1,7 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
method: MyString
|
||||
|
||||
two:
|
||||
method: MyString
|
||||
# Empty fixtures
|
||||
|
||||
8
test/fixtures/request_protocols.yml
vendored
8
test/fixtures/request_protocols.yml
vendored
@@ -1,7 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
protocol: MyString
|
||||
|
||||
two:
|
||||
protocol: MyString
|
||||
# Empty fixtures
|
||||
|
||||
16
test/fixtures/rule_sets.yml
vendored
16
test/fixtures/rule_sets.yml
vendored
@@ -1,15 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
name: MyString
|
||||
description: MyText
|
||||
enabled: false
|
||||
projects:
|
||||
rules:
|
||||
|
||||
two:
|
||||
name: MyString
|
||||
description: MyText
|
||||
enabled: false
|
||||
projects:
|
||||
rules:
|
||||
# Empty fixtures
|
||||
|
||||
24
test/fixtures/rules.yml
vendored
24
test/fixtures/rules.yml
vendored
@@ -1,23 +1 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
rule_set: one
|
||||
rule_type: MyString
|
||||
target: MyString
|
||||
action: MyString
|
||||
enabled: false
|
||||
expires_at: 2025-11-02 19:10:14
|
||||
priority: 1
|
||||
conditions:
|
||||
metadata:
|
||||
|
||||
two:
|
||||
rule_set: two
|
||||
rule_type: MyString
|
||||
target: MyString
|
||||
action: MyString
|
||||
enabled: false
|
||||
expires_at: 2025-11-02 19:10:14
|
||||
priority: 1
|
||||
conditions:
|
||||
metadata:
|
||||
# Empty fixtures
|
||||
|
||||
138
test/jobs/expired_rules_cleanup_job_test.rb
Normal file
138
test/jobs/expired_rules_cleanup_job_test.rb
Normal file
@@ -0,0 +1,138 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
test "disables expired rules" do
|
||||
expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
active_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
expires_at: 1.hour.from_now,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
count = ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
assert_not expired_rule.reload.enabled?
|
||||
assert active_rule.reload.enabled?
|
||||
end
|
||||
|
||||
test "does not affect rules without expiration" do
|
||||
permanent_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
expires_at: nil,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert permanent_rule.reload.enabled?
|
||||
end
|
||||
|
||||
test "does not affect already disabled rules" do
|
||||
disabled_expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: false
|
||||
)
|
||||
|
||||
count = ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
assert_not disabled_expired_rule.reload.enabled?
|
||||
end
|
||||
|
||||
test "updates updated_at timestamp when disabling" do
|
||||
expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
original_updated_at = expired_rule.updated_at
|
||||
|
||||
sleep 0.01 # Ensure time passes
|
||||
|
||||
ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert expired_rule.reload.updated_at > original_updated_at
|
||||
end
|
||||
|
||||
test "deletes old disabled rules when running at 1am" do
|
||||
old_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: false
|
||||
)
|
||||
old_disabled_rule.update_column(:updated_at, 31.days.ago)
|
||||
|
||||
recent_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
enabled: false
|
||||
)
|
||||
|
||||
Time.stub :current, Time.current.change(hour: 1) do
|
||||
ExpiredRulesCleanupJob.perform_now
|
||||
end
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotFound) { old_disabled_rule.reload }
|
||||
assert_nothing_raised { recent_disabled_rule.reload }
|
||||
end
|
||||
|
||||
test "does not delete old rules when not running at 1am" do
|
||||
old_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: false
|
||||
)
|
||||
old_disabled_rule.update_column(:updated_at, 31.days.ago)
|
||||
|
||||
Time.stub :current, Time.current.change(hour: 10) do
|
||||
ExpiredRulesCleanupJob.perform_now
|
||||
end
|
||||
|
||||
assert_nothing_raised { old_disabled_rule.reload }
|
||||
end
|
||||
|
||||
test "returns count of disabled rules" do
|
||||
3.times do |i|
|
||||
Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.#{i}.0.0/16" },
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
end
|
||||
|
||||
count = ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert_equal 3, count
|
||||
end
|
||||
|
||||
test "returns zero when no expired rules" do
|
||||
count = ExpiredRulesCleanupJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
end
|
||||
end
|
||||
251
test/jobs/path_scanner_detector_job_test.rb
Normal file
251
test/jobs/path_scanner_detector_job_test.rb
Normal file
@@ -0,0 +1,251 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
@project = Project.first || Project.create!(
|
||||
name: "Test Project",
|
||||
slug: "test-project",
|
||||
public_key: SecureRandom.hex(16)
|
||||
)
|
||||
end
|
||||
|
||||
test "creates ban rule for IP hitting scanner paths" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
# Create events hitting scanner paths
|
||||
["/.env", "/.git", "/wp-admin"].each do |path|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: path,
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_not_nil rule
|
||||
assert_equal "network_v4", rule.rule_type
|
||||
assert_equal "deny", rule.action
|
||||
assert_equal "#{ip}/32", rule.cidr
|
||||
assert_equal 32, rule.priority
|
||||
assert rule.enabled?
|
||||
end
|
||||
|
||||
test "sets 24 hour expiration on ban rules" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
3.times do |i|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
PathScannerDetectorJob.perform_now
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_not_nil rule.expires_at
|
||||
|
||||
# Should expire in approximately 24 hours
|
||||
time_until_expiry = rule.expires_at - Time.current
|
||||
assert time_until_expiry > 23.hours
|
||||
assert time_until_expiry < 25.hours
|
||||
end
|
||||
|
||||
test "includes metadata about detected paths" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
paths = ["/.env", "/.git", "/wp-admin"]
|
||||
paths.each do |path|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: path,
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
PathScannerDetectorJob.perform_now
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_equal 3, rule.metadata["hit_count"]
|
||||
assert_equal paths.sort, rule.metadata["paths"].sort
|
||||
assert rule.metadata["reason"].include?("Scanner detected")
|
||||
assert rule.metadata["auto_generated"]
|
||||
end
|
||||
|
||||
test "does not create rule for insufficient hits" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
# Only 2 hits, minimum is 3
|
||||
2.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
end
|
||||
|
||||
test "only considers recent events" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
# Old event (outside lookback window)
|
||||
old_event = Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: 10.minutes.ago,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
|
||||
# Recent events
|
||||
2.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.git",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
# Should not find sufficient hits (only 2 recent, 1 old)
|
||||
assert_equal 0, count
|
||||
end
|
||||
|
||||
test "does not create duplicate rules for existing IP" do
|
||||
ip = "192.168.1.100"
|
||||
|
||||
# Create existing rule
|
||||
Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "#{ip}/32" },
|
||||
enabled: true
|
||||
)
|
||||
|
||||
# Create scanner events
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 0, count
|
||||
end
|
||||
|
||||
test "handles IPv6 addresses" do
|
||||
ip = "2001:db8::1"
|
||||
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 1, count
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_equal "network_v6", rule.rule_type
|
||||
assert_equal "#{ip}/32", rule.cidr
|
||||
end
|
||||
|
||||
test "creates separate rules for different IPs" do
|
||||
ip1 = "192.168.1.100"
|
||||
ip2 = "192.168.1.101"
|
||||
|
||||
[ip1, ip2].each do |ip|
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 2, count
|
||||
end
|
||||
|
||||
test "handles invalid IP addresses gracefully" do
|
||||
# Create event with invalid IP
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: "invalid-ip",
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
PathScannerDetectorJob.perform_now
|
||||
end
|
||||
end
|
||||
|
||||
test "returns count of created rules" do
|
||||
3.times do |i|
|
||||
ip = "192.168.1.#{100 + i}"
|
||||
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
waf_action: "allow"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
count = PathScannerDetectorJob.perform_now
|
||||
|
||||
assert_equal 3, count
|
||||
end
|
||||
end
|
||||
292
test/models/event_test.rb
Normal file
292
test/models/event_test.rb
Normal file
@@ -0,0 +1,292 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class EventTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@project = Project.create!(name: "Test Project", slug: "test-project")
|
||||
@sample_payload = {
|
||||
"event_id" => "test-event-123",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.1",
|
||||
"method" => "GET",
|
||||
"path" => "/api/test",
|
||||
"headers" => {
|
||||
"host" => "example.com",
|
||||
"user-agent" => "TestAgent/1.0",
|
||||
"content-type" => "application/json"
|
||||
},
|
||||
"query" => { "param" => "value" }
|
||||
},
|
||||
"response" => {
|
||||
"status_code" => 200,
|
||||
"duration_ms" => 150,
|
||||
"size" => 1024
|
||||
},
|
||||
"waf_action" => "allow",
|
||||
"server_name" => "test-server",
|
||||
"environment" => "test",
|
||||
"geo" => {
|
||||
"country_code" => "US",
|
||||
"city" => "Test City"
|
||||
},
|
||||
"tags" => { "source" => "test" },
|
||||
"agent" => {
|
||||
"name" => "baffle-agent",
|
||||
"version" => "1.0.0"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def teardown
|
||||
Event.delete_all # Delete events first to avoid foreign key constraints
|
||||
Project.delete_all
|
||||
end
|
||||
|
||||
test "create_from_waf_payload! creates event with proper enum values" do
|
||||
event = Event.create_from_waf_payload!("test-123", @sample_payload, @project)
|
||||
|
||||
assert event.persisted?
|
||||
assert_equal @project, event.project
|
||||
assert_equal "test-123", event.event_id
|
||||
assert_equal "192.168.1.1", event.ip_address
|
||||
assert_equal "/api/test", event.request_path
|
||||
assert_equal 200, event.response_status
|
||||
assert_equal 150, event.response_time_ms
|
||||
assert_equal "test-server", event.server_name
|
||||
assert_equal "test", event.environment
|
||||
assert_equal "US", event.country_code
|
||||
assert_equal "Test City", event.city
|
||||
assert_equal "baffle-agent", event.agent_name
|
||||
assert_equal "1.0.0", event.agent_version
|
||||
end
|
||||
|
||||
test "create_from_waf_payload! properly normalizes request_method enum" do
|
||||
test_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
||||
expected_enums = [:get, :post, :put, :patch, :delete, :head, :options]
|
||||
|
||||
test_methods.each_with_index do |method, index|
|
||||
payload = @sample_payload.dup
|
||||
payload["request"]["method"] = method
|
||||
payload["event_id"] = "test-method-#{method.downcase}"
|
||||
|
||||
event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload, @project)
|
||||
|
||||
assert_equal expected_enums[index].to_s, event.request_method,
|
||||
"Method #{method} should map to enum #{expected_enums[index]}"
|
||||
assert_equal index, event.request_method_before_type_cast,
|
||||
"Method #{method} should be stored as integer #{index}"
|
||||
end
|
||||
end
|
||||
|
||||
test "create_from_waf_payload! properly normalizes waf_action enum" do
|
||||
test_actions = [
|
||||
["allow", :allow, 0],
|
||||
["pass", :allow, 0],
|
||||
["deny", :deny, 1],
|
||||
["block", :deny, 1],
|
||||
["redirect", :redirect, 2],
|
||||
["challenge", :challenge, 3],
|
||||
["unknown", :allow, 0] # Default fallback
|
||||
]
|
||||
|
||||
test_actions.each do |action, expected_enum, expected_int|
|
||||
payload = @sample_payload.dup
|
||||
payload["waf_action"] = action
|
||||
payload["event_id"] = "test-action-#{action}"
|
||||
|
||||
event = Event.create_from_waf_payload!("test-action-#{action}", payload, @project)
|
||||
|
||||
assert_equal expected_enum.to_s, event.waf_action,
|
||||
"Action #{action} should map to enum #{expected_enum}"
|
||||
assert_equal expected_int, event.waf_action_before_type_cast,
|
||||
"Action #{action} should be stored as integer #{expected_int}"
|
||||
end
|
||||
end
|
||||
|
||||
test "create_from_waf_payload! handles header case normalization" do
|
||||
payload = @sample_payload.dup
|
||||
payload["request"]["headers"] = {
|
||||
"HOST" => "EXAMPLE.COM",
|
||||
"User-Agent" => "TestAgent/1.0",
|
||||
"CONTENT-TYPE" => "application/json"
|
||||
}
|
||||
|
||||
event = Event.create_from_waf_payload!("test-headers", payload, @project)
|
||||
|
||||
assert_equal "TestAgent/1.0", event.user_agent
|
||||
# The normalize_payload_headers method should normalize header keys to lowercase
|
||||
# but keep values as-is
|
||||
assert_equal "EXAMPLE.COM", event.headers["host"]
|
||||
assert_equal "application/json", event.headers["content-type"]
|
||||
end
|
||||
|
||||
test "enum values persist after save and reload" do
|
||||
event = Event.create_from_waf_payload!("test-persist", @sample_payload, @project)
|
||||
|
||||
# Verify initial values
|
||||
assert_equal "get", event.request_method
|
||||
assert_equal "allow", event.waf_action
|
||||
assert_equal 0, event.request_method_before_type_cast
|
||||
assert_equal 0, event.waf_action_before_type_cast
|
||||
|
||||
# Reload from database
|
||||
event.reload
|
||||
|
||||
# Values should still be correct
|
||||
assert_equal "get", event.request_method
|
||||
assert_equal "allow", event.waf_action
|
||||
assert_equal 0, event.request_method_before_type_cast
|
||||
assert_equal 0, event.waf_action_before_type_cast
|
||||
end
|
||||
|
||||
test "enum scopes work correctly" do
|
||||
# Create events with different methods and actions
|
||||
Event.create_from_waf_payload!("get-allow", @sample_payload, @project)
|
||||
|
||||
post_payload = @sample_payload.dup
|
||||
post_payload["request"]["method"] = "POST"
|
||||
post_payload["event_id"] = "post-allow"
|
||||
Event.create_from_waf_payload!("post-allow", post_payload, @project)
|
||||
|
||||
deny_payload = @sample_payload.dup
|
||||
deny_payload["waf_action"] = "deny"
|
||||
deny_payload["event_id"] = "get-deny"
|
||||
Event.create_from_waf_payload!("get-deny", deny_payload, @project)
|
||||
|
||||
# Test method scopes - use string values for enum queries
|
||||
get_events = Event.where(request_method: "get")
|
||||
post_events = Event.where(request_method: "post")
|
||||
|
||||
assert_equal 2, get_events.count
|
||||
assert_equal 1, post_events.count
|
||||
|
||||
# Test action scopes - use string values for enum queries
|
||||
allowed_events = Event.where(waf_action: "allow")
|
||||
denied_events = Event.where(waf_action: "deny")
|
||||
|
||||
assert_equal 2, allowed_events.count
|
||||
assert_equal 1, denied_events.count
|
||||
end
|
||||
|
||||
test "event normalization is triggered when needed" do
|
||||
# Create event without enum values (simulating old data)
|
||||
event = Event.create!(
|
||||
project: @project,
|
||||
event_id: "normalization-test",
|
||||
timestamp: Time.current,
|
||||
payload: @sample_payload,
|
||||
ip_address: "192.168.1.1",
|
||||
request_path: "/test",
|
||||
# Don't set request_method or waf_action to trigger normalization
|
||||
request_method: nil,
|
||||
waf_action: nil
|
||||
)
|
||||
|
||||
# Manually set the raw values that would normally be extracted
|
||||
event.instance_variable_set(:@raw_request_method, "POST")
|
||||
event.instance_variable_set(:@raw_action, "deny")
|
||||
|
||||
# Trigger normalization
|
||||
event.send(:normalize_event_fields)
|
||||
event.save!
|
||||
|
||||
# Verify normalization worked
|
||||
event.reload
|
||||
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
|
||||
end
|
||||
|
||||
test "payload extraction methods work correctly" do
|
||||
event = Event.create_from_waf_payload!("extraction-test", @sample_payload, @project)
|
||||
|
||||
# Test request_details
|
||||
request_details = event.request_details
|
||||
assert_equal "192.168.1.1", request_details[:ip]
|
||||
assert_equal "GET", request_details[:method]
|
||||
assert_equal "/api/test", request_details[:path]
|
||||
assert_equal "example.com", request_details[:headers]["host"]
|
||||
|
||||
# Test response_details
|
||||
response_details = event.response_details
|
||||
assert_equal 200, response_details[:status_code]
|
||||
assert_equal 150, response_details[:duration_ms]
|
||||
assert_equal 1024, response_details[:size]
|
||||
|
||||
# Test geo_details
|
||||
geo_details = event.geo_details
|
||||
assert_equal "US", geo_details["country_code"]
|
||||
assert_equal "Test City", geo_details["city"]
|
||||
|
||||
# Test tags
|
||||
tags = event.tags
|
||||
assert_equal "test", tags["source"]
|
||||
end
|
||||
|
||||
test "helper methods work correctly" do
|
||||
event = Event.create_from_waf_payload!("helper-test", @sample_payload, @project)
|
||||
|
||||
# Test boolean methods
|
||||
assert event.allowed?
|
||||
assert_not event.blocked?
|
||||
assert_not event.rate_limited?
|
||||
assert_not event.challenged?
|
||||
assert_not event.rule_matched?
|
||||
|
||||
# Test path methods
|
||||
assert_equal ["api", "test"], event.path_segments
|
||||
assert_equal 2, event.path_depth
|
||||
end
|
||||
|
||||
test "timestamp parsing works with various formats" do
|
||||
timestamps = [
|
||||
Time.now.iso8601,
|
||||
(Time.now.to_f * 1000).to_i, # Unix timestamp in milliseconds
|
||||
Time.now.utc # Time object
|
||||
]
|
||||
|
||||
timestamps.each_with_index do |timestamp, index|
|
||||
payload = @sample_payload.dup
|
||||
payload["timestamp"] = timestamp
|
||||
payload["event_id"] = "timestamp-test-#{index}"
|
||||
|
||||
event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload, @project)
|
||||
assert event.timestamp.is_a?(Time), "Timestamp #{index} should be parsed as Time"
|
||||
assert_not event.timestamp.nil?
|
||||
end
|
||||
end
|
||||
|
||||
test "handles missing optional fields gracefully" do
|
||||
minimal_payload = {
|
||||
"event_id" => "minimal-test",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "10.0.0.1",
|
||||
"method" => "GET",
|
||||
"path" => "/simple"
|
||||
},
|
||||
"response" => {
|
||||
"status_code" => 404
|
||||
}
|
||||
}
|
||||
|
||||
event = Event.create_from_waf_payload!("minimal-test", minimal_payload, @project)
|
||||
|
||||
assert event.persisted?
|
||||
assert_equal "10.0.0.1", event.ip_address
|
||||
assert_equal "get", event.request_method
|
||||
assert_equal "/simple", event.request_path
|
||||
assert_equal 404, event.response_status
|
||||
|
||||
# Optional fields should be nil
|
||||
assert_nil event.user_agent
|
||||
assert_nil event.response_time_ms
|
||||
assert_nil event.country_code
|
||||
assert_nil event.city
|
||||
assert_nil event.agent_name
|
||||
assert_nil event.agent_version
|
||||
end
|
||||
end
|
||||
122
test/models/ipv4_range_test.rb
Normal file
122
test/models/ipv4_range_test.rb
Normal file
@@ -0,0 +1,122 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Ipv4RangeTest < ActiveSupport::TestCase
|
||||
test "creates range from CIDR notation" do
|
||||
range = Ipv4Range.create!(cidr: "192.168.1.0/24")
|
||||
|
||||
assert_equal 24, range.network_prefix
|
||||
assert range.network_start.present?
|
||||
assert range.network_end.present?
|
||||
assert range.network_start < range.network_end
|
||||
end
|
||||
|
||||
test "calculates correct range for /32 single IP" do
|
||||
range = Ipv4Range.create!(cidr: "192.168.1.100/32")
|
||||
|
||||
assert_equal 32, range.network_prefix
|
||||
assert_equal range.network_start, range.network_end
|
||||
end
|
||||
|
||||
test "calculates correct range for /8 large network" do
|
||||
range = Ipv4Range.create!(cidr: "10.0.0.0/8")
|
||||
|
||||
assert_equal 8, range.network_prefix
|
||||
|
||||
# 10.0.0.0 to 10.255.255.255
|
||||
ip_start = IPAddr.new("10.0.0.0").to_i
|
||||
ip_end = IPAddr.new("10.255.255.255").to_i
|
||||
|
||||
assert_equal ip_start, range.network_start
|
||||
assert_equal ip_end, range.network_end
|
||||
end
|
||||
|
||||
test "validates network_prefix range" do
|
||||
range = Ipv4Range.new(cidr: "192.168.1.0/24")
|
||||
range.network_prefix = 33
|
||||
|
||||
assert_not range.valid?
|
||||
assert_includes range.errors[:network_prefix], "must be less than or equal to 32"
|
||||
end
|
||||
|
||||
test "contains_ip? returns true for IP in range" do
|
||||
range = Ipv4Range.create!(cidr: "192.168.1.0/24")
|
||||
|
||||
assert range.contains_ip?("192.168.1.1")
|
||||
assert range.contains_ip?("192.168.1.100")
|
||||
assert range.contains_ip?("192.168.1.255")
|
||||
end
|
||||
|
||||
test "contains_ip? returns false for IP outside range" do
|
||||
range = Ipv4Range.create!(cidr: "192.168.1.0/24")
|
||||
|
||||
assert_not range.contains_ip?("192.168.2.1")
|
||||
assert_not range.contains_ip?("10.0.0.1")
|
||||
end
|
||||
|
||||
test "contains_ip class method finds matching ranges" do
|
||||
range1 = Ipv4Range.create!(cidr: "10.0.0.0/8")
|
||||
range2 = Ipv4Range.create!(cidr: "192.168.1.0/24")
|
||||
|
||||
results = Ipv4Range.contains_ip("10.5.10.50")
|
||||
|
||||
assert_includes results, range1
|
||||
assert_not_includes results, range2
|
||||
end
|
||||
|
||||
test "contains_ip returns most specific range first" do
|
||||
broad_range = Ipv4Range.create!(cidr: "10.0.0.0/8")
|
||||
specific_range = Ipv4Range.create!(cidr: "10.0.1.0/24")
|
||||
|
||||
results = Ipv4Range.contains_ip("10.0.1.50")
|
||||
|
||||
assert_equal specific_range, results.first
|
||||
end
|
||||
|
||||
test "to_cidr returns CIDR notation" do
|
||||
range = Ipv4Range.create!(cidr: "192.168.1.0/24")
|
||||
|
||||
assert_equal "192.168.1.0/24", range.to_cidr
|
||||
end
|
||||
|
||||
test "datacenter scope returns datacenter IPs" do
|
||||
datacenter = Ipv4Range.create!(cidr: "1.2.3.0/24", is_datacenter: true)
|
||||
regular = Ipv4Range.create!(cidr: "192.168.1.0/24", is_datacenter: false)
|
||||
|
||||
results = Ipv4Range.datacenter
|
||||
|
||||
assert_includes results, datacenter
|
||||
assert_not_includes results, regular
|
||||
end
|
||||
|
||||
test "stores and retrieves JSON metadata" do
|
||||
range = Ipv4Range.create!(cidr: "1.2.3.0/24")
|
||||
range.abuser_scores_hash = { "spam" => 0.8, "malware" => 0.3 }
|
||||
range.save!
|
||||
|
||||
range.reload
|
||||
scores = range.abuser_scores_hash
|
||||
|
||||
assert_equal 0.8, scores["spam"]
|
||||
assert_equal 0.3, scores["malware"]
|
||||
end
|
||||
|
||||
test "stores IP intelligence metadata" do
|
||||
range = Ipv4Range.create!(
|
||||
cidr: "1.2.3.0/24",
|
||||
company: "Example Corp",
|
||||
asn: 12345,
|
||||
asn_org: "AS Example",
|
||||
is_datacenter: true,
|
||||
is_proxy: false,
|
||||
is_vpn: false,
|
||||
ip_api_country: "US"
|
||||
)
|
||||
|
||||
assert_equal "Example Corp", range.company
|
||||
assert_equal 12345, range.asn
|
||||
assert range.is_datacenter?
|
||||
assert_not range.is_proxy?
|
||||
end
|
||||
end
|
||||
107
test/models/ipv6_range_test.rb
Normal file
107
test/models/ipv6_range_test.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Ipv6RangeTest < ActiveSupport::TestCase
|
||||
test "creates range from CIDR notation" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
|
||||
assert_equal 32, range.network_prefix
|
||||
assert range.network_start.present?
|
||||
assert range.network_end.present?
|
||||
end
|
||||
|
||||
test "calculates correct range for /128 single IP" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::1/128")
|
||||
|
||||
assert_equal 128, range.network_prefix
|
||||
assert_equal range.network_start, range.network_end
|
||||
end
|
||||
|
||||
test "validates network_prefix range" do
|
||||
range = Ipv6Range.new(cidr: "2001:db8::/32")
|
||||
range.network_prefix = 129
|
||||
|
||||
assert_not range.valid?
|
||||
assert_includes range.errors[:network_prefix], "must be less than or equal to 128"
|
||||
end
|
||||
|
||||
test "contains_ip? returns true for IP in range" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
|
||||
assert range.contains_ip?("2001:db8::1")
|
||||
assert range.contains_ip?("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff")
|
||||
end
|
||||
|
||||
test "contains_ip? returns false for IP outside range" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
|
||||
assert_not range.contains_ip?("2001:db9::1")
|
||||
assert_not range.contains_ip?("fe80::1")
|
||||
end
|
||||
|
||||
test "contains_ip class method finds matching ranges" do
|
||||
range1 = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
range2 = Ipv6Range.create!(cidr: "fe80::/10")
|
||||
|
||||
results = Ipv6Range.contains_ip("2001:db8::1")
|
||||
|
||||
assert_includes results, range1
|
||||
assert_not_includes results, range2
|
||||
end
|
||||
|
||||
test "contains_ip returns most specific range first" do
|
||||
broad_range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
specific_range = Ipv6Range.create!(cidr: "2001:db8:1::/48")
|
||||
|
||||
results = Ipv6Range.contains_ip("2001:db8:1::5")
|
||||
|
||||
assert_equal specific_range, results.first
|
||||
end
|
||||
|
||||
test "to_cidr returns CIDR notation" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
|
||||
# IPv6 addresses can be formatted differently
|
||||
assert range.to_cidr.include?("2001:db8")
|
||||
assert range.to_cidr.include?("/32")
|
||||
end
|
||||
|
||||
test "datacenter scope returns datacenter IPs" do
|
||||
datacenter = Ipv6Range.create!(cidr: "2001:db8::/32", is_datacenter: true)
|
||||
regular = Ipv6Range.create!(cidr: "fe80::/10", is_datacenter: false)
|
||||
|
||||
results = Ipv6Range.datacenter
|
||||
|
||||
assert_includes results, datacenter
|
||||
assert_not_includes results, regular
|
||||
end
|
||||
|
||||
test "stores and retrieves JSON metadata" do
|
||||
range = Ipv6Range.create!(cidr: "2001:db8::/32")
|
||||
range.additional_data_hash = { "notes" => "Test network", "verified" => true }
|
||||
range.save!
|
||||
|
||||
range.reload
|
||||
data = range.additional_data_hash
|
||||
|
||||
assert_equal "Test network", data["notes"]
|
||||
assert_equal true, data["verified"]
|
||||
end
|
||||
|
||||
test "stores IP intelligence metadata" do
|
||||
range = Ipv6Range.create!(
|
||||
cidr: "2001:db8::/32",
|
||||
company: "IPv6 Corp",
|
||||
asn: 65000,
|
||||
asn_org: "AS IPv6",
|
||||
is_proxy: true,
|
||||
ip_api_country: "GB"
|
||||
)
|
||||
|
||||
assert_equal "IPv6 Corp", range.company
|
||||
assert_equal 65000, range.asn
|
||||
assert range.is_proxy?
|
||||
assert_equal "GB", range.ip_api_country
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class NetworkRangeTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -1,7 +1,179 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class RuleTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
# Validation tests
|
||||
test "should create valid network_v4 rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
source: "manual"
|
||||
)
|
||||
assert rule.valid?
|
||||
rule.save!
|
||||
assert_equal 8, rule.priority # Auto-calculated from CIDR prefix
|
||||
end
|
||||
|
||||
test "should create valid network_v6 rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v6",
|
||||
action: "deny",
|
||||
conditions: { cidr: "2001:db8::/32" },
|
||||
source: "manual"
|
||||
)
|
||||
assert rule.valid?
|
||||
rule.save!
|
||||
assert_equal 32, rule.priority
|
||||
end
|
||||
|
||||
test "should create valid rate_limit rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100, window: 60 },
|
||||
source: "manual"
|
||||
)
|
||||
assert rule.valid?
|
||||
end
|
||||
|
||||
test "should create valid path_pattern rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "path_pattern",
|
||||
action: "log",
|
||||
conditions: { patterns: ["/.env", "/.git"] },
|
||||
source: "default"
|
||||
)
|
||||
assert rule.valid?
|
||||
end
|
||||
|
||||
test "should require rule_type" do
|
||||
rule = Rule.new(action: "deny", conditions: { cidr: "10.0.0.0/8" })
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:rule_type], "can't be blank"
|
||||
end
|
||||
|
||||
test "should require action" do
|
||||
rule = Rule.new(rule_type: "network_v4", conditions: { cidr: "10.0.0.0/8" })
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:action], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate network_v4 has valid IPv4 CIDR" do
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "2001:db8::/32" } # IPv6 in IPv4 rule
|
||||
)
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:conditions], "cidr must be IPv4 for network_v4 rules"
|
||||
end
|
||||
|
||||
test "should validate rate_limit has limit and window in metadata" do
|
||||
rule = Rule.new(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100 } # Missing window
|
||||
)
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:metadata], "must include 'limit' and 'window' for rate_limit rules"
|
||||
end
|
||||
|
||||
# Default value tests
|
||||
test "should default enabled to true" do
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" }
|
||||
)
|
||||
assert rule.enabled?
|
||||
end
|
||||
|
||||
# Priority calculation tests
|
||||
test "should calculate priority from IPv4 CIDR prefix" do
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.1.0/24" }
|
||||
)
|
||||
assert_equal 24, rule.priority
|
||||
end
|
||||
|
||||
# Scope tests
|
||||
test "active scope returns enabled and non-expired rules" do
|
||||
active = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: true
|
||||
)
|
||||
|
||||
disabled = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
enabled: false
|
||||
)
|
||||
|
||||
expired = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "172.16.0.0/12" },
|
||||
enabled: true,
|
||||
expires_at: 1.hour.ago
|
||||
)
|
||||
|
||||
results = Rule.active.to_a
|
||||
assert_includes results, active
|
||||
assert_not_includes results, disabled
|
||||
assert_not_includes results, expired
|
||||
end
|
||||
|
||||
# Instance method tests
|
||||
test "active? returns true for enabled non-expired rule" do
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: true
|
||||
)
|
||||
assert rule.active?
|
||||
end
|
||||
|
||||
test "disable! sets enabled to false and adds metadata" do
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" }
|
||||
)
|
||||
|
||||
rule.disable!(reason: "False positive")
|
||||
|
||||
assert_not rule.enabled?
|
||||
assert_equal "False positive", rule.metadata["disabled_reason"]
|
||||
assert rule.metadata["disabled_at"].present?
|
||||
end
|
||||
|
||||
# Agent format tests
|
||||
test "to_agent_format returns correct structure" do
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
expires_at: 1.day.from_now,
|
||||
source: "manual",
|
||||
metadata: { reason: "Test" }
|
||||
)
|
||||
|
||||
format = rule.to_agent_format
|
||||
|
||||
assert_equal rule.id, format[:id]
|
||||
assert_equal "network_v4", format[:rule_type]
|
||||
assert_equal "deny", format[:action]
|
||||
assert_equal 8, format[:priority]
|
||||
assert_equal true, format[:enabled]
|
||||
end
|
||||
end
|
||||
|
||||
124
test/services/hub_load_test.rb
Normal file
124
test/services/hub_load_test.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class HubLoadTest < ActiveSupport::TestCase
|
||||
test "normal load level with low queue depth" do
|
||||
HubLoad.stub :queue_depth, 500 do
|
||||
assert_equal :normal, HubLoad.calculate_load_level
|
||||
end
|
||||
end
|
||||
|
||||
test "moderate load level with moderate queue depth" do
|
||||
HubLoad.stub :queue_depth, 3000 do
|
||||
assert_equal :moderate, HubLoad.calculate_load_level
|
||||
end
|
||||
end
|
||||
|
||||
test "high load level with high queue depth" do
|
||||
HubLoad.stub :queue_depth, 7500 do
|
||||
assert_equal :high, HubLoad.calculate_load_level
|
||||
end
|
||||
end
|
||||
|
||||
test "critical load level with very high queue depth" do
|
||||
HubLoad.stub :queue_depth, 15000 do
|
||||
assert_equal :critical, HubLoad.calculate_load_level
|
||||
end
|
||||
end
|
||||
|
||||
test "current_sampling returns correct rates for normal load" do
|
||||
HubLoad.stub :queue_depth, 500 do
|
||||
sampling = HubLoad.current_sampling
|
||||
|
||||
assert_equal 1.0, sampling[:allowed_requests]
|
||||
assert_equal 1.0, sampling[:blocked_requests]
|
||||
assert_equal 1.0, sampling[:rate_limited_requests]
|
||||
assert_equal :normal, sampling[:load_level]
|
||||
assert_equal 500, sampling[:queue_depth]
|
||||
assert sampling[:effective_until].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "current_sampling reduces allowed requests under moderate load" do
|
||||
HubLoad.stub :queue_depth, 3000 do
|
||||
sampling = HubLoad.current_sampling
|
||||
|
||||
assert_equal 0.5, sampling[:allowed_requests]
|
||||
assert_equal 1.0, sampling[:blocked_requests]
|
||||
assert_equal 1.0, sampling[:rate_limited_requests]
|
||||
assert_equal :moderate, sampling[:load_level]
|
||||
end
|
||||
end
|
||||
|
||||
test "current_sampling reduces allowed requests under high load" do
|
||||
HubLoad.stub :queue_depth, 7500 do
|
||||
sampling = HubLoad.current_sampling
|
||||
|
||||
assert_equal 0.2, sampling[:allowed_requests]
|
||||
assert_equal 1.0, sampling[:blocked_requests]
|
||||
assert_equal 1.0, sampling[:rate_limited_requests]
|
||||
assert_equal :high, sampling[:load_level]
|
||||
end
|
||||
end
|
||||
|
||||
test "current_sampling minimizes allowed requests under critical load" do
|
||||
HubLoad.stub :queue_depth, 15000 do
|
||||
sampling = HubLoad.current_sampling
|
||||
|
||||
assert_equal 0.05, sampling[:allowed_requests]
|
||||
assert_equal 1.0, sampling[:blocked_requests]
|
||||
assert_equal 1.0, sampling[:rate_limited_requests]
|
||||
assert_equal :critical, sampling[:load_level]
|
||||
end
|
||||
end
|
||||
|
||||
test "effective_until is approximately 10 seconds in future" do
|
||||
sampling = HubLoad.current_sampling
|
||||
effective_until = Time.parse(sampling[:effective_until])
|
||||
|
||||
time_diff = effective_until - Time.current
|
||||
assert time_diff > 9, "effective_until should be ~10 seconds in future"
|
||||
assert time_diff < 11, "effective_until should be ~10 seconds in future"
|
||||
end
|
||||
|
||||
test "overloaded? returns false for normal and moderate load" do
|
||||
HubLoad.stub :queue_depth, 500 do
|
||||
assert_not HubLoad.overloaded?
|
||||
end
|
||||
|
||||
HubLoad.stub :queue_depth, 3000 do
|
||||
assert_not HubLoad.overloaded?
|
||||
end
|
||||
end
|
||||
|
||||
test "overloaded? returns true for high and critical load" do
|
||||
HubLoad.stub :queue_depth, 7500 do
|
||||
assert HubLoad.overloaded?
|
||||
end
|
||||
|
||||
HubLoad.stub :queue_depth, 15000 do
|
||||
assert HubLoad.overloaded?
|
||||
end
|
||||
end
|
||||
|
||||
test "stats returns complete load information" do
|
||||
HubLoad.stub :queue_depth, 3000 do
|
||||
stats = HubLoad.stats
|
||||
|
||||
assert_equal 3000, stats[:queue_depth]
|
||||
assert_equal :moderate, stats[:load_level]
|
||||
assert_equal false, stats[:overloaded]
|
||||
assert_equal 0.5, stats[:sampling_rates][:allowed]
|
||||
assert_equal 1.0, stats[:sampling_rates][:blocked]
|
||||
end
|
||||
end
|
||||
|
||||
test "handles queue depth query errors gracefully" do
|
||||
# Simulate SolidQueue error
|
||||
SolidQueue::Job.stub :where, -> (*) { raise StandardError, "DB error" } do
|
||||
depth = HubLoad.queue_depth
|
||||
assert_equal 0, depth # Should return 0 on error
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user