diff --git a/blog_demo.rb b/blog_demo.rb index 2634506..7add3c5 100644 --- a/blog_demo.rb +++ b/blog_demo.rb @@ -1894,8 +1894,97 @@ Net::HTTP.start('127.0.0.1', port) do |http| 'user[email]' => 'writer@example.com', 'user[password]' => 'wrong password', 'authenticity_token' => token ) # Three POST /session slots are already spent before this loop starts: + # device A's own sign-in plus devices B and C from the multi-device + # remember-me scene above (each a real POST /session.json, all from the + # same IP). So the throttle (limit: 5) trips two attempts earlier here + # than it would against a completely fresh IP. Attempt 1 goes through + # .json instead of the usual form post - same slot either way (the + # discriminator strips the extension), but it doubles as the + # JSON-format proof that a rejected login is a 401 with a JSON:API + # error, not the HTML branch's 422. + 6.times do |i| + path = i.zero? ? '/session.json' : '/session' + attempt = http.post(path, login_attempt_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => cookie) + expected = if i.zero? then '401' + elsif i == 1 then '422' + else '429' + end + puts " attempt #{i + 1}: POST #{path} -> #{attempt.code} (expected #{expected})" + raise "login attempt #{i + 1}: expected #{expected}, got #{attempt.code}" unless attempt.code == expected + if i.zero? + error = JSON.parse(attempt.body)['errors'].first + raise 'expected the JSON login rejection to point at the password attribute' unless error['source']['pointer'] == '/data/attributes/password' + end + raise 'the blocked attempt should carry a Retry-After header' if i == 2 && attempt['retry-after'].nil? + end + + puts '-' * 40 + puts 'hammering POST /users to exercise the signup throttle (limit 3/60s per IP)' + signup_attempt_body = URI.encode_www_form( + 'user[email]' => 'writer@example.com', 'user[password]' => 'irrelevant', + 'user[password_confirmation]' => 'irrelevant', 'authenticity_token' => token + ) + # The demo user and the ownership-check user above each cost one of the + # 3 POST /users slots, so the throttle trips one attempt earlier here + # than it would against a completely fresh IP. Attempt 1 goes through + # .json to prove the duplicate-email rejection is a normal render_errors + # 422 (same shape a JSON client already gets from posts.json), not a + # special case just because it happens to come from a throttled action. + 2.times do |i| + path = i.zero? ? '/users.json' : '/users' + attempt = http.post(path, signup_attempt_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => cookie) + expected = i.zero? ? '422' : '429' + puts " attempt #{i + 1}: POST #{path} -> #{attempt.code} (expected #{expected})" + raise "signup attempt #{i + 1}: expected #{expected}, got #{attempt.code}" unless attempt.code == expected + if i.zero? + error = JSON.parse(attempt.body)['errors'].first + raise 'expected the JSON signup rejection to point at the email attribute' unless error['source']['pointer'] == '/data/attributes/email' + end + raise 'the blocked attempt should carry a Retry-After header' if i == 1 && attempt['retry-after'].nil? + end end +puts '-' * 40 +puts "checking that add_index :users, :email, unique: true (db/migrate/20260907000002_add_index_on_users_email.rb) is a REAL storage-level constraint, not just the app-level validates :email, uniqueness: true above" +existing_writer = User.find_by(email: 'writer@example.com') +raise 'expected the writer@example.com user created earlier in this demo to still exist' unless existing_writer +users_count_before_bypass = User.count +# Model#save always calls #valid? first, which is exactly what the app-level +# uniqueness validator above already covers - so the only way to prove the +# INDEX (not the validator) is what actually stops a duplicate is to skip +# the model layer entirely and hit Store#insert directly, the way a future +# admin script, a data migration, or a second process racing past the +# validator's own SELECT before either commits, all would. +begin + User.store.insert(User.table_name, 'email' => existing_writer[:email]) + raise 'expected the unique index on users.email to reject a duplicate row inserted directly through Store#insert, bypassing Model#valid? entirely' +rescue Prism::Orm::Store::UniqueConstraintViolation => e + puts " Store#insert with a duplicate encrypted email, bypassing Model validation entirely -> #{e.class}: #{e.message}" +end +raise 'the rejected bypass insert must not have left a phantom row behind' unless User.count == users_count_before_bypass + +puts '-' * 40 +puts 'checking examples/blog/log/development.log for real Instrumentation::LogSubscriber output' +new_log_lines = File.read(log_path)[log_offset_before_requests..].to_s +%w[sql.prism render_template.prism process_action.prism perform_job.prism deliver_mail.prism].each do |event_name| + raise "expected at least one #{event_name} line in #{log_path}" unless new_log_lines.include?(" #{event_name} (") +end +unless new_log_lines.include?('table="posts"') + raise "expected a sql.prism line touching the posts table in #{log_path}" +end +unless new_log_lines.include?('path="/"') + raise "expected a process_action.prism line for the homepage in #{log_path}" +end +unless new_log_lines.include?('job_class="Prism::Mailer::DeliveryJob"') + raise "expected a perform_job.prism line for the mailer delivery job in #{log_path}" +end +unless new_log_lines.include?('deliver_mail.prism') && new_log_lines.include?('delivery_method=:test') + raise "expected a deliver_mail.prism line for the notification email in #{log_path}" +end +puts " log grew by #{new_log_lines.lines.count} lines, sql/render/process_action/perform_job/deliver_mail all present" + server.stop puts '-' * 40 puts 'blog demo done (process about to exit; data stays on disk for the restart check)'