Files
prism/blog_demo.rb
T
2026-09-01 21:06:03 +02:00

1991 lines
133 KiB
Ruby

$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
require 'prism'
require 'net/http'
require 'zlib'
require 'stringio'
require 'csv'
require_relative 'examples/blog/app'
server = Prism::Server.new(Blog, port: 0).start
port = server.port
puts "blog demo server on port #{port}"
puts "database file: #{File.join(__dir__, 'examples/blog/db/development.pstore')}"
log_path = File.join(__dir__, 'examples/blog/log/development.log')
log_offset_before_requests = File.exist?(log_path) ? File.size(log_path) : 0
puts '-' * 40
def cookie_from(response)
lines = response.get_fields('set-cookie') || []
lines.map { |line| line.split(';').first }.join('; ')
end
def cookie_value_from(response, name)
lines = response.get_fields('set-cookie') || []
line = lines.find { |l| l.start_with?("#{name}=") }
line&.split(';')&.first
end
def csrf_token_from(html)
html[/name="csrf-token" content="([^"]+)"/, 1]
end
def multipart_body(boundary, fields:, files: {})
parts = fields.map do |name, value|
"--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
end
parts += files.map do |name, (filename, content_type, content)|
"--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
"Content-Type: #{content_type}\r\n\r\n#{content}\r\n"
end
parts.join + "--#{boundary}--\r\n"
end
# Signs in as the writer from a brand new, entirely independent cookie jar
# (its own session cookie, its own CSRF token fetched fresh from the
# sign-in page) - a second real HTTP client would look exactly like this,
# unlike reusing the caller's own `cookie`/`token`. Returns the
# remember_session_token cookie this "browser" got back, proving
# Prism::Authenticatable's remember_sessions: hands out one independent
# LoginSession row per device instead of one shared column.
def sign_in_new_device(http, label, user_agent:)
new_session_page = http.get('/session/new', 'User-Agent' => user_agent)
device_cookie = cookie_from(new_session_page)
device_token = csrf_token_from(new_session_page.body)
body = URI.encode_www_form(
'user[email]' => 'writer@example.com', 'user[password]' => 'correct horse battery staple',
'user[remember_me]' => '1', 'authenticity_token' => device_token
)
signed_in = http.post('/session.json', body, 'Content-Type' => 'application/x-www-form-urlencoded',
'Cookie' => device_cookie, 'User-Agent' => user_agent)
raise "expected #{label} to sign in, got #{signed_in.code}" unless signed_in.code == '200'
remember_cookie = cookie_value_from(signed_in, 'remember_session_token')
raise "expected #{label}'s sign-in to set its own remember_session_token cookie" unless remember_cookie
puts " #{label} signed in from its own independent cookie jar -> #{signed_in.code}, own remember-me cookie set"
remember_cookie
end
Net::HTTP.start('127.0.0.1', port) do |http|
home = http.get('/')
cookie = cookie_from(home)
token = csrf_token_from(home.body)
puts "GET / -> #{home.code} (session cookie set, csrf token captured)"
css_href = home.body[/<link rel="stylesheet" href="([^"]+)">/, 1]
js_src = home.body[/<script src="([^"]+)">/, 1]
raise 'expected the layout to reference a fingerprinted stylesheet' unless css_href =~ %r{\A/assets/stylesheets/application-[0-9a-f]{8}\.css\z}
raise 'expected the layout to reference a fingerprinted script' unless js_src =~ %r{\A/assets/javascripts/application-[0-9a-f]{8}\.js\z}
css_response = http.get(css_href)
js_response = http.get(js_src)
manifest = JSON.parse(File.read(File.join(__dir__, 'examples/blog/public/assets/manifest.json')))
unless css_response.code == '200' && css_response['cache-control'] == 'public, max-age=31536000, immutable'
raise "expected the fingerprinted stylesheet to be served with a far-future Cache-Control, got #{css_response.code} #{css_response['cache-control'].inspect}"
end
unless manifest['stylesheets/application.css'] == css_href.delete_prefix('/assets/')
raise 'expected the on-disk manifest.json to agree with the href the layout rendered'
end
puts "GET #{css_href} -> #{css_response.code}, Cache-Control=#{css_response['cache-control']}"
puts "GET #{js_src} -> #{js_response.code} (Prism::Assets::Compiler fingerprinted both files, manifest.json on disk matches)"
puts '-' * 40
puts 'Conditional GET (Prism::StaticCache, RFC 7232/7233) against the fingerprinted stylesheet, over the ' \
'real Prism::Server socket - Middleware::Assets hands out a STRONG ETag here (derived from the ' \
'fingerprint hash already in the filename), the only place in this app where If-Range after an ETag can succeed'
css_etag = css_response['etag']
raise "expected a strong ETag (no W/ prefix) on the fingerprinted stylesheet, got #{css_etag.inspect}" if css_etag.nil? || css_etag.start_with?('W/')
css_not_modified = http.get(css_href, 'If-None-Match' => css_etag)
puts "GET #{css_href} with If-None-Match: #{css_etag} -> #{css_not_modified.code}"
raise "expected 304 for a matching strong ETag, got #{css_not_modified.code}" unless css_not_modified.code == '304'
raise 'a 304 must not carry a body' unless css_not_modified.body.to_s.empty?
raise 'a 304 must not carry Content-Type (no entity, only validators)' if css_not_modified['content-type']
css_range_honored = http.get(css_href, 'If-Range' => css_etag, 'Range' => 'bytes=0-3')
puts "GET #{css_href} with If-Range: #{css_etag} (strong) + Range: bytes=0-3 -> #{css_range_honored.code}, " \
"Content-Range=#{css_range_honored['content-range']}"
unless css_range_honored.code == '206'
raise "expected 206: If-Range with a matching STRONG ETag must honor Range, got #{css_range_honored.code}"
end
raise 'expected a bytes 0-3/<total> Content-Range' unless css_range_honored['content-range'] =~ %r{\Abytes 0-3/\d+\z}
puts 'strong ETag -> 304 on a repeat GET and Range honored under If-Range, both through the real TCP socket'
anonymous_attempt = http.post(
'/posts', URI.encode_www_form('post[title]' => 'should be blocked', 'authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
puts "POST /posts while signed out -> #{anonymous_attempt.code} Location=#{anonymous_attempt['location']}"
unless anonymous_attempt.code == '302' && anonymous_attempt['location'].to_s.end_with?('/session/new')
raise "expected an anonymous post to be redirected to the sign-in page, got #{anonymous_attempt.code} " \
"Location=#{anonymous_attempt['location']}"
end
signup_body = URI.encode_www_form(
'user[email]' => 'writer@example.com', 'user[password]' => 'correct horse battery staple',
'user[password_confirmation]' => 'correct horse battery staple', 'authenticity_token' => token
)
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST /users (sign up) -> #{signup.code} Location=#{signup['location']}"
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
signed_in_cookie = cookie_from(signup)
cookie = signed_in_cookie unless signed_in_cookie.empty?
# User#email is `encrypts :email, deterministic: true` - prove that is
# real end to end, not just unit-tested: find_by(email:) still resolves
# the plaintext through the real request cycle's created row, but what
# actually landed on disk is ciphertext, not "writer@example.com".
writer_user = User.find_by(email: 'writer@example.com')
raise 'expected to find the newly signed-up user back by plaintext email (deterministic encryption)' unless writer_user
writer_raw_row = Prism::Orm.store.find(:users, writer_user.id)
if writer_raw_row['email'] == 'writer@example.com' || !writer_raw_row['email'].to_s.include?('--')
raise "expected users.email to be stored encrypted at rest, got #{writer_raw_row['email'].inspect}"
end
puts " users.email stored encrypted at rest (#{writer_raw_row['email'][0, 20]}...), decrypts back to #{writer_user.email}"
boundary = '----PrismBlogDemoBoundary'
body = multipart_body(
boundary,
fields: { 'post[title]' => 'Hello from Prism', 'post[body]' => 'This record was written through the ORM-lite file store.',
'authenticity_token' => token },
files: { 'post[cover]' => ['cover.txt', 'text/plain', 'not really an image, just bytes'] }
)
create = http.post(
'/posts',
body,
'Content-Type' => "multipart/form-data; boundary=#{boundary}", 'Cookie' => cookie
)
post_path = create['location']
puts "POST /posts (multipart, with cover + CSRF token) -> #{create.code} Location=#{post_path}"
raise "expected a redirect to the new post, got #{create.code}" unless create.code == '302'
# Every flash-touching response gets a fresh Set-Cookie (the whole
# session, flash included, lives signed in the cookie - see
# Prism::Middleware::Session#write_cookie), so it has to be captured
# here too, not just after sign-in/sign-up/sign-out - otherwise the
# edit/update scene below would keep reading the flash frozen into the
# cookie from right after sign-up instead of the post's own notices.
post_create_cookie = cookie_from(create)
cookie = post_create_cookie unless post_create_cookie.empty?
show = http.get(post_path, 'Cookie' => cookie)
puts "GET #{post_path} -> #{show.code}"
raise 'cover filename missing from show page' unless show.body.include?('/uploads/')
post_id = post_path.split('/').last
# posts#show is slug-keyed now (see app.rb's `param: :slug`), so post_id
# above is really the post's SLUG - fine for building URLs (that's all
# it's used for almost everywhere below), but Like.likeable_id is a real
# numeric FK (see LikesController#find_likeable!, which does
# klass.find(params['likeable_id'])), so the handful of places that POST
# to /likes or query the Like table directly need this actual row id
# instead, resolved once here since it never changes across the post's
# own trash/restore cycle later in this script.
post_row_id = Post.find_by!(slug: post_id).id
cover_path = show.body[%r{/uploads/([^"']+)}, 0]
cover_file = File.join(__dir__, 'examples/blog/public', cover_path)
raise "uploaded cover file missing on disk: #{cover_file}" unless File.exist?(cover_file)
puts '-' * 40
puts 'HTTP Range requests against the uploaded cover (Prism::HttpRange, RFC 7233), served by ' \
'Middleware::Static over the real Prism::Server socket - not just .call(env) in a unit test'
cover_size = File.size(cover_file)
full = http.get(cover_path, 'Cookie' => cookie)
puts "GET #{cover_path} (no Range) -> #{full.code}, Accept-Ranges=#{full['accept-ranges']}, " \
"Content-Length=#{full['content-length']}"
raise "expected 200 with the whole cover, got #{full.code}" unless full.code == '200'
raise 'expected Accept-Ranges: bytes on an ordinary 200' unless full['accept-ranges'] == 'bytes'
raise 'expected the full cover body on a plain GET' unless full.body.bytesize == cover_size
prefix = http.get(cover_path, 'Cookie' => cookie, 'Range' => 'bytes=0-2')
puts "GET #{cover_path} (Range: bytes=0-2) -> #{prefix.code}, Content-Range=#{prefix['content-range']}, " \
"body=#{prefix.body.inspect}"
raise "expected 206 for a satisfiable prefix range, got #{prefix.code}" unless prefix.code == '206'
raise "expected Content-Range bytes 0-2/#{cover_size}" unless prefix['content-range'] == "bytes 0-2/#{cover_size}"
raise "expected only the first 3 bytes on disk, got #{prefix.body.inspect}" unless prefix.body == 'not'
suffix = http.get(cover_path, 'Cookie' => cookie, 'Range' => 'bytes=-5')
puts "GET #{cover_path} (Range: bytes=-5) -> #{suffix.code}, Content-Range=#{suffix['content-range']}, " \
"body=#{suffix.body.inspect}"
raise "expected 206 for a satisfiable suffix range, got #{suffix.code}" unless suffix.code == '206'
raise "expected the last 5 bytes actually read off disk, got #{suffix.body.inspect}" unless suffix.body == 'bytes'
unsatisfiable = http.get(cover_path, 'Cookie' => cookie, 'Range' => "bytes=#{cover_size + 100}-#{cover_size + 200}")
puts "GET #{cover_path} (Range: bytes=#{cover_size + 100}-#{cover_size + 200}, beyond EOF) -> " \
"#{unsatisfiable.code}, Content-Range=#{unsatisfiable['content-range']}"
raise "expected 416 for a range starting past EOF, got #{unsatisfiable.code}" unless unsatisfiable.code == '416'
unless unsatisfiable['content-range'] == "bytes */#{cover_size}"
raise "expected Content-Range: bytes */#{cover_size} on 416, got #{unsatisfiable['content-range'].inspect}"
end
puts "cover (#{cover_size} bytes) served correctly whole, as a 3-byte prefix, a 5-byte suffix, and rejected " \
'with 416 for an out-of-range request - all through the real TCP socket, not a direct middleware call'
puts '-' * 40
puts 'Conditional GET against the SAME cover file (Middleware::Static hands out only a cheap WEAK ETag - ' \
'size+mtime, no fingerprint - unlike the CSS scene above), over the real socket'
cover_etag = full['etag']
raise "expected a weak ETag (W/ prefix) on a plain static file, got #{cover_etag.inspect}" unless cover_etag&.start_with?('W/')
cover_not_modified = http.get(cover_path, 'Cookie' => cookie, 'If-None-Match' => cover_etag)
puts "GET #{cover_path} with If-None-Match: #{cover_etag} -> #{cover_not_modified.code}"
raise "expected 304 for a matching weak ETag, got #{cover_not_modified.code}" unless cover_not_modified.code == '304'
unless [nil, '0'].include?(cover_not_modified['content-length'])
raise "expected an empty (or absent) Content-Length on a 304, got #{cover_not_modified['content-length'].inspect}"
end
cover_range_falls_back = http.get(cover_path, 'Cookie' => cookie, 'If-Range' => cover_etag, 'Range' => 'bytes=0-2')
puts "GET #{cover_path} with If-Range: #{cover_etag} (weak) + Range: bytes=0-2 -> #{cover_range_falls_back.code} " \
'(RFC 7232 SS2.3.2: If-Range never honors a weak validator, so this must fall back to the full body)'
unless cover_range_falls_back.code == '200'
raise "expected 200 (Range ignored): a weak ETag can never satisfy If-Range, got #{cover_range_falls_back.code}"
end
raise 'expected the FULL cover body once If-Range falls back' unless cover_range_falls_back.body.bytesize == cover_size
puts 'weak ETag -> 304 on a repeat GET, but If-Range on that same weak ETag correctly falls back to a full 200 ' \
'(the asymmetry Prism::StaticCache exists to get right)'
comment_body = URI.encode_www_form(
'comment[author]' => 'A reader', 'comment[body]' => 'Nice post!', 'authenticity_token' => token
)
comment = http.post(
"/posts/#{post_id}/comments", comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
puts "POST /posts/#{post_id}/comments -> #{comment.code} Location=#{comment['location']}"
show_with_comment = http.get(post_path, 'Cookie' => cookie)
raise 'comment did not show up on the post page' unless show_with_comment.body.include?('Nice post!')
puts "GET #{post_path} -> comment thread now includes the new comment"
comment_id = show_with_comment.body[/id="comment-(\d+)"/, 1]
raise 'could not find the new comment id on the post page' unless comment_id
comment_edit_path = "/posts/#{post_id}/comments/#{comment_id}"
puts '-' * 40
puts 'liking the post and one of its comments (polymorphic Like: belongs_to :likeable, polymorphic: true)'
like_post = http.post("/likes?likeable_type=Post&likeable_id=#{post_row_id}",
URI.encode_www_form('authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST /likes?likeable_type=Post&likeable_id=#{post_row_id} -> #{like_post.code} Location=#{like_post['location']}"
raise "expected liking the post to redirect, got #{like_post.code}" unless like_post.code == '302'
like_post_cookie = cookie_from(like_post)
cookie = like_post_cookie unless like_post_cookie.empty?
raise 'Post#likes (as: :likeable) should report exactly 1 like' unless Post.find_by!(slug: post_id).likes.count == 1
unless Post.find_by!(slug: post_id).likes_count == 1
raise 'belongs_to :likeable, counter_cache: true should have bumped Post#likes_count to 1 on Like#create'
end
show_with_like = http.get(post_path, 'Cookie' => cookie)
unless show_with_like.body.include?('1 like') || show_with_like.body.include?('Polubienia: 1')
raise 'post page should render the updated like count'
end
puts "GET #{post_path} -> like count rendered (ApplicationController#likes_count now reads the " \
"counter_cache column, not record.likes.count), Unlike button now shown"
like_comment = http.post("/likes?likeable_type=Comment&likeable_id=#{comment_id}",
URI.encode_www_form('authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected liking the comment to redirect, got #{like_comment.code}" unless like_comment.code == '302'
like_comment_cookie = cookie_from(like_comment)
cookie = like_comment_cookie unless like_comment_cookie.empty?
raise 'Comment#likes (as: :likeable) should report exactly 1 like' unless Comment.find(comment_id).likes.count == 1
unless Comment.find(comment_id).likes_count == 1
raise 'polymorphic counter_cache should also bump Comment#likes_count, same Like model/column default'
end
puts "POST /likes?likeable_type=Comment&likeable_id=#{comment_id} -> #{like_comment.code} (comment liked too, " \
"same Like model and controller action as the post)"
duplicate_like = http.post("/likes?likeable_type=Post&likeable_id=#{post_row_id}",
URI.encode_www_form('authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless duplicate_like.code == '302'
raise "expected a same-user duplicate like to still just redirect quietly, got #{duplicate_like.code}"
end
raise 'validates_uniqueness_of scope: should have silently rejected the duplicate like' unless Post.find_by!(slug: post_id).likes.count == 1
unless Post.find_by!(slug: post_id).likes_count == 1
raise 'a rejected duplicate like (failed validation, no Like row created) must not bump likes_count'
end
puts "POST /likes?likeable_type=Post&likeable_id=#{post_row_id} again -> #{duplicate_like.code} " \
"(uniqueness scope: [:likeable_id, :likeable_type] rejected the duplicate, still 1 like)"
own_like_id = Post.find_by!(slug: post_id).likes.first.id
unlike = http.post("/likes/#{own_like_id}", URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected unliking the post to redirect, got #{unlike.code}" unless unlike.code == '302'
unlike_cookie = cookie_from(unlike)
cookie = unlike_cookie unless unlike_cookie.empty?
raise 'Post#likes should be back to zero after unliking' unless Post.find_by!(slug: post_id).likes.count.zero?
unless Post.find_by!(slug: post_id).likes_count.zero?
raise 'unliking (really_destroy! per LikesController#destroy) should have decremented likes_count back to 0'
end
puts "POST /likes/#{own_like_id} with _method=DELETE -> #{unlike.code} (unliked, count back to 0)"
relike = http.post("/likes?likeable_type=Post&likeable_id=#{post_row_id}",
URI.encode_www_form('authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected re-liking the post to redirect, got #{relike.code}" unless relike.code == '302'
relike_cookie = cookie_from(relike)
cookie = relike_cookie unless relike_cookie.empty?
puts "POST /likes?likeable_type=Post&likeable_id=#{post_row_id} -> #{relike.code} " \
"(re-liked so the dependent :destroy cascade below has something to prove)"
comment_edit_page = http.get("#{comment_edit_path}/edit", 'Cookie' => cookie)
puts "GET #{comment_edit_path}/edit -> #{comment_edit_page.code}"
raise 'comment edit form should be pre-filled with the current body' unless comment_edit_page.body.include?('Nice post!')
comment_update_body = URI.encode_www_form('comment[body]' => 'Nice post, edited!', '_method' => 'PATCH',
'authenticity_token' => token)
comment_update = http.post(comment_edit_path, comment_update_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{comment_edit_path} with _method=PATCH -> #{comment_update.code} Location=#{comment_update['location']}"
raise "expected the comment update to redirect back to the post, got #{comment_update.code}" unless comment_update.code == '302'
comment_update_cookie = cookie_from(comment_update)
cookie = comment_update_cookie unless comment_update_cookie.empty?
show_with_edited_comment = http.get(post_path, 'Cookie' => cookie)
raise 'edited comment body did not appear on the show page' unless show_with_edited_comment.body.include?('Nice post, edited!')
unless show_with_edited_comment.body.include?('changed: body') || show_with_edited_comment.body.include?('zmienione: body')
raise 'Comment#saved_changes-backed notice should name body as the changed field'
end
puts "GET #{post_path} -> comment body updated, dirty-tracking notice named the changed field"
comment_noop_body = URI.encode_www_form('comment[body]' => 'Nice post, edited!', '_method' => 'PATCH',
'authenticity_token' => token)
comment_noop = http.post(comment_edit_path, comment_noop_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the no-op comment update to redirect too, got #{comment_noop.code}" unless comment_noop.code == '302'
comment_noop_cookie = cookie_from(comment_noop)
cookie = comment_noop_cookie unless comment_noop_cookie.empty?
comment_noop_show = http.get(post_path, 'Cookie' => cookie)
unless comment_noop_show.body.include?('Nothing changed') || comment_noop_show.body.include?('Nic sie nie zmienilo')
raise 'submitting an identical comment body should report that zero fields actually changed'
end
puts "POST #{comment_edit_path} with identical body -> Comment#saved_changes was empty, notice said nothing changed"
# Prism::Orm::Locking on Comment: same "two browser tabs" scenario as the
# Post edit above, proving the mechanism (auto-detected via the
# add_lock_version_to_comments migration, zero model code) works
# identically for a second model, not just the one it was built against.
comment_tab_a_edit = http.get("#{comment_edit_path}/edit", 'Cookie' => cookie)
comment_tab_b_edit = http.get("#{comment_edit_path}/edit", 'Cookie' => cookie)
comment_tab_a_lock_version = comment_tab_a_edit.body[/name="comment\[lock_version\]"[^>]*value="([^"]*)"/, 1]
comment_tab_b_lock_version = comment_tab_b_edit.body[/name="comment\[lock_version\]"[^>]*value="([^"]*)"/, 1]
raise 'comment edit form should carry the current lock_version in a hidden field' if comment_tab_a_lock_version.to_s.empty?
unless comment_tab_a_lock_version == comment_tab_b_lock_version
raise 'both comment tabs should have observed the same lock_version before either saved'
end
comment_tab_a_save_body = URI.encode_www_form(
'comment[body]' => 'Nice post, tab A won!', 'comment[lock_version]' => comment_tab_a_lock_version,
'_method' => 'PATCH', 'authenticity_token' => token
)
comment_tab_a_save = http.post(comment_edit_path, comment_tab_a_save_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected comment tab A's save to succeed, got #{comment_tab_a_save.code}" unless comment_tab_a_save.code == '302'
comment_tab_a_cookie = cookie_from(comment_tab_a_save)
cookie = comment_tab_a_cookie unless comment_tab_a_cookie.empty?
puts "POST #{comment_edit_path} tab A (lock_version=#{comment_tab_a_lock_version}) -> #{comment_tab_a_save.code}, saved first"
comment_tab_b_save_body = URI.encode_www_form(
'comment[body]' => 'Nice post, tab B lost!', 'comment[lock_version]' => comment_tab_b_lock_version,
'_method' => 'PATCH', 'authenticity_token' => token
)
comment_tab_b_save = http.post(comment_edit_path, comment_tab_b_save_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless comment_tab_b_save.code == '409'
raise "expected comment tab B's stale save to be rejected with 409, got #{comment_tab_b_save.code}"
end
unless comment_tab_b_save.body.include?('Someone else changed this comment') ||
comment_tab_b_save.body.include?('Ktos inny zmienil ten komentarz')
raise 'the 409 response should render the comment stale-conflict notice'
end
puts "POST #{comment_edit_path} tab B (stale lock_version=#{comment_tab_b_lock_version}) -> " \
"#{comment_tab_b_save.code} (expected 409, StaleObjectError caught)"
after_comment_conflict = http.get(post_path, 'Cookie' => cookie)
unless after_comment_conflict.body.include?('Nice post, tab A won!')
raise "tab B's rejected comment write must not have overwritten tab A's body"
end
raise "tab B's rejected comment write must not have leaked through" if after_comment_conflict.body.include?('tab B lost')
puts "GET #{post_path} -> tab A's comment body survives untouched, Prism::Orm::Locking prevented the lost update"
# Comment's enum :status defaults every new comment to "pending" (column
# default 0), so this comment - created way back before this feature
# existed in the script - is STILL pending right now. The moderation
# visibility scene further below (reader2, then an anonymous visitor)
# depends on it already being approved by the time they look, same as
# any real moderator would approve a comment before strangers see it.
raise "the pending badge should show for the comment's own author before it's approved" unless after_comment_conflict.body.include?('Pending approval')
approve_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
approve_comment = http.post("#{comment_edit_path}/approve", approve_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the comment approval to redirect, got #{approve_comment.code}" unless approve_comment.code == '302'
approve_comment_cookie = cookie_from(approve_comment)
cookie = approve_comment_cookie unless approve_comment_cookie.empty?
approved_show = http.get(post_path, 'Cookie' => cookie)
raise 'an approved comment should no longer show the pending badge' if approved_show.body.include?('Pending approval')
puts "POST #{comment_edit_path}/approve -> #{approve_comment.code} (Comment#status: pending -> approved, " \
'Comment enum :status + CommentPolicy#approve? checked the POST owner, not the comment author)'
fresh_show = http.get(post_path, 'Cookie' => cookie)
etag = fresh_show['etag']
raise 'posts#show should set an ETag now that it uses fresh_when' unless etag
puts "GET #{post_path} -> #{fresh_show.code}, ETag=#{etag}"
conditional_show = http.get(post_path, 'Cookie' => cookie, 'If-None-Match' => etag)
puts "GET #{post_path} with If-None-Match: #{etag} -> #{conditional_show.code}"
raise "expected a fresh conditional GET to return 304, got #{conditional_show.code}" unless conditional_show.code == '304'
raise '304 response must not carry a body' unless conditional_show.body.to_s.empty?
second_comment_body = URI.encode_www_form(
'comment[author]' => 'Another reader', 'comment[body]' => 'Me too!', 'authenticity_token' => token
)
second_comment = http.post("/posts/#{post_id}/comments", second_comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the second comment to redirect, got #{second_comment.code}" unless second_comment.code == '302'
second_comment_cookie = cookie_from(second_comment)
cookie = second_comment_cookie unless second_comment_cookie.empty?
now_stale = http.get(post_path, 'Cookie' => cookie, 'If-None-Match' => etag)
puts "GET #{post_path} with the OLD If-None-Match after a new comment -> #{now_stale.code}"
unless now_stale.code == '200' && now_stale.body.include?('Me too!')
raise "expected the stale ETag to be rejected and the fresh page (with the new comment) rendered, got #{now_stale.code}"
end
raise 'the fresh render must carry a NEW ETag, not the stale one' if now_stale['etag'] == etag
puts "GET #{post_path} -> #{now_stale.code} (ETag changed after a comment was added, Prism::ConditionalGet is content-aware)"
json_index = http.get('/posts.json')
puts "GET /posts.json -> #{json_index.code} #{json_index['content-type']}"
parsed = JSON.parse(json_index.body)
raise 'json index did not include the new post' unless parsed.any? { |row| row['title'] == 'Hello from Prism' }
puts '-' * 40
puts 'CORS: Prism::Middleware::Cors + Prism::Cors, matched against the .json API surface only - a ' \
'public read-only rule for any origin, and a credentialed read/write rule for one trusted admin origin'
public_client_origin = 'https://blog-widget.example.net'
cors_public_get = http.get('/posts.json', 'Origin' => public_client_origin)
unless cors_public_get.code == '200' && cors_public_get['access-control-allow-origin'] == public_client_origin
raise "expected a public GET /posts.json to echo Access-Control-Allow-Origin: #{public_client_origin}, " \
"got #{cors_public_get['access-control-allow-origin'].inspect}"
end
if cors_public_get['access-control-allow-credentials']
raise 'a public (non-credentialed) CORS resource must not set Access-Control-Allow-Credentials'
end
puts "GET /posts.json with Origin: #{public_client_origin} -> #{cors_public_get.code}, " \
"Access-Control-Allow-Origin=#{cors_public_get['access-control-allow-origin']} (any origin can read)"
cors_public_write_preflight = http.options('/posts', 'Origin' => public_client_origin,
'Access-Control-Request-Method' => 'POST')
unless cors_public_write_preflight.code == '403'
raise "expected the public origin's write preflight to be rejected, got #{cors_public_write_preflight.code}"
end
puts "OPTIONS /posts (preflight for POST) with Origin: #{public_client_origin} -> " \
"#{cors_public_write_preflight.code} (the public rule only allows GET, POST is refused before it " \
'ever reaches the router)'
admin_origin = 'https://admin.blog-demo.internal'
cors_admin_preflight = http.options('/posts', 'Origin' => admin_origin,
'Access-Control-Request-Method' => 'POST',
'Access-Control-Request-Headers' => 'Content-Type, X-CSRF-Token')
unless cors_admin_preflight.code == '204' && cors_admin_preflight['access-control-allow-credentials'] == 'true'
raise "expected the trusted admin origin's preflight to succeed with credentials allowed, got " \
"#{cors_admin_preflight.code}"
end
unless cors_admin_preflight['access-control-allow-methods'].to_s.include?('POST')
raise 'expected the preflight response to advertise POST among the allowed methods'
end
puts "OPTIONS /posts (preflight for POST) with Origin: #{admin_origin} -> #{cors_admin_preflight.code}, " \
"Allow-Methods=#{cors_admin_preflight['access-control-allow-methods']}, Allow-Credentials=true " \
'(a second, more privileged CORS rule for the trusted admin dashboard origin)'
cors_unrelated_path = http.get('/', 'Origin' => public_client_origin)
if cors_unrelated_path['access-control-allow-origin']
raise 'a path with no configured CORS resource must not carry any Access-Control-* headers, even with ' \
'an Origin header present'
end
puts "GET / with Origin: #{public_client_origin} -> #{cors_unrelated_path.code}, no Access-Control-* " \
'headers (the homepage is not a registered CORS resource, so the middleware passes it straight through)'
puts '-' * 40
puts 'JSON:API-lite validation/conflict errors (Prism::Orm::Errors#to_jsonapi + ' \
'Renderable#render_errors) - PostsController#create/#update now respond_to format.json ' \
'on both the happy and the failure path, not just posts#index'
json_create_body = URI.encode_www_form(
'post[title]' => 'JSON API post', 'post[body]' => 'Created via the .json format.',
'authenticity_token' => token
)
json_create = http.post('/posts.json', json_create_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected creating a post via .json to return 201, got #{json_create.code}" unless json_create.code == '201'
created_via_json = JSON.parse(json_create.body)
json_post_id = created_via_json['id']
# posts#create/#index/#update .json bodies now carry `slug` alongside
# `id` (see PostsController) - a JSON client has no other way to build
# a valid /posts/:slug URL for a post it just created, now that show/
# update/destroy are slug-keyed (see app.rb's `param: :slug`).
json_post_slug = created_via_json['slug']
raise 'json create response should echo the saved title' unless created_via_json['title'] == 'JSON API post'
raise 'json create response should carry the generated slug' if json_post_slug.to_s.empty?
puts "POST /posts.json -> #{json_create.code}, created post #{json_post_id} as a JSON payload (no redirect)"
json_create_cookie = cookie_from(json_create)
cookie = json_create_cookie unless json_create_cookie.empty?
invalid_json_create_body = URI.encode_www_form(
'post[title]' => '', 'post[body]' => '', 'authenticity_token' => token
)
invalid_json_create = http.post('/posts.json', invalid_json_create_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless invalid_json_create.code == '422'
raise "expected a blank title via .json to fail with 422, got #{invalid_json_create.code}"
end
invalid_create_errors = JSON.parse(invalid_json_create.body)['errors']
unless invalid_create_errors.is_a?(Array) && invalid_create_errors.any? { |e| e['source']['pointer'] == '/data/attributes/title' }
raise "expected a JSON:API error object pointing at /data/attributes/title, got #{invalid_json_create.body}"
end
raise 'every JSON:API error object should carry the response status as a string' unless invalid_create_errors.all? { |e| e['status'] == '422' }
puts "POST /posts.json with a blank title -> #{invalid_json_create.code}, " \
"errors[0].source.pointer=#{invalid_create_errors.first['source']['pointer']}"
json_update_body = URI.encode_www_form(
'post[title]' => 'JSON API post (edited)', 'post[body]' => 'Updated via the .json format.',
'post[lock_version]' => '0', '_method' => 'PATCH', 'authenticity_token' => token
)
json_update = http.post("/posts/#{json_post_slug}.json", json_update_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected updating a post via .json to return 200, got #{json_update.code}" unless json_update.code == '200'
updated_via_json = JSON.parse(json_update.body)
raise 'json update response should reflect the new title' unless updated_via_json['title'] == 'JSON API post (edited)'
puts "PATCH /posts/#{json_post_slug}.json -> #{json_update.code}, title is now '#{updated_via_json['title']}'"
json_update_cookie = cookie_from(json_update)
cookie = json_update_cookie unless json_update_cookie.empty?
stale_json_update_body = URI.encode_www_form(
'post[title]' => 'stale attempt', 'post[body]' => 'this must be rejected',
'post[lock_version]' => '0', '_method' => 'PATCH', 'authenticity_token' => token
)
stale_json_update = http.post("/posts/#{json_post_slug}.json", stale_json_update_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless stale_json_update.code == '409'
raise "expected a stale lock_version via .json to be rejected with 409, got #{stale_json_update.code}"
end
stale_update_errors = JSON.parse(stale_json_update.body)['errors']
unless stale_update_errors.is_a?(Array) && stale_update_errors.first['source']['pointer'] == '/data/attributes/lock_version'
raise "expected the 409 body to be a JSON:API error pointing at lock_version, got #{stale_json_update.body}"
end
puts "PATCH /posts/#{json_post_slug}.json with a stale lock_version -> #{stale_json_update.code} " \
"(StaleObjectError still caught, now with a JSON:API error body instead of a re-rendered form)"
# 'no-such-post-slug', not a bare number - posts#show is slug-keyed now
# (see app.rb's `param: :slug`), so this exercises the SAME "no row
# matches" 404 path a real mistyped/stale URL would hit.
missing_html = http.get('/posts/no-such-post-slug', 'Cookie' => cookie)
puts "GET /posts/no-such-post-slug (HTML) -> #{missing_html.code} #{missing_html['content-type']}"
raise "expected a plain-text/HTML 404 for a missing post, got #{missing_html.code}" unless missing_html.code == '404'
raise 'an HTML-format 404 must not be a JSON:API error body' if missing_html.body.strip.start_with?('{')
missing_json = http.get('/posts/no-such-post-slug.json', 'Cookie' => cookie)
puts "GET /posts/no-such-post-slug.json -> #{missing_json.code} #{missing_json['content-type']}"
raise "expected a JSON:API 404 for a missing post via .json, got #{missing_json.code}" unless missing_json.code == '404'
missing_errors = JSON.parse(missing_json.body)['errors']
unless missing_errors.is_a?(Array) && missing_errors.size == 1 && missing_errors.first['status'] == '404'
raise "expected a single JSON:API error object with status 404, got #{missing_json.body}"
end
raise 'a 404 JSON:API error has no single attribute to point at, so source must be absent' if missing_errors.first.key?('source')
puts "GET /posts/no-such-post-slug.json -> #{missing_json.code}, errors[0]={status: #{missing_errors.first['status'].inspect}, " \
"detail: #{missing_errors.first['detail'].inspect}} (respond_not_found: same JSON:API shape as render_errors, no source.pointer)"
puts '-' * 40
puts 'Comment belongs_to :post, counter_cache: true - posts#index reads post.comments_count, ' \
'a plain column, instead of running a query per post to count its comments'
home_with_two_comments = http.get('/', 'Cookie' => cookie)
unless home_with_two_comments.body.include?('2 comment(s)')
raise "expected the index listing to show the counter-cached total of 2 comments, body was: " \
"#{home_with_two_comments.body[/<li class="post".*?<\/li>/m]}"
end
puts "GET / -> post listing shows '2 comment(s)' (Post#comments_count bumped twice by Comment's after_create hook)"
second_comment_id = now_stale.body.scan(/id="comment-(\d+)"/).flatten.first
raise 'could not find the second comment id (newest-first ordering) on the post page' unless second_comment_id
second_comment_delete_path = "/posts/#{post_id}/comments/#{second_comment_id}"
second_comment_destroy = http.post(
second_comment_delete_path, URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
unless second_comment_destroy.code == '302'
raise "expected destroying the second comment to redirect, got #{second_comment_destroy.code}"
end
destroy_comment_cookie = cookie_from(second_comment_destroy)
cookie = destroy_comment_cookie unless destroy_comment_cookie.empty?
puts "POST #{second_comment_delete_path} with _method=DELETE -> #{second_comment_destroy.code}"
home_with_one_comment = http.get('/', 'Cookie' => cookie)
unless home_with_one_comment.body.include?('1 comment(s)')
raise 'expected the index listing to show comments_count back down to 1 after destroying a comment'
end
raise 'stale 2-comment count must not still be showing' if home_with_one_comment.body.include?('2 comment(s)')
puts "GET / -> post listing shows '1 comment(s)' (Comment#destroy decremented Post#comments_count back down)"
polish = http.get('/?locale=pl')
raise 'locale switch did not translate the tagline' unless polish.body.include?('Maly blog')
puts 'GET /?locale=pl -> tagline rendered in Polish'
edit_page = http.get("#{post_path}/edit", 'Cookie' => cookie)
puts "GET #{post_path}/edit -> #{edit_page.code}"
raise 'edit form should be pre-filled with the current title' unless edit_page.body.include?('value="Hello from Prism"')
puts '-' * 40
puts 'PostsController#preview - diffs a proposed edit against the saved row without persisting it, ' \
'using Prism::Orm::Dirty#restore_attributes to revert its Post instance so the SAME response can ' \
'still show the actual saved title alongside the proposed one'
preview_lock_version = edit_page.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1]
raise 'edit form should carry the current lock_version for the preview scene' if preview_lock_version.to_s.empty?
preview_attempt_body = URI.encode_www_form(
'post[title]' => 'Previewed title, not yet saved', 'post[body]' => 'Previewed body, not yet saved.',
'post[lock_version]' => preview_lock_version, '_method' => 'PATCH', 'authenticity_token' => token
)
preview = http.post("#{post_path}/preview", preview_attempt_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{post_path}/preview with _method=PATCH -> #{preview.code}"
raise "expected the preview action to render 200, got #{preview.code}" unless preview.code == '200'
unless preview.body.include?('Previewed title, not yet saved')
raise 'preview response should show the proposed title in the diff table'
end
unless preview.body.include?('Hello from Prism')
raise "preview response should still show the post's actual saved title - " \
"Post#restore_attributes should have reverted the in-memory change before rendering"
end
puts "POST #{post_path}/preview -> #{preview.code}, response shows both the proposed title (diff) and the " \
"still-saved current title (restore_attributes reverted the model instance within the same request)"
not_yet_saved = http.get(post_path, 'Cookie' => cookie)
raise 'the previewed title must never have been persisted' if not_yet_saved.body.include?('Previewed title, not yet saved')
puts "GET #{post_path} -> the previewed title was never written to disk, only diffed"
update_body = URI.encode_www_form(
'post[title]' => 'Hello from Prism (edited)', 'post[body]' => 'Updated through a dirty-tracked update.',
'_method' => 'PATCH', 'authenticity_token' => token
)
update = http.post(post_path, update_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{post_path} with _method=PATCH -> #{update.code} Location=#{update['location']}"
raise "expected the update to redirect back to the post, got #{update.code}" unless update.code == '302'
post_update_cookie = cookie_from(update)
cookie = post_update_cookie unless post_update_cookie.empty?
updated_show = http.get(post_path, 'Cookie' => cookie)
raise 'updated title did not appear on the show page' unless updated_show.body.include?('Hello from Prism (edited)')
unless updated_show.body.include?('changed: title, body') || updated_show.body.include?('zmienione: title, body')
raise 'Prism::Orm::Dirty-backed notice should list the fields that actually changed'
end
puts "GET #{post_path} -> title/body updated, dirty-tracking notice named the changed fields"
noop_body = URI.encode_www_form(
'post[title]' => 'Hello from Prism (edited)', 'post[body]' => 'Updated through a dirty-tracked update.',
'_method' => 'PATCH', 'authenticity_token' => token
)
noop = http.post(post_path, noop_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the no-op update to redirect too, got #{noop.code}" unless noop.code == '302'
noop_cookie = cookie_from(noop)
cookie = noop_cookie unless noop_cookie.empty?
noop_show = http.get(post_path, 'Cookie' => cookie)
unless noop_show.body.include?('Nothing changed') || noop_show.body.include?('Nic sie nie zmienilo')
raise 'submitting identical values should report that Prism::Orm::Dirty saw zero real changes'
end
puts "POST #{post_path} with identical values -> Post#saved_changes was empty, notice said nothing changed"
# Prism::Orm::Locking: two "browser tabs" open the same edit form (each
# capturing the lock_version the row had at that moment), tab A saves
# first and bumps it, then tab B - still holding the OLD lock_version in
# its hidden field - submits on top of that. Store#update's version check
# runs inside the same PStore transaction as the merge, so tab B's write
# cannot silently clobber tab A's: it must come back as a 409 with the
# conflict notice, and tab A's title must survive untouched.
tab_a_edit = http.get("#{post_path}/edit", 'Cookie' => cookie)
tab_b_edit = http.get("#{post_path}/edit", 'Cookie' => cookie)
tab_a_lock_version = tab_a_edit.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1]
tab_b_lock_version = tab_b_edit.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1]
raise 'edit form should carry the current lock_version in a hidden field' if tab_a_lock_version.to_s.empty?
raise 'both tabs should have observed the same lock_version before either saved' unless tab_a_lock_version == tab_b_lock_version
tab_a_save_body = URI.encode_www_form(
'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.',
'post[lock_version]' => tab_a_lock_version, '_method' => 'PATCH', 'authenticity_token' => token
)
tab_a_save = http.post(post_path, tab_a_save_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected tab A's save to succeed, got #{tab_a_save.code}" unless tab_a_save.code == '302'
tab_a_cookie = cookie_from(tab_a_save)
cookie = tab_a_cookie unless tab_a_cookie.empty?
puts "POST #{post_path}/edit tab A (lock_version=#{tab_a_lock_version}) -> #{tab_a_save.code}, saved first"
tab_b_save_body = URI.encode_www_form(
'post[title]' => 'Hello from Prism (tab B lost)', 'post[body]' => 'Submitted stale, should be rejected.',
'post[lock_version]' => tab_b_lock_version, '_method' => 'PATCH', 'authenticity_token' => token
)
tab_b_save = http.post(post_path, tab_b_save_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless tab_b_save.code == '409'
raise "expected tab B's stale save to be rejected with 409, got #{tab_b_save.code}"
end
unless tab_b_save.body.include?('Someone else changed this post') || tab_b_save.body.include?('Ktos inny zmienil ten wpis')
raise 'the 409 response should render the stale-conflict notice'
end
puts "POST #{post_path}/edit tab B (stale lock_version=#{tab_b_lock_version}) -> #{tab_b_save.code} (expected 409, StaleObjectError caught)"
after_conflict = http.get(post_path, 'Cookie' => cookie)
raise "tab B's rejected write must not have overwritten tab A's title" unless after_conflict.body.include?('Hello from Prism (tab A won)')
raise "tab B's rejected write must not have leaked through" if after_conflict.body.include?('tab B lost')
puts "GET #{post_path} -> tab A's title survives untouched, Prism::Orm::Locking prevented the lost update"
puts '-' * 40
puts 'Post.accepts_nested_attributes_for :comments - the author moderates a comment from the POST edit form ' \
'itself, not that comment\'s own /comments/:id route'
moderation_comment_body = URI.encode_www_form(
'comment[author]' => 'Someone to moderate', 'comment[body]' => 'unmoderated text', 'authenticity_token' => token
)
moderation_comment = http.post("/posts/#{post_id}/comments", moderation_comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the extra comment to redirect, got #{moderation_comment.code}" unless moderation_comment.code == '302'
moderation_comment_cookie = cookie_from(moderation_comment)
cookie = moderation_comment_cookie unless moderation_comment_cookie.empty?
before_moderation_home = http.get('/', 'Cookie' => cookie)
unless before_moderation_home.body.include?('2 comment(s)')
raise 'expected comments_count to read 2 before the moderation scene (the tab-A comment plus this new one)'
end
puts "POST /posts/#{post_id}/comments -> #{moderation_comment.code} (a second comment to moderate through the post form)"
moderation_edit_page = http.get("#{post_path}/edit", 'Cookie' => cookie)
moderation_index = nil
moderation_edit_page.body.scan(/name="post\[comments_attributes\]\[(\d+)\]\[id\]"[^>]*value="(\d+)"/) do |index, id|
moderation_index = index if id.to_i != comment_id.to_i
end
raise 'edit form should render a nested field for the comment to be moderated' unless moderation_index
raise 'moderation nested field should carry the unmoderated body' unless moderation_edit_page.body.include?('unmoderated text')
puts "GET #{post_path}/edit -> FormBuilder#fields_for rendered comments_attributes[#{moderation_index}] for the new comment"
moderation_lock_version = moderation_edit_page.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1]
moderate_body_edit = URI.encode_www_form(
'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.',
'post[lock_version]' => moderation_lock_version,
"post[comments_attributes][#{moderation_index}][id]" => moderation_edit_page.body[/name="post\[comments_attributes\]\[#{moderation_index}\]\[id\]"[^>]*value="(\d+)"/, 1],
"post[comments_attributes][#{moderation_index}][body]" => 'Edited by the post author, not the commenter',
'_method' => 'PATCH', 'authenticity_token' => token
)
moderate_edit = http.post(post_path, moderate_body_edit, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the moderation edit to redirect, got #{moderate_edit.code}" unless moderate_edit.code == '302'
moderate_edit_cookie = cookie_from(moderate_edit)
cookie = moderate_edit_cookie unless moderate_edit_cookie.empty?
puts "POST #{post_path} with comments_attributes[#{moderation_index}][body] -> #{moderate_edit.code}"
moderated_show = http.get(post_path, 'Cookie' => cookie)
unless moderated_show.body.include?('Edited by the post author, not the commenter')
raise 'expected the comment moderated through the post edit form to show its new body on the post page'
end
raise 'the other comment must not have been touched by this moderation edit' unless moderated_show.body.include?('Nice post, tab A won!')
puts "GET #{post_path} -> the moderated comment shows its new body, the untouched comment (tab A/B locking scene) is unaffected"
moderation_edit_page_2 = http.get("#{post_path}/edit", 'Cookie' => cookie)
moderation_lock_version_2 = moderation_edit_page_2.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1]
destroy_via_moderation_body = URI.encode_www_form(
'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.',
'post[lock_version]' => moderation_lock_version_2,
"post[comments_attributes][#{moderation_index}][id]" => moderation_edit_page.body[/name="post\[comments_attributes\]\[#{moderation_index}\]\[id\]"[^>]*value="(\d+)"/, 1],
"post[comments_attributes][#{moderation_index}][_destroy]" => '1',
'_method' => 'PATCH', 'authenticity_token' => token
)
destroy_via_moderation = http.post(post_path, destroy_via_moderation_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the moderation destroy to redirect, got #{destroy_via_moderation.code}" unless destroy_via_moderation.code == '302'
moderation_destroy_cookie = cookie_from(destroy_via_moderation)
cookie = moderation_destroy_cookie unless moderation_destroy_cookie.empty?
puts "POST #{post_path} with comments_attributes[#{moderation_index}][_destroy]=1 -> #{destroy_via_moderation.code} " \
'(comment removed through accepts_nested_attributes_for, not its own /comments/:id route)'
after_moderation_destroy = http.get('/', 'Cookie' => cookie)
unless after_moderation_destroy.body.include?('1 comment(s)')
raise 'expected comments_count to drop back to 1 - counter_cache after_destroy still fires when a comment ' \
'is destroyed through nested attributes, not just through CommentsController#destroy'
end
puts "GET / -> post listing shows '1 comment(s)' again (counter_cache after_destroy ran for the nested-attributes destroy too)"
puts '-' * 40
puts 'CommentsController#index - comments now get their own paginated URL, not just whatever fits ' \
'inline on posts#show'
11.times do |i|
filler_comment_body = URI.encode_www_form(
'comment[author]' => 'Pager', 'comment[body]' => "Filler comment #{i + 1}", 'authenticity_token' => token
)
filler_comment = http.post("/posts/#{post_id}/comments", filler_comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected filler comment #{i + 1} to redirect, got #{filler_comment.code}" unless filler_comment.code == '302'
end
puts 'created 11 filler comments (12 total: the surviving tab-A comment plus these) to exercise ' \
'Orm::Pagination on comments#index'
comments_page_one = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie)
comments_page_two = http.get("/posts/#{post_id}/comments?page=2", 'Cookie' => cookie)
puts "GET /posts/#{post_id}/comments -> #{comments_page_one.code}, GET ...?page=2 -> #{comments_page_two.code}"
raise 'comments page 1 should render pagination nav links' unless comments_page_one.body.include?('class="pagination"')
unless comments_page_one.body.scan('class="comment"').size == 10
raise 'comments page 1 should show exactly 10 comments (Orm::Pagination default per-page)'
end
raise 'comments page 2 should show the remaining 2 comments' unless comments_page_two.body.scan('class="comment"').size == 2
comments_etag = comments_page_one['etag']
raise 'comments#index should set an ETag now that it uses fresh_when' unless comments_etag
puts "GET /posts/#{post_id}/comments -> ETag=#{comments_etag}"
conditional_comments = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie, 'If-None-Match' => comments_etag)
puts "GET /posts/#{post_id}/comments with If-None-Match: #{comments_etag} -> #{conditional_comments.code}"
unless conditional_comments.code == '304'
raise "expected a fresh conditional GET on comments#index to return 304, got #{conditional_comments.code}"
end
raise '304 response on comments#index must not carry a body' unless conditional_comments.body.to_s.empty?
one_more_comment_body = URI.encode_www_form(
'comment[author]' => 'Pager', 'comment[body]' => 'One more filler comment', 'authenticity_token' => token
)
one_more_comment = http.post("/posts/#{post_id}/comments", one_more_comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the extra filler comment to redirect, got #{one_more_comment.code}" unless one_more_comment.code == '302'
now_stale_comments = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie, 'If-None-Match' => comments_etag)
puts "GET /posts/#{post_id}/comments with the OLD If-None-Match after a new comment -> #{now_stale_comments.code}"
unless now_stale_comments.code == '200'
raise "expected the stale conditional GET on comments#index to return 200, got #{now_stale_comments.code}"
end
raise 'the fresh comments#index render must carry a NEW ETag, not the stale one' if now_stale_comments['etag'] == comments_etag
comments_json = http.get("/posts/#{post_id}/comments.json", 'Cookie' => cookie)
parsed_comments_json = JSON.parse(comments_json.body)
puts "GET /posts/#{post_id}/comments.json -> #{comments_json.code} (#{parsed_comments_json.size} comments on this page)"
raise 'comments#index.json should return only the current page (10), not every comment' unless parsed_comments_json.size == 10
unless parsed_comments_json.first.keys.sort == %w[author body id post_id status]
raise 'comments#index.json entries should carry exactly id/author/body/post_id/status'
end
post_show_with_comments_link = http.get(post_path, 'Cookie' => cookie)
unless post_show_with_comments_link.body.include?("/posts/#{post_id}/comments")
raise 'post show page should link to the new paginated comments#index'
end
puts "GET #{post_path} -> links to the new /posts/#{post_id}/comments page"
rejected = http.post('/posts', URI.encode_www_form('post[title]' => 'no token'),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST /posts without a CSRF token -> #{rejected.code} (expected 422)"
raise 'missing CSRF token should have been rejected' unless rejected.code == '422'
5.times do |i|
filler_body = URI.encode_www_form(
'post[title]' => "Filler post #{i + 1}", 'post[body]' => 'Padding content to exercise pagination.',
'authenticity_token' => token
)
filler = http.post('/posts', filler_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected filler post #{i + 1} to redirect, got #{filler.code}" unless filler.code == '302'
end
puts 'created 5 filler posts (6 total) to exercise Orm::Pagination on posts#index'
page_one = http.get('/posts', 'Cookie' => cookie)
page_two = http.get('/posts?page=2', 'Cookie' => cookie)
puts "GET /posts -> #{page_one.code}, GET /posts?page=2 -> #{page_two.code}"
raise 'page 1 should render pagination nav links' unless page_one.body.include?('class="pagination"')
raise 'page 2 should render a different set of posts than page 1' if page_one.body == page_two.body
raise 'page 2 should still contain the oldest post (Hello from Prism)' unless page_two.body.include?('Hello from Prism')
fresh_index = http.get('/posts', 'Cookie' => cookie)
index_etag = fresh_index['etag']
raise 'posts#index should set an ETag now that it uses fresh_when' unless index_etag
puts "GET /posts -> #{fresh_index.code}, ETag=#{index_etag}"
conditional_index = http.get('/posts', 'Cookie' => cookie, 'If-None-Match' => index_etag)
puts "GET /posts with If-None-Match: #{index_etag} -> #{conditional_index.code}"
raise "expected a fresh conditional GET on the index to return 304, got #{conditional_index.code}" unless conditional_index.code == '304'
raise '304 response on the index must not carry a body' unless conditional_index.body.to_s.empty?
another_filler_body = URI.encode_www_form(
'post[title]' => 'One more filler post', 'post[body]' => 'Exercises index etag invalidation.',
'authenticity_token' => token
)
another_filler = http.post('/posts', another_filler_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the extra filler post to redirect, got #{another_filler.code}" unless another_filler.code == '302'
index_after_new_post_cookie = cookie_from(another_filler)
cookie = index_after_new_post_cookie unless index_after_new_post_cookie.empty?
now_stale_index = http.get('/posts', 'Cookie' => cookie, 'If-None-Match' => index_etag)
puts "GET /posts with the OLD If-None-Match after a new post -> #{now_stale_index.code}"
unless now_stale_index.code == '200'
raise "expected the stale index ETag to be rejected after a new post shifted pagination, got #{now_stale_index.code}"
end
raise 'the fresh index render must carry a NEW ETag, not the stale one' if now_stale_index['etag'] == index_etag
puts "GET /posts -> #{now_stale_index.code} (ETag changed after a new post shifted total_count/pagination)"
puts '-' * 40
puts "exercising Post's enum :status (draft/published/archived) via #publish/#unpublish"
status_target_path = another_filler['location']
before_unpublish_index = http.get('/posts', 'Cookie' => cookie)
raise "'One more filler post' should be visible on the index" unless before_unpublish_index.body.include?('One more filler post')
unpublish_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
unpublish = http.post("#{status_target_path}/unpublish", unpublish_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{status_target_path}/unpublish with _method=PATCH -> #{unpublish.code} Location=#{unpublish['location']}"
raise "expected the unpublish action to redirect, got #{unpublish.code}" unless unpublish.code == '302'
unpublish_cookie = cookie_from(unpublish)
cookie = unpublish_cookie unless unpublish_cookie.empty?
after_unpublish_index = http.get('/posts', 'Cookie' => cookie)
raise 'a draft post must not appear on the public index' if after_unpublish_index.body.include?('One more filler post')
draft_show = http.get(status_target_path, 'Cookie' => cookie)
raise "a draft's own show page must still be reachable directly by its author" unless draft_show.code == '200'
raise 'show page should render the Draft status label while unpublished' unless draft_show.body.include?('Draft')
puts "GET #{status_target_path} while a draft -> #{draft_show.code} (still reachable directly, index no longer lists it)"
republish_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
republish = http.post("#{status_target_path}/publish", republish_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{status_target_path}/publish with _method=PATCH -> #{republish.code}"
raise "expected the publish action to redirect, got #{republish.code}" unless republish.code == '302'
republish_cookie = cookie_from(republish)
cookie = republish_cookie unless republish_cookie.empty?
after_republish_index = http.get('/posts', 'Cookie' => cookie)
raise 're-published post should be back on the public index' unless after_republish_index.body.include?('One more filler post')
puts 'GET /posts -> re-published post is listed again'
puts '-' * 40
puts "checking that add_index :posts, :status (db/migrate/#{'20260907000003_add_index_on_posts_status.rb'}) is really used by the live posts#index request, not just the synthetic CountingIndexStore in test/test_orm_index.rb"
index_usage_log_offset = File.size(log_path)
http.get('/posts', 'Cookie' => cookie)
index_usage_log_lines = File.read(log_path)[index_usage_log_offset..].to_s
unless index_usage_log_lines.include?('operation="SELECT INDEX"') && index_usage_log_lines.include?('table="posts"')
raise 'expected Post.published (the enum scope backing posts#index, -> where(status: 1)) to hit the new posts.status index instead of a full table scan'
end
raise 'expected the posts.status index lookup to be reported as covering the status column' unless index_usage_log_lines.include?('columns=["status"]')
puts ' sql.prism log line for this GET /posts shows operation="SELECT INDEX" table="posts" columns=["status"] - the enum scope reused the real index, zero full scan'
puts '-' * 40
puts "checking the posts#index ?days= recency filter (Post.created_within, db/migrate/20260907000004_add_index_on_posts_created_at.rb)"
recency_log_offset = File.size(log_path)
recent_index = http.get('/posts?days=7', 'Cookie' => cookie)
raise "expected GET /posts?days=7 to succeed, got #{recent_index.code}" unless recent_index.code == '200'
raise 'every post in this demo run was just created, so the 7-day filter must still show them' unless recent_index.body.include?('One more filler post')
recency_log_lines = File.read(log_path)[recency_log_offset..].to_s
# .published is chained BEFORE .created_within on posts#index, and
# Relation#indexed_candidates tries an equality-covering index before it
# ever looks at ranges - so this combined query resolves entirely through
# the posts.status index (see the "SELECT INDEX" check above) and never
# reaches the created_at range index at all. That is correct (still zero
# full scans), just not proof of the range index specifically - see the
# direct Post.created_within call below for that.
if recency_log_lines.include?("operation=\"SELECT\" table=\"posts\"")
raise 'expected Post.published.created_within(...) (posts#index ?days= filter) to resolve through an index, not a bare full table scan'
end
puts ' GET /posts?days=7 resolved through an index (posts.status, since .published already narrows it down), zero full scan'
ancient_index = http.get('/posts?days=0', 'Cookie' => cookie)
raise "expected GET /posts?days=0 (filter off) to succeed, got #{ancient_index.code}" unless ancient_index.code == '200'
raise 'days=0 must mean "no recency filter", same post set as plain /posts' unless ancient_index.body.include?('One more filler post')
puts ' GET /posts?days=0 -> filter off, same post set as an unfiltered /posts'
# Isolates Post.created_within itself (no .published in front of it, so
# nothing else could resolve the query first) to prove the created_at
# range index specifically is what's backing it, not just an accident of
# chaining order.
range_only_log_offset = File.size(log_path)
recent_unscoped = Post.created_within(7).to_a
raise 'Post.created_within(7) should include every post created during this demo run' if recent_unscoped.empty?
range_only_log_lines = File.read(log_path)[range_only_log_offset..].to_s
unless range_only_log_lines.include?('operation="SELECT INDEX RANGE"') && range_only_log_lines.include?('table="posts"')
raise 'expected the standalone Post.created_within(7) to hit the posts.created_at range index instead of a full table scan'
end
raise 'expected the posts.created_at range index lookup to be reported as covering the created_at column' unless range_only_log_lines.include?('columns=["created_at"]')
puts ' Post.created_within(7) alone -> sql.prism shows operation="SELECT INDEX RANGE" table="posts" columns=["created_at"], zero full scan'
puts '-' * 40
puts 'checking GET /posts/export (PostsController#export) - Prism::Controller#stream over a REAL socket, not just a directly-invoked App#call(env) tuple like test_controller_streaming.rb/test_server.rb already cover'
export_response = http.get('/posts/export', 'Cookie' => cookie)
raise "expected GET /posts/export to succeed, got #{export_response.code}" unless export_response.code == '200'
raise 'expected the export to be sent as chunked Transfer-Encoding, not a single buffered Content-Length body' unless export_response['transfer-encoding'] == 'chunked'
raise 'a chunked response must not also carry a Content-Length' if export_response['content-length']
raise "expected text/csv, got #{export_response['content-type']}" unless export_response['content-type'] == 'text/csv'
csv_lines = export_response.body.lines
raise 'expected a header row followed by one row per published post' unless csv_lines.first == "id,title,created_at\n"
raise 'expected the re-published post from the enum scene above to be in the export' unless export_response.body.include?('One more filler post')
expected_row_count = Post.published.count
raise "expected #{expected_row_count} data row(s) (Post.published.count), got #{csv_lines.size - 1} - the export scope must exclude drafts/archived just like .published does" unless csv_lines.size - 1 == expected_row_count
puts " GET /posts/export -> #{export_response.code}, Transfer-Encoding: chunked, #{expected_row_count} published post row(s) (matches Post.published.count exactly)"
puts '-' * 40
puts 'checking Prism::Middleware::Compression over a REAL socket - setting our own Accept-Encoding disables ' \
"Net::HTTP's transparent auto-decoding, so response.body below is the raw wire bytes, not something " \
'this script infers from matching decompressed content (which every other Net::HTTP call above already ' \
'does silently, since Net::HTTP sends its own Accept-Encoding: gzip by default and decodes for us)'
gzip_headers = {'Cookie' => cookie, 'Accept-Encoding' => 'gzip'}
compressed_index = http.get('/posts', gzip_headers)
raise "expected GET /posts to succeed, got #{compressed_index.code}" unless compressed_index.code == '200'
raise 'expected Content-Encoding: gzip on a compressible HTML response' unless compressed_index['content-encoding'] == 'gzip'
decompressed_index = Zlib::GzipReader.new(StringIO.new(compressed_index.body)).read
raise 'expected the decompressed body to still be the same posts#index HTML' unless decompressed_index.include?('One more filler post')
raise 'expected gzip to actually shrink a repetitive HTML page' unless compressed_index.body.bytesize < decompressed_index.bytesize
raise 'a compressed response Content-Length must describe the compressed bytes on the wire, not the original size' \
unless compressed_index['content-length'].to_i == compressed_index.body.bytesize
puts " GET /posts (Accept-Encoding: gzip) -> #{compressed_index.code}, Content-Encoding: gzip, " \
"#{decompressed_index.bytesize} bytes -> #{compressed_index.body.bytesize} bytes on the wire"
compressed_export = http.get('/posts/export', gzip_headers)
raise "expected GET /posts/export to succeed, got #{compressed_export.code}" unless compressed_export.code == '200'
raise 'expected chunked Transfer-Encoding and gzip Content-Encoding to compose on the streamed export' \
unless compressed_export['transfer-encoding'] == 'chunked' && compressed_export['content-encoding'] == 'gzip'
raise 'a chunked response must still never carry a Content-Length, gzipped or not' if compressed_export['content-length']
decompressed_export = Zlib::GzipReader.new(StringIO.new(compressed_export.body)).read
raise 'gzipping a StreamingBody chunk-by-chunk must reproduce byte-for-byte the same CSV as the uncompressed export' \
unless decompressed_export == export_response.body
puts ' GET /posts/export (Accept-Encoding: gzip) -> chunked + gzip together, decompresses to the exact same CSV'
small_json_probe = http.get('/assets/manifest.json', gzip_headers)
raise "expected GET /assets/manifest.json to succeed, got #{small_json_probe.code}" unless small_json_probe.code == '200'
raise 'expected a tiny response body to be left alone (below the compression threshold)' if small_json_probe['content-encoding']
puts " GET /assets/manifest.json (Accept-Encoding: gzip) -> #{small_json_probe.code}, no Content-Encoding " \
"(#{small_json_probe.body.bytesize} bytes, below the size threshold)"
puts '-' * 40
puts 'draining the background job queue (NotificationMailer.deliver_later from the post#create above)'
worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue)
processed = worker.work_off
puts "worker processed #{processed} job(s); Prism::Mailer.deliveries=#{Prism::Mailer.deliveries.size}"
raise 'expected the new-post notification email to have been delivered' if Prism::Mailer.deliveries.empty?
puts "last delivery subject: #{Prism::Mailer.deliveries.last.subject}"
preview_index = http.get('/rails/mailers')
puts "GET /rails/mailers -> #{preview_index.code}"
raise 'mailer preview index did not list notification_mailer' unless preview_index.body.include?('notification_mailer')
preview_email = http.get('/rails/mailers/notification_mailer/new_post')
puts "GET /rails/mailers/notification_mailer/new_post -> #{preview_email.code}"
unless preview_email.body.include?('New post:')
raise 'mailer preview did not render the new_post email subject'
end
puts '-' * 40
puts 'checking POST /posts/import (Post.import_csv) - the CSV counterpart of GET /posts/export above, and the ' \
"only place in this app where Post.create_with_unique_slug's per-row transaction(requires_new: true) " \
'savepoint actually matters (import_csv wraps the whole batch in one outer Post.transaction, so a plain ' \
'nested transaction per row would either roll back rows already imported earlier in the same batch, or ' \
'leave a doomed row enlisted to wrongly fire its after_commit notification once the batch commits)'
import_csv_body = CSV.generate do |csv|
csv << %w[title body]
csv << ['Bulk Import Special Report', 'First half of a two-part report.']
csv << ['Bulk Import Special Report',
'Second half, same title on purpose - this row must collide on the ' \
'first-choice slug (a REAL Store::UniqueConstraintViolation from the unique index on posts.slug, ' \
'not a mocked one) and retry with a numeric suffix.']
csv << ['', 'This row has no title and must be skipped without aborting the rows around it.']
csv << ['Another Imported Post', 'A normal row after the skipped one, proving the batch keeps going.']
end
posts_before_import = Post.count
deliveries_before_import = Prism::Mailer.deliveries.size
import_boundary = '----PrismBlogDemoImportBoundary'
import_body = multipart_body(
import_boundary,
fields: { 'authenticity_token' => token },
files: { 'file' => ['posts.csv', 'text/csv', import_csv_body] }
)
import_response = http.post(
'/posts/import', import_body,
'Content-Type' => "multipart/form-data; boundary=#{import_boundary}", 'Cookie' => cookie
)
puts "POST /posts/import (multipart CSV: 2 unique rows + 1 duplicate-titled row + 1 blank-titled row) -> " \
"#{import_response.code} Location=#{import_response['location']}"
raise "expected the import to redirect back to /posts, got #{import_response.code}" unless import_response.code == '302'
import_cookie = cookie_from(import_response)
cookie = import_cookie unless import_cookie.empty?
imported_count = Post.count - posts_before_import
raise "expected exactly 3 new posts (2 unique-titled rows + 1 retried duplicate), got #{imported_count}" unless imported_count == 3
special_report = Post.where(title: 'Bulk Import Special Report').order(:id, :asc).to_a
raise 'expected both duplicate-titled rows to have been imported' unless special_report.size == 2
unless special_report.first.slug == 'bulk-import-special-report'
raise "expected the first row's slug to be the plain, unsuffixed base, got #{special_report.first.slug.inspect}"
end
unless special_report.last.slug == 'bulk-import-special-report-2'
raise "expected the second row's real unique-index collision to have been retried with a numeric suffix, " \
"got #{special_report.last.slug.inspect}"
end
puts " both duplicate-titled rows imported: slugs #{special_report.map(&:slug).inspect} " \
'(a real Store::UniqueConstraintViolation retried via transaction(requires_new: true), not a mocked collision)'
another = Post.find_by(title: 'Another Imported Post')
raise 'expected the row after the skipped blank-titled one to have been imported too' unless another
puts " row after the skipped blank-titled row was still imported (slug=#{another.slug.inspect}) - " \
"Post.import_csv's outer transaction survived the skip instead of losing the whole batch"
import_worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue)
import_processed = import_worker.work_off
new_deliveries = Prism::Mailer.deliveries.size - deliveries_before_import
puts "worker processed #{import_processed} job(s) from the import; #{new_deliveries} new notification email(s)"
unless new_deliveries == 3
raise "expected exactly 3 new notification emails (one per actually-imported post, none for the skipped " \
"blank-titled row or for either retry attempt on the duplicate title), got #{new_deliveries}"
end
puts '-' * 40
puts 'reserved-slug check: a post titled the same as a collection route (Post.reserved_slugs, derived ' \
"from Blog.router itself) must not steal that route's URL - a plain title-derived slug 'trash' would " \
'otherwise be permanently unreachable at its own /posts/trash show page, since Application.resources ' \
'registers the literal GET /posts/trash collection route before the generic GET /posts/:slug show route ' \
'it would shadow'
raise 'Post.reserved_slugs should include both collection routes registered under /posts' \
unless (%w[trash export] - Post.reserved_slugs).empty?
reserved_title_body = URI.encode_www_form(
'post[title]' => 'Trash', 'post[body]' => 'This title collides with the /posts/trash collection route.',
'authenticity_token' => token
)
reserved_title_create = http.post('/posts', reserved_title_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
reserved_post_path = reserved_title_create['location']
puts "POST /posts (title 'Trash') -> #{reserved_title_create.code} Location=#{reserved_post_path}"
raise "expected a redirect to the new post, got #{reserved_title_create.code}" unless reserved_title_create.code == '302'
reserved_post_cookie = cookie_from(reserved_title_create)
cookie = reserved_post_cookie unless reserved_post_cookie.empty?
reserved_slug = reserved_post_path.split('/').last
raise "expected the base slug 'trash' to have been skipped in favor of a numeric suffix, got #{reserved_slug.inspect}" \
if reserved_slug == 'trash'
puts " title 'Trash' -> slug #{reserved_slug.inspect} (base 'trash' skipped - it's a reserved collection route)"
reserved_post_show = http.get(reserved_post_path, 'Cookie' => cookie)
raise "expected GET #{reserved_post_path} to render the post's own show page" \
unless reserved_post_show.code == '200' && reserved_post_show.body.include?('class="status"')
puts " GET #{reserved_post_path} -> #{reserved_post_show.code}, renders the post's own show page (not the trash listing)"
trash_listing = http.get('/posts/trash', 'Cookie' => cookie)
# trash.erb never renders class="status" (that markup is show.erb-only,
# see the reserved_post_show assertion above) whether or not the current
# user's trash is empty right now - a content check that would only work
# while it happens to be non-empty (e.g. looking for the trash <ul>)
# would be an accident of demo ordering, not a real proof this is the
# listing and not the post titled "Trash".
raise "expected GET /posts/trash to still resolve to the trash LISTING (not the post titled 'Trash')" \
unless trash_listing.code == '200' && !trash_listing.body.include?('class="status"')
puts " GET /posts/trash -> #{trash_listing.code}, still the trash listing page, untouched by the reserved slug"
puts '-' * 40
puts 'featured flag check: Prism::Orm::Attributes/TypeCasting - the "featured" checkbox posts a String ' \
'("1"/"0") like every HTML form field does, cast into a real TrueClass/FalseClass on write (Post#featured, ' \
'see the model) so Post.featured (where(featured: true)) and the ?featured=1 index filter can actually ' \
'match it - a raw uncast "1" string would never equal the Boolean true a plain `==` query compares against.'
featured_body = URI.encode_www_form(
'post[title]' => 'Featured launch announcement', 'post[body]' => 'This one should show up in the featured filter.',
'post[featured]' => '1', 'authenticity_token' => token
)
featured_create = http.post('/posts', featured_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
featured_post_path = featured_create['location']
puts "POST /posts (post[featured]=1) -> #{featured_create.code} Location=#{featured_post_path}"
raise "expected a redirect to the new post, got #{featured_create.code}" unless featured_create.code == '302'
featured_cookie = cookie_from(featured_create)
cookie = featured_cookie unless featured_cookie.empty?
featured_slug = featured_post_path.split('/').last
featured_post = Post.find_by!(slug: featured_slug)
unless featured_post.featured == true && featured_post.featured.is_a?(TrueClass)
raise "expected Post#featured to be a real Boolean true, got #{featured_post.featured.inspect} (#{featured_post.featured.class})"
end
puts " Post.find_by!(slug: #{featured_slug.inspect}).featured -> #{featured_post.featured.inspect} " \
"(#{featured_post.featured.class}, not the raw \"1\" string the form submitted)"
plain_body = URI.encode_www_form(
'post[title]' => 'Ordinary, not featured', 'post[body]' => 'Should be absent from the featured filter.',
'post[featured]' => '0', 'authenticity_token' => token
)
plain_create = http.post('/posts', plain_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected a redirect to the new post, got #{plain_create.code}" unless plain_create.code == '302'
plain_cookie = cookie_from(plain_create)
cookie = plain_cookie unless plain_cookie.empty?
plain_slug = plain_create['location'].split('/').last
featured_filter = http.get('/posts?featured=1', 'Cookie' => cookie)
raise "expected GET /posts?featured=1 to succeed, got #{featured_filter.code}" unless featured_filter.code == '200'
raise 'expected the featured post to appear in the ?featured=1 filter' \
unless featured_filter.body.include?("id=\"post-#{featured_slug}\"")
raise 'expected the non-featured post to be ABSENT from the ?featured=1 filter' \
if featured_filter.body.include?("id=\"post-#{plain_slug}\"")
raise 'expected the featured badge to render for the featured post' unless featured_filter.body.include?('featured-badge')
puts " GET /posts?featured=1 -> #{featured_filter.code}, includes the featured post, excludes the plain one " \
'(Post.featured scope -> where(featured: true) matched a real Boolean, not a full-scan string comparison)'
queried_featured = Post.where(featured: true).to_a
raise "expected exactly the one featured post created above, got #{queried_featured.map(&:slug).inspect}" \
unless queried_featured.map(&:slug) == [featured_slug]
puts " Post.where(featured: true) -> #{queried_featured.map(&:slug).inspect} (ORM query engine, not just the controller filter)"
puts '-' * 40
puts 'Post#acts_as_list (scoped by featured, see the model) - editors reorder the featured shelf with ' \
'#move_up/#move_down (PostsController) instead of it always sorting by recency'
second_featured_body = URI.encode_www_form(
'post[title]' => 'Second featured pick', 'post[body]' => 'Also curated onto the shelf.',
'post[featured]' => '1', 'authenticity_token' => token
)
second_featured_create = http.post('/posts', second_featured_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected a redirect to the new post, got #{second_featured_create.code}" unless second_featured_create.code == '302'
second_featured_cookie = cookie_from(second_featured_create)
cookie = second_featured_cookie unless second_featured_cookie.empty?
second_featured_slug = second_featured_create['location'].split('/').last
third_featured_body = URI.encode_www_form(
'post[title]' => 'Third featured pick', 'post[body]' => 'Bottom of the shelf for now.',
'post[featured]' => '1', 'authenticity_token' => token
)
third_featured_create = http.post('/posts', third_featured_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected a redirect to the new post, got #{third_featured_create.code}" unless third_featured_create.code == '302'
third_featured_cookie = cookie_from(third_featured_create)
cookie = third_featured_cookie unless third_featured_cookie.empty?
third_featured_slug = third_featured_create['location'].split('/').last
shelf_before = http.get('/posts?featured=1', 'Cookie' => cookie)
order_before = [featured_slug, second_featured_slug, third_featured_slug].select { |slug| shelf_before.body.index("id=\"post-#{slug}\"") }
.sort_by { |slug| shelf_before.body.index("id=\"post-#{slug}\"") }
raise "expected the shelf in creation order, got #{order_before.inspect}" \
unless order_before == [featured_slug, second_featured_slug, third_featured_slug]
etag_before_reorder = shelf_before['etag']
puts " GET /posts?featured=1 -> shelf order #{order_before.inspect} (creation order, ETag #{etag_before_reorder})"
move_down_log_offset = File.size(log_path)
move_down_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
move_down = http.post("/posts/#{featured_slug}/move_down", move_down_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST /posts/#{featured_slug}/move_down with _method=PATCH -> #{move_down.code} Location=#{move_down['location']}"
raise "expected the move_down action to redirect, got #{move_down.code}" unless move_down.code == '302'
raise 'expected move_down to redirect back to the featured shelf' unless move_down['location'].end_with?('?featured=1')
move_down_cookie = cookie_from(move_down)
cookie = move_down_cookie unless move_down_cookie.empty?
# Post#move_lower (lib/prism/orm/list_position.rb#lower_item) queries
# list_scope_relation.where(position: { gt: pos }) - list_scope_relation
# is already narrowed by .where(featured: true), so this is exactly an
# equality-prefix-plus-range condition. Before the composite
# [featured, position] index (db/migrate/20260908000001_...), Store had
# no way to answer that without either a full scan or, at best, a plain
# range index on position alone that would have ignored the featured
# filter and returned candidates from BOTH shelves for Relation#rows to
# re-filter in memory - checking the real log line here proves the
# composite index (not just the synthetic CountingIndexStore fixtures in
# test/test_orm_index.rb) is what a real scoped reorder actually hits.
move_down_log_lines = File.read(log_path)[move_down_log_offset..].to_s
unless move_down_log_lines.include?('operation="SELECT INDEX RANGE"') && move_down_log_lines.include?('table="posts"')
raise 'expected Post#move_lower (scoped by featured) to hit the composite posts index instead of a full table scan'
end
unless move_down_log_lines.include?('columns=["featured", "position"]')
raise 'expected the composite index lookup to be reported as covering featured and position together'
end
puts ' POST .../move_down -> sql.prism log shows operation="SELECT INDEX RANGE" table="posts" ' \
'columns=["featured", "position"] - the scoped acts_as_list neighbor lookup used the composite index, zero full scan'
# The old ETag must NOT satisfy If-None-Match anymore - see the etag
# components fix in PostsController#index (position now rides along).
# A stale 304 here would mean the shelf visibly reordered on the very
# next real GET but a conditional client kept showing the old order.
stale_conditional = http.get('/posts?featured=1', 'Cookie' => cookie, 'If-None-Match' => etag_before_reorder)
raise "expected the reorder to invalidate the old ETag (200, not 304), got #{stale_conditional.code}" \
unless stale_conditional.code == '200'
shelf_after = http.get('/posts?featured=1', 'Cookie' => cookie)
order_after = [featured_slug, second_featured_slug, third_featured_slug].sort_by { |slug| shelf_after.body.index("id=\"post-#{slug}\"") }
raise "expected #{featured_slug} to swap with #{second_featured_slug}, got #{order_after.inspect}" \
unless order_after == [second_featured_slug, featured_slug, third_featured_slug]
puts " #{etag_before_reorder} rejected with a fresh 200 after the reorder, new shelf order #{order_after.inspect} " \
"(#{featured_slug} swapped down with #{second_featured_slug}, #{third_featured_slug} untouched)"
reordered_positions = Post.where(slug: [featured_slug, second_featured_slug, third_featured_slug]).to_a
.sort_by(&:position).map(&:slug)
raise "expected the ORM's own position column to agree with the rendered order, got #{reordered_positions.inspect}" \
unless reordered_positions == order_after
puts " Post.where(...).sort_by(&:position) -> #{reordered_positions.inspect} (ORM position column, not just markup order)"
# The shelf leader has nothing above it - PostsController#move_up still
# accepts the request, but Post#move_higher (see lib/prism/orm/list_position.rb)
# is a documented no-op with nobody to swap with, through a real HTTP
# round trip rather than only the unit test's in-process call.
noop_move_up_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
noop_move_up = http.post("/posts/#{second_featured_slug}/move_up", noop_move_up_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the move_up action to redirect, got #{noop_move_up.code}" unless noop_move_up.code == '302'
noop_move_up_cookie = cookie_from(noop_move_up)
cookie = noop_move_up_cookie unless noop_move_up_cookie.empty?
shelf_still = http.get('/posts?featured=1', 'Cookie' => cookie)
order_still = [featured_slug, second_featured_slug, third_featured_slug].sort_by { |slug| shelf_still.body.index("id=\"post-#{slug}\"") }
raise "expected move_up on the shelf leader to be a no-op, got #{order_still.inspect}" unless order_still == order_after
puts " POST /posts/#{second_featured_slug}/move_up (already first) -> order unchanged, #{order_still.inspect}"
puts '-' * 40
puts "ownership check: a second user may not delete somebody else's post"
other_signup_body = URI.encode_www_form(
'user[email]' => 'reader2@example.com', 'user[password]' => 'another passphrase',
'user[password_confirmation]' => 'another passphrase', 'authenticity_token' => token
)
# .json instead of the usual form post here doubles as the demo of
# UsersController#create's JSON branch: sign_in (and so the Set-Cookie
# this scenario depends on to become "reader2" below) happens exactly
# the same regardless of format, only the response body/status differ.
other_signup = http.post('/users.json', other_signup_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected the JSON sign-up to return 201, got #{other_signup.code}" unless other_signup.code == '201'
other_signup_json = JSON.parse(other_signup.body)
raise 'JSON sign-up response should carry the new user id/email' unless other_signup_json['email'] == 'reader2@example.com' && other_signup_json['id']
raise 'JSON sign-up response must never leak password_digest' if other_signup_json.key?('password_digest')
puts "POST /users.json (sign up) -> #{other_signup.code}, id=#{other_signup_json['id']}, no password_digest in the body"
fresh_cookie = cookie_from(other_signup)
cookie = fresh_cookie unless fresh_cookie.empty?
puts 'draining the background job queue again (User after_commit(on: :create) now also queues a welcome email ' \
'AND a confirmation-instructions email - two separate after_commit(on: :create) hooks, see User)'
deliveries_before_welcome = Prism::Mailer.deliveries.size
processed_after_signup = worker.work_off
puts "worker processed #{processed_after_signup} job(s); Prism::Mailer.deliveries=#{Prism::Mailer.deliveries.size}"
raise 'expected new emails to have been delivered' unless Prism::Mailer.deliveries.size > deliveries_before_welcome
# A handful of OTHER posts were created since the last drain (each queues
# its own new_post notification to editors@example.com), so this batch is
# not just reader2's 2 signup emails - filter down to reader2's own mail
# rather than assuming an exact count or a fixed position among them.
new_deliveries_after_signup = Prism::Mailer.deliveries[deliveries_before_welcome..].select { |d| d.to == ['reader2@example.com'] }
raise "expected exactly 2 emails to reader2 (welcome + confirmation instructions), got #{new_deliveries_after_signup.map(&:subject)}" \
unless new_deliveries_after_signup.size == 2
welcome_delivery = new_deliveries_after_signup.find { |d| d.subject.include?('Welcome') }
confirmation_delivery = new_deliveries_after_signup.find { |d| d.subject.include?('Confirm your') }
raise 'expected a welcome email among the 2 just delivered to reader2' unless welcome_delivery
raise 'expected a confirmation-instructions email among the 2 just delivered to reader2' unless confirmation_delivery
puts "delivery: to=#{welcome_delivery.to.first} subject=#{welcome_delivery.subject.inspect}"
puts "delivery: to=#{confirmation_delivery.to.first} subject=#{confirmation_delivery.subject.inspect}"
welcome_preview = http.get('/rails/mailers/notification_mailer/welcome')
puts "GET /rails/mailers/notification_mailer/welcome -> #{welcome_preview.code}"
raise 'welcome mailer preview did not render the welcome subject' unless welcome_preview.body.include?('Welcome to Prism Blog')
forbidden_delete_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
forbidden_attempt = http.post(post_path, forbidden_delete_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{post_path} with _method=DELETE as a DIFFERENT user -> #{forbidden_attempt.code} " \
"Location=#{forbidden_attempt['location']}"
unless forbidden_attempt.code == '302' && forbidden_attempt['location'] == post_path
raise "expected the second user's delete attempt to be redirected back to the post, got " \
"#{forbidden_attempt.code} Location=#{forbidden_attempt['location']}"
end
forbidden_edit_page = http.get("#{post_path}/edit", 'Cookie' => cookie)
puts "GET #{post_path}/edit as a DIFFERENT user -> #{forbidden_edit_page.code} Location=#{forbidden_edit_page['location']}"
unless forbidden_edit_page.code == '302' && forbidden_edit_page['location'] == post_path
raise "expected the second user's edit attempt to be redirected back to the post, got " \
"#{forbidden_edit_page.code} Location=#{forbidden_edit_page['location']}"
end
forbidden_comment_edit = http.get("#{comment_edit_path}/edit", 'Cookie' => cookie)
puts "GET #{comment_edit_path}/edit as a DIFFERENT user -> #{forbidden_comment_edit.code} " \
"Location=#{forbidden_comment_edit['location']}"
unless forbidden_comment_edit.code == '302' && forbidden_comment_edit['location'] == post_path
raise "expected the second user's comment-edit attempt to be redirected back to the post, got " \
"#{forbidden_comment_edit.code} Location=#{forbidden_comment_edit['location']}"
end
forbidden_comment_update_body = URI.encode_www_form('comment[body]' => 'hijacked', '_method' => 'PATCH',
'authenticity_token' => token)
forbidden_comment_update = http.post(comment_edit_path, forbidden_comment_update_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{comment_edit_path} with _method=PATCH as a DIFFERENT user -> #{forbidden_comment_update.code} " \
"Location=#{forbidden_comment_update['location']}"
unless forbidden_comment_update.code == '302' && forbidden_comment_update['location'] == post_path
raise "expected the second user's comment-update attempt to be redirected back to the post, got " \
"#{forbidden_comment_update.code} Location=#{forbidden_comment_update['location']}"
end
still_owned_comment = http.get(post_path, 'Cookie' => cookie)
raise 'the comment must not be hijacked by an unauthorized update attempt' if still_owned_comment.body.include?('hijacked')
raise 'the comment should still show its previous edit' unless still_owned_comment.body.include?('Nice post, tab A won!')
puts "GET #{post_path} -> comment survives, still owned by its original author"
still_there = http.get(post_path, 'Cookie' => cookie)
raise 'the post must survive an unauthorized delete attempt by another user' unless still_there.code == '200'
raise 'the post content should still be there after the forbidden attempt' unless still_there.body.include?('Hello from Prism')
puts "GET #{post_path} -> #{still_there.code} (post survives, still owned by its author)"
puts '-' * 40
puts "comment moderation visibility: reader2 (still signed in) posts a brand new comment, which " \
'defaults to pending and so is invisible to a stranger until the post owner approves it'
pending_comment_body = URI.encode_www_form(
'comment[author]' => 'Reader2', 'comment[body]' => 'A brand new pending remark', 'authenticity_token' => token
)
pending_comment = http.post("/posts/#{post_id}/comments", pending_comment_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
raise "expected reader2's new comment to redirect, got #{pending_comment.code}" unless pending_comment.code == '302'
pending_comment_cookie = cookie_from(pending_comment)
cookie = pending_comment_cookie unless pending_comment_cookie.empty?
puts "POST /posts/#{post_id}/comments as reader2 -> #{pending_comment.code} (status defaults to pending)"
own_pending_view = http.get(post_path, 'Cookie' => cookie)
unless own_pending_view.body.include?('A brand new pending remark') && own_pending_view.body.include?('Pending approval')
raise "a comment's own author should see it, badge and all, while it's still pending"
end
# posts#show orders comments by created_at desc, so reader2's brand new
# comment - the most recent thing on this page - is the first <li>.
pending_comment_id = own_pending_view.body[/id="comment-(\d+)"/, 1]
raise "could not find reader2's new comment id on the post page" unless pending_comment_id
puts "GET #{post_path} as reader2 -> sees own pending comment with its 'Pending approval' badge"
stranger_view = http.get(post_path)
if stranger_view.body.include?('A brand new pending remark')
raise 'a signed-out stranger must not see a pending comment that is neither theirs nor on their own post'
end
puts "GET #{post_path} signed out (no cookie at all) -> pending comment from another user stays hidden " \
'(Comment.visible_to)'
sign_back_in_body = URI.encode_www_form(
'user[email]' => 'writer@example.com', 'user[password]' => 'correct horse battery staple',
'user[remember_me]' => '1', 'authenticity_token' => token
)
# .json demonstrates SessionController#create's JSON branch (200 + the
# signed-in user, no redirect) instead of the usual 302 - same single
# slot spent against the logins/ip throttle either way (see the
# hammering loop below), just a different response shape. remember_me=1
# also exercises Prism::Authenticatable's persistent-login cookie, now
# backed by remember_sessions: LoginSession (multi-device, one row per
# browser) instead of a single shared remember_cookie: column - this is
# "device A" in the multi-device scene below.
sign_back_in = http.post('/session.json', sign_back_in_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie,
'User-Agent' => 'DeviceA-Desktop/1.0')
raise "expected the original author to sign back in via JSON, got #{sign_back_in.code}" unless sign_back_in.code == '200'
sign_back_in_json = JSON.parse(sign_back_in.body)
raise 'JSON sign-in response should carry the signed-in user email' unless sign_back_in_json['email'] == 'writer@example.com'
puts "POST /session.json (sign in, remember_me=1) -> #{sign_back_in.code}, signed in as #{sign_back_in_json['email']} (device A)"
fresh_cookie = cookie_from(sign_back_in)
cookie = fresh_cookie unless fresh_cookie.empty?
remember_session_token_a = cookie_value_from(sign_back_in, 'remember_session_token')
raise 'expected sign_in(remember: true) to set a remember_session_token cookie' unless remember_session_token_a
puts " Set-Cookie also carried #{remember_session_token_a[/\A[^=]+/]}=... (device A's own LoginSession row)"
# No session cookie at all here - simulates a brand new browser session
# (e.g. after closing the browser) that only still has the persistent
# cookie, proving Authenticatable#current_user really falls back to it
# instead of the fallback being dead code that only unit tests exercise.
remember_only_trash = http.get('/posts/trash', 'Cookie' => remember_session_token_a)
unless remember_only_trash.code == '200'
raise "expected the remember-me cookie alone to authenticate GET /posts/trash, got #{remember_only_trash.code}"
end
puts "GET /posts/trash using ONLY device A's remember-me cookie (no session cookie) -> 200"
unless cookie_value_from(remember_only_trash, 'prism.session')
raise 'expected falling back to the remember cookie to also repopulate a fresh session cookie'
end
puts ' response also carried a fresh prism.session cookie (fast path repopulated for the next request)'
puts '-' * 40
puts 'multi-device remember me: TWO more entirely independent browsers (own cookie jars, own CSRF tokens, ' \
'no cookie ever shared with device A) sign in as the SAME writer account with remember_me=1'
remember_session_token_b = sign_in_new_device(http, 'device B', user_agent: 'DeviceB-Mobile/2.0')
remember_session_token_c = sign_in_new_device(http, 'device C', user_agent: 'DeviceC-Tablet/3.0')
devices_page = http.get('/login_sessions', 'Cookie' => cookie)
raise "expected device A to load /login_sessions, got #{devices_page.code}" unless devices_page.code == '200'
other_ids = devices_page.body.scan(/class="login-session" id="login-session-(\d+)"/).flatten
unless other_ids.size == 2 && devices_page.body.include?('login-session current')
raise "expected device A's own row plus exactly 2 OTHER remembered devices (B and C), got #{other_ids.inspect}"
end
puts "GET /login_sessions as device A -> 200, lists itself (\"this device\") plus #{other_ids.size} other " \
'remembered devices (B and C) - Prism::Authenticatable#other_login_sessions'
%w[DeviceA-Desktop/1.0 DeviceB-Mobile/2.0 DeviceC-Tablet/3.0].each do |ua|
raise "expected /login_sessions to show \"#{ua}\" (Authenticatable's captured user_agent)" unless devices_page.body.include?(ua)
end
if devices_page.body.include?(Prism::I18n.t('login_sessions.unknown_user_agent'))
raise 'expected every device row to have a captured user_agent, none should fall back to "unknown browser"'
end
puts ' every row shows its own captured User-Agent (LoginSessionsController#device_detail_for) - none falls ' \
'back to "unknown browser", proving Authenticatable#remember_user_with_session really persisted it, not ' \
'just accepted the column silently'
revoke_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
revoke_one = http.post("/login_sessions/#{other_ids.first}", revoke_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless revoke_one.code == '302' && revoke_one['location'].to_s.end_with?('/login_sessions')
raise "expected device A revoking one other device to redirect back to /login_sessions, got #{revoke_one.code}"
end
puts "POST /login_sessions/#{other_ids.first} with _method=DELETE (device A revokes ONE other device) -> " \
"#{revoke_one.code}"
b_survives = http.get('/posts/trash', 'Cookie' => remember_session_token_b).code == '200'
c_survives = http.get('/posts/trash', 'Cookie' => remember_session_token_c).code == '200'
unless [b_survives, c_survives].count(true) == 1
raise "expected revoking exactly one LoginSession row to break exactly one of B/C's remember cookies, " \
"got device B survives=#{b_survives} device C survives=#{c_survives}"
end
survivor_cookie = b_survives ? remember_session_token_b : remember_session_token_c
puts ' the revoked device\'s remember-me cookie is now dead (falls through to sign-in), the untouched ' \
'device\'s cookie still authenticates - per-device revocation, unlike sign_out rotating one shared column'
sign_out_others = http.post('/login_sessions/sign_out_others',
URI.encode_www_form('authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless sign_out_others.code == '302' && sign_out_others['location'].to_s.end_with?('/login_sessions')
raise "expected sign_out_all_other_sessions to redirect back to /login_sessions, got #{sign_out_others.code}"
end
puts "POST /login_sessions/sign_out_others as device A -> #{sign_out_others.code} " \
'(Authenticatable#sign_out_all_other_sessions revokes every remaining OTHER device in one call)'
survivor_after_bulk = http.get('/posts/trash', 'Cookie' => survivor_cookie)
unless survivor_after_bulk.code == '302' && survivor_after_bulk['location'].to_s.end_with?('/session/new')
raise "expected the last surviving other device to be signed out after sign_out_others, got #{survivor_after_bulk.code}"
end
puts ' the last surviving OTHER device is now signed out too -> 302 to sign-in'
device_a_still_works = http.get('/posts/trash', 'Cookie' => remember_session_token_a)
unless device_a_still_works.code == '200'
raise "expected device A's OWN remember cookie to survive sign_out_all_other_sessions, got #{device_a_still_works.code}"
end
puts " device A's OWN remember-me cookie still works -> 200 (sign_out_all_other_sessions never touches the " \
'caller\'s own session)'
moderator_view = http.get(post_path, 'Cookie' => cookie)
unless moderator_view.body.include?('A brand new pending remark')
raise "the post's own author (moderator) should see a pending comment even before approving it"
end
puts "GET #{post_path} as the post's author -> sees reader2's still-pending comment (moderator sees " \
'everything on their own post, CommentPolicy#approve?/#reject? check the POST owner)'
puts '-' * 40
puts "checking AccountController#moderation (GET /account/moderation) - a cross-post moderation queue, " \
"gathering reader2's still-pending comment onto ONE page instead of the author having to open every " \
"post separately, and confirming add_index :posts, :user_id (db/migrate/20260907000009) plus the " \
"existing add_index :comments, :post_id (.../20260907000005) answer it without a single full table scan"
moderation_log_offset = File.size(log_path)
moderation_queue = http.get('/account/moderation', 'Cookie' => cookie)
raise "expected GET /account/moderation to succeed, got #{moderation_queue.code}" unless moderation_queue.code == '200'
unless moderation_queue.body.include?('A brand new pending remark') && moderation_queue.body.include?('Hello from Prism')
raise "expected the moderation queue to list reader2's pending comment alongside its post's title"
end
moderation_log_lines = File.read(log_path)[moderation_log_offset..].to_s
if moderation_log_lines.include?('operation="SELECT" table="posts"') || moderation_log_lines.include?('operation="SELECT" table="comments"')
raise 'expected AccountController#moderation (current_user.posts then Comment.pending.where(post_id: ...)) ' \
'to resolve entirely through indexes, not a full table scan'
end
unless moderation_log_lines.include?('operation="SELECT INDEX" table="posts" columns=["user_id"]')
raise 'expected current_user.posts (owned post ids) to hit the posts.user_id index'
end
unless moderation_log_lines.include?('operation="SELECT INDEX IN" table="comments" columns=["post_id"]')
raise 'expected Comment.pending.where(post_id: owned_post_ids) to hit the comments.post_id index via an ' \
'IN-list lookup, not a scan'
end
puts ' GET /account/moderation -> 200, sql.prism log shows SELECT INDEX on posts.user_id then SELECT INDEX ' \
'IN on comments.post_id - zero full scans across both index-backed steps'
reader2_forbidden_approve_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
reader2_forbidden_approve = http.post(
"/posts/#{post_id}/comments/#{pending_comment_id}/approve", reader2_forbidden_approve_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => pending_comment_cookie
)
unless reader2_forbidden_approve.code == '302' && reader2_forbidden_approve['location'] == post_path
raise 'a non-owner (even the comment author themself) must not be able to approve their own comment, got ' \
"#{reader2_forbidden_approve.code} Location=#{reader2_forbidden_approve['location']}"
end
puts "POST /posts/#{post_id}/comments/#{pending_comment_id}/approve as reader2 (not the post's author) -> " \
"#{reader2_forbidden_approve.code} (CommentPolicy#approve? denied it, forbidden redirect back to the post)"
approve_pending_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
approve_pending = http.post(
"/posts/#{post_id}/comments/#{pending_comment_id}/approve", approve_pending_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
raise "expected the moderator's approval to redirect, got #{approve_pending.code}" unless approve_pending.code == '302'
approve_pending_cookie = cookie_from(approve_pending)
cookie = approve_pending_cookie unless approve_pending_cookie.empty?
puts "POST /posts/#{post_id}/comments/#{pending_comment_id}/approve as the post's author -> #{approve_pending.code}"
stranger_view_after_approval = http.get(post_path)
unless stranger_view_after_approval.body.include?('A brand new pending remark')
raise "an approved comment should now be visible to a signed-out stranger too"
end
if stranger_view_after_approval.body.include?('Pending approval')
raise 'an approved comment must no longer carry the pending badge'
end
puts "GET #{post_path} signed out -> reader2's comment is now visible to everyone, no more pending badge"
moderation_queue_after_approval = http.get('/account/moderation', 'Cookie' => cookie)
raise "expected GET /account/moderation to succeed, got #{moderation_queue_after_approval.code}" unless moderation_queue_after_approval.code == '200'
if moderation_queue_after_approval.body.include?('A brand new pending remark')
raise 'an approved comment must no longer show up in the moderation queue'
end
puts 'GET /account/moderation after approval -> the now-approved comment is no longer listed'
puts '-' * 40
puts 'comment moderation, the other outcome: reader2 posts a second comment and the post\'s ' \
'author REJECTS it instead of approving it'
second_pending_body = URI.encode_www_form(
'comment[author]' => 'Reader2', 'comment[body]' => 'A remark that will get rejected', 'authenticity_token' => token
)
second_pending = http.post("/posts/#{post_id}/comments", second_pending_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => pending_comment_cookie)
raise "expected reader2's second comment to redirect, got #{second_pending.code}" unless second_pending.code == '302'
puts "POST /posts/#{post_id}/comments as reader2 -> #{second_pending.code} (status defaults to pending again)"
reader2_second_view = http.get(post_path, 'Cookie' => pending_comment_cookie)
unless reader2_second_view.body.include?('A remark that will get rejected') && reader2_second_view.body.include?('Pending approval')
raise "reader2 should see their own new comment pending, badge and all"
end
# posts#show orders comments by created_at desc, so this brand new
# comment is once again the first <li> on the page.
rejectable_comment_id = reader2_second_view.body[/id="comment-(\d+)"/, 1]
raise "could not find reader2's second comment id on the post page" unless rejectable_comment_id
reader2_forbidden_reject_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
reader2_forbidden_reject = http.post(
"/posts/#{post_id}/comments/#{rejectable_comment_id}/reject", reader2_forbidden_reject_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => pending_comment_cookie
)
unless reader2_forbidden_reject.code == '302' && reader2_forbidden_reject['location'] == post_path
raise 'a non-owner (even the comment author themself) must not be able to reject their own comment, got ' \
"#{reader2_forbidden_reject.code} Location=#{reader2_forbidden_reject['location']}"
end
puts "POST /posts/#{post_id}/comments/#{rejectable_comment_id}/reject as reader2 (not the post's author) -> " \
"#{reader2_forbidden_reject.code} (CommentPolicy#reject? denied it, forbidden redirect back to the post)"
reject_pending_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
reject_pending = http.post(
"/posts/#{post_id}/comments/#{rejectable_comment_id}/reject", reject_pending_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
raise "expected the moderator's rejection to redirect, got #{reject_pending.code}" unless reject_pending.code == '302'
reject_pending_cookie = cookie_from(reject_pending)
cookie = reject_pending_cookie unless reject_pending_cookie.empty?
puts "POST /posts/#{post_id}/comments/#{rejectable_comment_id}/reject as the post's author -> #{reject_pending.code}"
stranger_view_after_rejection = http.get(post_path)
if stranger_view_after_rejection.body.include?('A remark that will get rejected')
raise 'a rejected comment must stay invisible to a signed-out stranger, same as a pending one'
end
puts "GET #{post_path} signed out -> the rejected comment stays hidden (Comment.visible_to only " \
'admits approved comments or the viewer\'s own)'
author_view_after_rejection = http.get(post_path, 'Cookie' => pending_comment_cookie)
unless author_view_after_rejection.body.include?('A remark that will get rejected') &&
author_view_after_rejection.body.include?('Rejected')
raise "the rejected comment's own author should still see it, now carrying the \"Rejected\" badge"
end
puts "GET #{post_path} as reader2 (the comment's own author) -> sees their own rejected comment, " \
'badge and all (Comment.visible_to admits their own regardless of status)'
moderator_view_after_rejection = http.get(post_path, 'Cookie' => cookie)
reject_path = "/posts/#{post_id}/comments/#{rejectable_comment_id}/reject"
approve_path = "/posts/#{post_id}/comments/#{rejectable_comment_id}/approve"
raise 'a rejected comment must no longer offer the moderator a Reject button' if moderator_view_after_rejection.body.include?(reject_path)
unless moderator_view_after_rejection.body.include?(approve_path)
raise 'a rejected comment should still offer the moderator an Approve button (it can still be reconsidered)'
end
puts "GET #{post_path} as the post's author -> the Reject button for this comment is gone, Approve is still " \
"offered (Comment#rejected? flips exactly one of the two buttons)"
delete_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
destroy = http.post(post_path, delete_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST #{post_path} with _method=DELETE -> #{destroy.code} Location=#{destroy['location']}"
gone = http.get(post_path, 'Cookie' => cookie)
puts "GET #{post_path} after delete -> #{gone.code} (expected 404, dependent comment cascaded away too)"
raise 'post should be gone after destroy' unless gone.code == '404'
# Post.acts_as_paranoid: #destroy above only stamped deleted_at, it did
# not remove the row (or, thanks to Post#remove_cover_file's
# soft_deleting? guard, the uploaded cover file) - both are still there
# for #restore below to bring back, unlike a plain hard delete.
raise "soft-deleted post's cover file should NOT have been removed: #{cover_file}" unless File.exist?(cover_file)
puts "soft-deleted post kept its cover file on disk (#{File.basename(cover_file)}), only stamped deleted_at"
# Comment and Like are acts_as_paranoid too now, so the dependent: :destroy
# cascades above soft-deleted them along with the post instead of removing
# them for real - hidden from the default scope exactly like a trashed
# post is, but still sitting in only_deleted for #restore(recursive: true)
# below to bring back. post_row_id (resolved once, way up where post_id
# itself was captured) is still valid here - a post's own numeric row id
# never changes across its own trash/restore cycle.
unless Like.where(likeable_type: 'Post', likeable_id: post_row_id).count.zero?
raise "trashing the post should have hidden its own Like row from the default scope"
end
unless Like.only_deleted.where(likeable_type: 'Post', likeable_id: post_row_id).count == 1
raise "trashing the post should have soft-deleted (not removed) its own Like row"
end
unless Comment.where(id: comment_id.to_i).count.zero?
raise "trashing the post should have hidden its cascaded comment from the default scope"
end
unless Comment.only_deleted.where(id: comment_id.to_i).count == 1
raise "trashing the post should have soft-deleted (not removed) its cascaded comment"
end
unless Like.only_deleted.where(likeable_type: 'Comment', likeable_id: comment_id.to_i).count == 1
raise "trashing the post should have recursively soft-deleted its cascaded comment's own Like too"
end
puts 'Like/Comment rows under the trashed post are hidden but not gone (only_deleted still finds them, ' \
'polymorphic dependent: :destroy through a soft-delete)'
trash_page = http.get('/posts/trash', 'Cookie' => cookie)
unless trash_page.code == '200' && trash_page.body.include?("id=\"post-#{post_id}\"")
raise "expected the post's own author to see it in their trash, got #{trash_page.code}"
end
puts "GET /posts/trash as the post's author -> 200, trashed post listed"
restore_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
restore = http.post("/posts/#{post_id}/restore", restore_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless restore.code == '302' && restore['location'].to_s.end_with?('/posts/trash')
raise "expected restore to redirect back to the trash, got #{restore.code} Location=#{restore['location']}"
end
puts "POST /posts/#{post_id}/restore with _method=PATCH -> #{restore.code} Location=#{restore['location']}"
restored = http.get(post_path, 'Cookie' => cookie)
raise "restored post should be reachable again, got #{restored.code}" unless restored.code == '200'
raise 'restored post should still reference its original cover file' unless restored.body.include?(File.basename(cover_file))
puts "GET #{post_path} after restore -> 200, original cover still referenced"
trash_page_after_restore = http.get('/posts/trash', 'Cookie' => cookie)
if trash_page_after_restore.body.include?("id=\"post-#{post_id}\"")
raise 'a restored post must no longer show up in the trash'
end
puts 'GET /posts/trash after restore -> the restored post is no longer listed'
# PostsController#restore calls post.restore(recursive: true) - the post's
# own Like, its cascaded-away Comment, AND that comment's own Like (a
# grandchild) should all have come back with it, not just the post row.
unless Comment.where(id: comment_id.to_i).count == 1
raise 'restore(recursive: true) should have brought the cascaded comment back too'
end
unless Like.where(likeable_type: 'Post', likeable_id: post_row_id).count == 1
raise "restore(recursive: true) should have brought the post's own Like back too"
end
unless Like.where(likeable_type: 'Comment', likeable_id: comment_id.to_i).count == 1
raise "restore(recursive: true) should have recursively restored the comment's own Like too"
end
raise 'restoring a comment should have re-incremented comments_count' unless Post.find_by!(slug: post_id).comments_count.positive?
unless Post.find_by!(slug: post_id).likes_count == 1
raise "Post#after_restore counter_cache hook should have re-incremented likes_count for the post's own Like"
end
unless Comment.find(comment_id).likes_count == 1
raise "cascaded Comment#after_restore counter_cache hook should have re-incremented its own likes_count too"
end
puts 'restore(recursive: true) brought the comment AND both Likes (post-level and comment-level) back ' \
'(Associable#cascade_restore_dependent_associations recursing two levels deep, counter_cache re-synced)'
puts '-' * 40
puts 'deleting and restoring ONE comment on its own, independent of its (live, not trashed) post - ' \
'CommentsController#trash/#restore, a flat top-level page (not nested under /posts/:post_id)'
comments_count_before_solo_delete = Post.find_by!(slug: post_id).comments_count
solo_delete_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
solo_delete = http.post(comment_edit_path, solo_delete_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless solo_delete.code == '302' && solo_delete['location'] == post_path
raise "expected the solo comment delete to redirect back to the post, got #{solo_delete.code}"
end
puts "POST #{comment_edit_path} with _method=DELETE -> #{solo_delete.code} (soft-deleted, Comment#acts_as_paranoid)"
post_after_solo_delete = http.get(post_path, 'Cookie' => cookie)
raise 'the post itself must stay reachable when only one of its comments is deleted' unless post_after_solo_delete.code == '200'
if post_after_solo_delete.body.include?("id=\"comment-#{comment_id}\"")
raise 'the soft-deleted comment should no longer show up on its (still live) post'
end
unless Post.find_by!(slug: post_id).comments_count == comments_count_before_solo_delete - 1
raise 'deleting one comment should decrement its post comments_count, independent of any post-level trash'
end
puts "GET #{post_path} -> post still live (200), the deleted comment is gone from it, comments_count decremented " \
'(nothing about the POST changed - Post itself was never touched)'
unless Like.where(likeable_type: 'Comment', likeable_id: comment_id.to_i).count.zero?
raise "deleting the comment should have cascaded (soft-deleted) its own Like via dependent: :destroy"
end
unless Like.only_deleted.where(likeable_type: 'Comment', likeable_id: comment_id.to_i).count == 1
raise "the comment's cascaded Like should be soft-deleted (only_deleted), not gone for good"
end
comments_trash_page = http.get('/comments/trash', 'Cookie' => cookie)
unless comments_trash_page.code == '200' && comments_trash_page.body.include?("id=\"comment-#{comment_id}\"")
raise "expected the comment author's own trash to list it, got #{comments_trash_page.code}"
end
unless comments_trash_page.body.include?('Hello from Prism')
raise "the comments trash page should label each row with its (still live) post's title"
end
puts "GET /comments/trash -> 200, deleted comment listed with a link back to its still-live post"
solo_restore_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
solo_restore = http.post("/comments/#{comment_id}/restore", solo_restore_body,
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
unless solo_restore.code == '302' && solo_restore['location'].to_s.end_with?('/comments/trash')
raise "expected the solo comment restore to redirect back to /comments/trash, got #{solo_restore.code}"
end
puts "POST /comments/#{comment_id}/restore with _method=PATCH -> #{solo_restore.code} Location=#{solo_restore['location']}"
post_after_solo_restore = http.get(post_path, 'Cookie' => cookie)
unless post_after_solo_restore.body.include?("id=\"comment-#{comment_id}\"")
raise 'the restored comment should be back on its post'
end
unless Post.find_by!(slug: post_id).comments_count == comments_count_before_solo_delete
raise 'restoring the comment should re-increment comments_count back to what it was'
end
unless Like.where(likeable_type: 'Comment', likeable_id: comment_id.to_i).count == 1
raise "restore(recursive: true) inside CommentsController#restore should have brought the comment's own Like back too"
end
puts "GET #{post_path} -> the restored comment (and its Like) are back, comments_count restored"
comments_trash_after_restore = http.get('/comments/trash', 'Cookie' => cookie)
if comments_trash_after_restore.body.include?("id=\"comment-#{comment_id}\"")
raise 'a restored comment must no longer show up in /comments/trash'
end
puts 'GET /comments/trash after restore -> the restored comment is no longer listed'
# AccountController#comments ("Your comments" nav link) - current_user.
# comments is the one call site in this app where Comment.where(user_id:
# ...) is NOT already chained off post.comments first, so it is the only
# place the comments.user_id secondary index (added this session) gets
# picked over the pre-existing comments.post_id one for the same query.
# comment_id is one of this user's OLDEST comments (created near the very
# start of this whole demo run) and the listing orders newest-first, same
# as every other activity feed in this app, so it lands on the LAST page
# rather than page 1 once this account has commented more than 10 times.
owner_id = Comment.find(comment_id.to_i).user_id
my_comments_last_page = (Comment.where(user_id: owner_id).count / 10.0).ceil
my_comments_page = http.get("/account/comments?page=#{my_comments_last_page}", 'Cookie' => cookie)
unless my_comments_page.code == '200' && my_comments_page.body.include?("id=\"comment-#{comment_id}\"")
raise "expected /account/comments (page #{my_comments_last_page}) to list the signed-in user's own " \
"restored comment, got #{my_comments_page.code}"
end
unless my_comments_page.body.include?('Hello from Prism')
raise "the account comments page should label each row with its post's title, same as /comments/trash"
end
puts "GET /account/comments -> 200, the signed-in user's own comment listed with its post's title"
puts '-' * 40
# _method=DELETE makes Request#request_method report DELETE even though
# this is a real HTTP POST, so this call never touches the logins/ip
# throttle (that discriminator checks for POST specifically) regardless
# of the .json extension exercising SessionController#destroy's JSON branch.
logout_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
logout = http.post('/session.json', logout_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
puts "POST /session.json with _method=DELETE (sign out) -> #{logout.code}"
raise "expected JSON sign-out to return 200, got #{logout.code}" unless logout.code == '200'
raise 'JSON sign-out response should carry a message' unless JSON.parse(logout.body)['message']
signed_out_cookie = cookie_from(logout)
cookie = signed_out_cookie unless signed_out_cookie.empty?
# Authenticatable#sign_out (remember_sessions: variant) destroys THIS
# device's own LoginSession row, so device A's OLD remember-me cookie
# captured earlier must stop working now, not remain valid until
# whatever calendar date it expires.
stale_remember_attempt = http.get('/posts/trash', 'Cookie' => remember_session_token_a)
unless stale_remember_attempt.code == '302' && stale_remember_attempt['location'].to_s.end_with?('/session/new')
raise "expected the OLD remember-me cookie to stop working after sign-out, got #{stale_remember_attempt.code}"
end
puts "GET /posts/trash with device A's now-stale remember-me cookie after sign-out -> " \
"#{stale_remember_attempt.code} (sign_out destroyed its LoginSession row, so a captured old cookie is dead)"
blocked_again = http.post(
'/posts', URI.encode_www_form('post[title]' => 'still blocked', 'authenticity_token' => token),
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie
)
puts "POST /posts after sign out -> #{blocked_again.code} Location=#{blocked_again['location']}"
unless blocked_again.code == '302' && blocked_again['location'].to_s.end_with?('/session/new')
raise "expected post#create to require authentication again after sign-out, got #{blocked_again.code}"
end
puts '-' * 40
puts 'hammering POST /session with bad credentials to exercise Prism::Middleware::RateLimiting'
login_attempt_body = URI.encode_www_form(
'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)'