# Runs ONE phase of the rate-limiter persistence lifecycle against # examples/blog, over a real HTTP request cycle. Always invoked as a brand # new `ruby` process by blog_rate_limit_persistence_demo.rb (never required # directly) - the whole point is that Prism::RateLimiter.store is now a # Prism::RateLimiter::PStoreStore (see examples/blog/app.rb), so a ban or a # throttle window tripped by one process must still be in effect for a # completely fresh process that never made any of the earlier requests # itself, exactly like a real deploy restarting the app mid-attack. # # Covers both throttles examples/blog declares against the same # PStoreStore file (logins/ip and signups/ip) - session 85 only proved # logins/ip durability; signups/ip shares the store but was never # exercised across a real restart of its own. $LOAD_PATH.unshift(File.expand_path('lib', __dir__)) require 'prism' require 'net/http' require_relative 'examples/blog/app' def cookie_from(response) lines = response.get_fields('set-cookie') || [] lines.map { |line| line.split(';').first }.join('; ') end def csrf_token_from(html) html[/name="csrf-token" content="([^"]+)"/, 1] end phase = ARGV[0] server = Prism::Server.new(Blog, port: 0).start port = server.port Net::HTTP.start('127.0.0.1', port) do |http| home = http.get('/') cookie = cookie_from(home) token = csrf_token_from(home.body) login_body = URI.encode_www_form( 'user[email]' => 'nobody@example.com', 'user[password]' => 'wrong password', 'authenticity_token' => token ) # Mismatched confirmation is a validation failure (422) that never # touches the uniqueness index, so the same body can be POSTed # repeatedly without tripping User's unique-email constraint instead of # the throttle we actually want to exercise. signup_body = URI.encode_www_form( 'user[email]' => 'nobody@example.com', 'user[password]' => 'secret123', 'user[password_confirmation]' => 'does not match', 'authenticity_token' => token ) case phase when 'trip' # logins/ip is throttle(limit: 5, period: 60) in examples/blog/app.rb - # attempts 1..5 are the credentials check itself (422, wrong password), # attempt 6 never reaches the controller at all. 5.times do |i| attempt = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) raise "trip attempt #{i + 1}: expected 422 (invalid credentials), got #{attempt.code}" unless attempt.code == '422' end sixth = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) raise "trip attempt 6: expected 429 (throttled), got #{sixth.code}" unless sixth.code == '429' puts 'trip: 5 attempts allowed through to the controller, the 6th throttled with 429 - process now exiting' when 'confirm_still_blocked' # Brand new process, brand new Ruby-level RateLimiter::Rule registry # (throttles are re-declared by requiring app.rb again) and a brand new # in-memory window if the store were MemoryStore - the ONLY way this # first request can already be 429, with zero attempts made by THIS # process, is if the PStoreStore file from the 'trip' phase is still # being read. immediate = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) unless immediate.code == '429' raise "expected the very first request from a fresh process to already be throttled (durable state), got #{immediate.code}" end raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil? puts 'confirm_still_blocked: a fresh process inherited the ban/window from the previous process via PStoreStore - ' \ "first request already 429, Retry-After=#{immediate['retry-after']}" when 'trip_signup' # signups/ip is throttle(limit: 3, period: 60) in examples/blog/app.rb - # attempts 1..3 are the validation check itself (422, password # mismatch), attempt 4 never reaches the controller at all. 3.times do |i| attempt = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) raise "trip_signup attempt #{i + 1}: expected 422 (mismatched confirmation), got #{attempt.code}" unless attempt.code == '422' end fourth = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) raise "trip_signup attempt 4: expected 429 (throttled), got #{fourth.code}" unless fourth.code == '429' puts 'trip_signup: 3 attempts allowed through to the controller, the 4th throttled with 429 - process now exiting' when 'confirm_signup_still_blocked' # Same durability claim as confirm_still_blocked above, for the # independent signups/ip throttle rule. immediate = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) unless immediate.code == '429' raise "expected the very first signup request from a fresh process to already be throttled (durable state), got #{immediate.code}" end raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil? puts 'confirm_signup_still_blocked: a fresh process inherited the signups/ip window from the previous process via ' \ "PStoreStore - first request already 429, Retry-After=#{immediate['retry-after']}" else raise "unknown phase #{phase.inspect} (expected trip, confirm_still_blocked, trip_signup or confirm_signup_still_blocked)" end end server.stop