Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"

This commit is contained in:
Dan Milne
2025-11-04 00:11:10 +11:00
parent 0cbd462e7c
commit 5ff166613e
49 changed files with 4489 additions and 322 deletions

View 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
View 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
View File

@@ -0,0 +1,2 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# Empty fixtures - tests create their own data

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
action: MyString
two:
action: MyString
# Empty fixtures

View File

@@ -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

View File

@@ -1,7 +1 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
method: MyString
two:
method: MyString
# Empty fixtures

View File

@@ -1,7 +1 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
protocol: MyString
two:
protocol: MyString
# Empty fixtures

View File

@@ -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

View File

@@ -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

View 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

View 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
View 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

View 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

View 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

View File

@@ -1,7 +0,0 @@
require "test_helper"
class NetworkRangeTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -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

View 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