diff --git a/blog_demo.rb b/blog_demo.rb index ebda04b..cd38490 100644 --- a/blog_demo.rb +++ b/blog_demo.rb @@ -337,6 +337,179 @@ Net::HTTP.start('127.0.0.1', port) do |http| 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) end server.stop