test: cover MiddlewareRateLimitingPStoreStoreTest and teardown

This commit is contained in:
dualfroz committed 2026-09-11 17:16:48 +02:00
1 parent 0d777aaaca
commit 3e4dc3987b
1 file changed
+138
@@ -0,0 +1,138 @@
require 'minitest/autorun'
require 'prism'
require 'tmpdir'
require 'fileutils'
require 'support'
# Session 85 wired Prism::RateLimiter::PStoreStore into examples/blog and
# proved durability with a real two-process demo (blog_rate_limit_
# persistence_demo.rb), but test_middleware_rate_limiting.rb only ever
# exercises the default MemoryStore - so Middleware::RateLimiting itself
# was never unit-tested against the durable store it actually ships with
# in the example app. This file closes that gap: same middleware, same
# rule DSL, but every counter/ban lives in a PStoreStore file on disk, and
# the durability claim itself is proven in-process by dropping the store
# object and opening a brand new one against the same path (the cheapest
# faithful stand-in for "the process restarted").
class MiddlewareRateLimitingPStoreStoreTest < Minitest::Test
include TestSupport
def setup
# Minitest runs every test method from every loaded file in one
# randomized order within a single process, so Prism::RateLimiter's
# module-level store must be put back exactly as found - otherwise a
# later test in test_middleware_rate_limiting.rb or
# test_rate_limiter.rb would inherit this file's PStoreStore pointed
# at a tmpdir this test already deleted, and blow up with ENOENT.
@original_store = Prism::RateLimiter.store
@dir = Dir.mktmpdir('prism-rate-limit-pstore-')
@path = File.join(@dir, 'rate_limits.pstore')
# Assign the new store BEFORE calling reset! - reset! resets whatever
# store is currently set, and it must not touch @original_store's file.
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
Prism::RateLimiter.reset!
@downstream_calls = 0
downstream = lambda do |_env|
@downstream_calls += 1
[200, {}, ['ok']]
end
@app = Prism::Middleware::RateLimiting.new(downstream)
end
def teardown
Prism::RateLimiter.reset!
Prism::RateLimiter.store = @original_store
FileUtils.remove_entry(@dir)
end
def request(ip: '1.1.1.1', path: '/login')
env = env_for('POST', path)
env['REMOTE_ADDR'] = ip
@app.call(env)
end
def test_requests_under_the_limit_pass_through
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
2.times do
status, = request
assert_equal 200, status
end
assert_equal 2, @downstream_calls
end
def test_request_over_the_limit_is_throttled_with_retry_after
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
2.times { request }
status, headers, body = request
assert_equal 429, status
assert_equal '60', headers['Retry-After']
assert_includes body.join, 'Too Many Requests'
assert_equal 2, @downstream_calls, 'the throttled request must not reach the app'
end
def test_blocklisted_request_is_rejected_with_403_before_reaching_app
Prism::RateLimiter.blocklist('scanners') { |req| req.path == '/etc/passwd' }
status, = request(path: '/etc/passwd')
assert_equal 403, status
assert_equal 0, @downstream_calls
end
def test_fail2ban_style_blocklist_bans_after_repeated_offenses
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
req.path.include?('/etc/passwd')
end
end
assert_equal 200, request(ip: '5.5.5.5', path: '/etc/passwd').first
assert_equal 403, request(ip: '5.5.5.5', path: '/etc/passwd').first
assert_equal 403, request(ip: '5.5.5.5', path: '/harmless').first, 'stays banned without a new offense'
end
def test_throttle_state_survives_a_simulated_process_restart
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
2.times { assert_equal 200, request.first }
# Simulate the process dying and a brand new one booting: a fresh
# PStoreStore instance against the same file, with no in-memory state
# inherited from the object above. The rule DSL (throttles/blocklists)
# is process-local config re-declared on boot, exactly like
# examples/blog/app.rb re-declares its throttles every start; only the
# counters/bans themselves need to survive on disk.
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
status, headers, = request
assert_equal 429, status, 'the window counter must have been read back from disk, not reset to zero'
assert_equal '60', headers['Retry-After']
end
def test_ban_state_survives_a_simulated_process_restart
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
req.path.include?('/etc/passwd')
end
end
# maxretry: 2 - the 1st offense only records a strike (still let
# through), the 2nd offense crosses the threshold and this same
# request is already rejected.
assert_equal 200, request(ip: '7.7.7.7', path: '/etc/passwd').first
assert_equal 403, request(ip: '7.7.7.7', path: '/etc/passwd').first
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
req.path.include?('/etc/passwd')
end
end
status, = request(ip: '7.7.7.7', path: '/harmless')
assert_equal 403, status, 'the ban must have been read back from disk, not lost with the old store object'
end
end