diff --git a/test/test_controller_security.rb b/test/test_controller_security.rb new file mode 100644 index 0000000..aeed5db --- /dev/null +++ b/test/test_controller_security.rb @@ -0,0 +1,118 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class NotesController < Prism::Controller + protect_from_forgery except: [:token] + + def self.received_log + @received_log ||= [] + end + + def token + html(csrf_token) + end + + def create + self.class.received_log << params['title'] + halt(201, JSON.generate(ok: true)) + end + + def write_cookie + cookies.signed[:uid] = 42 + cookies.encrypted[:secret_note] = 'top secret' + halt(200, 'stored') + end + + def read_cookie + halt(200, JSON.generate(uid: cookies.signed[:uid], note: cookies.encrypted[:secret_note])) + end + + def read_raw_cookie + halt(200, JSON.generate(raw: cookies['uid'])) + end +end + +class SecureNotesApp < Prism::Application + enable_sessions(secret: 'notes-secret') + + get '/notes/token', to: 'notes#token' + post '/notes', to: 'notes#create' + get '/notes/cookie', to: 'notes#write_cookie' + get '/notes/read_cookie', to: 'notes#read_cookie' + get '/notes/read_raw_cookie', to: 'notes#read_raw_cookie' +end + +class ControllerSecurityTest < Minitest::Test + include TestSupport + + def setup + NotesController.received_log.clear + end + + def call(method, path, **opts) + SecureNotesApp.call(env_for(method, path, **opts)) + end + + def session_cookie_from(headers) + line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?('prism.session=') } + line.split(';').first + end + + def test_post_without_authenticity_token_is_rejected + status, = call('POST', '/notes', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'title=hi') + assert_equal 422, status + assert_equal [], NotesController.received_log + end + + def test_post_with_valid_authenticity_token_is_accepted + _, headers, body = call('GET', '/notes/token') + token = body.join + cookie = session_cookie_from(headers) + + status, = call('POST', '/notes', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + cookies: cookie, + body: "title=hi&authenticity_token=#{CGI.escape(token)}") + assert_equal 201, status + assert_equal ['hi'], NotesController.received_log + end + + def test_post_with_token_from_a_different_session_is_rejected + _, other_headers, other_body = call('GET', '/notes/token') + other_token = other_body.join + refute_nil session_cookie_from(other_headers) + + _, first_headers, = call('GET', '/notes/token') + first_cookie = session_cookie_from(first_headers) + + status, = call('POST', '/notes', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + cookies: first_cookie, + body: "title=hi&authenticity_token=#{CGI.escape(other_token)}") + assert_equal 422, status + end + + def test_signed_and_encrypted_cookies_round_trip_through_a_real_request_cycle + _, write_headers, = call('GET', '/notes/cookie') + lines = write_headers['Set-Cookie'].split("\n") + cookie_header = lines.map { |line| line.split(';').first }.join('; ') + + _, _, body = call('GET', '/notes/read_cookie', cookies: cookie_header) + parsed = JSON.parse(body.join) + assert_equal 42, parsed['uid'] + assert_equal 'top secret', parsed['note'] + end + + def test_signed_cookie_value_is_not_stored_in_plaintext + _, write_headers, = call('GET', '/notes/cookie') + lines = write_headers['Set-Cookie'].split("\n") + cookie_header = lines.map { |line| line.split(';').first }.join('; ') + + _, _, body = call('GET', '/notes/read_raw_cookie', cookies: cookie_header) + raw = JSON.parse(body.join)['raw'] + refute_nil raw + refute_equal '42', raw + end +end diff --git a/test/test_controller_streaming.rb b/test/test_controller_streaming.rb new file mode 100644 index 0000000..4ba8e7c --- /dev/null +++ b/test/test_controller_streaming.rb @@ -0,0 +1,83 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class StreamingExportController < Prism::Controller + after_action :log_after + + def self.after_log + @after_log ||= [] + end + + def export + headers('Content-Type' => 'text/csv') + stream do |out| + out.write("id,title\n") + %w[one two three].each_with_index do |title, index| + out.write("#{index + 1},#{title}\n") + end + end + end + + def plain + json(ok: true) + end + + private + + def log_after + self.class.after_log << action_name + end +end + +class StreamingApp < Prism::Application + get '/export', to: 'streaming_export#export' + get '/plain', to: 'streaming_export#plain' +end + +class ControllerStreamingTest < Minitest::Test + include TestSupport + + def setup + StreamingExportController.after_log.clear + end + + def call(env) + StreamingApp.call(env) + end + + def test_streaming_action_returns_a_streaming_body + _status, _headers, body = call(env_for(:get, '/export')) + assert_instance_of Prism::StreamingBody, body + end + + def test_streaming_action_produces_chunks_in_order_when_drained + _status, _headers, body = call(env_for(:get, '/export')) + collected = [] + body.each { |chunk| collected << chunk } + assert_equal ["id,title\n", "1,one\n", "2,two\n", "3,three\n"], collected + end + + def test_streaming_action_keeps_status_and_explicit_headers + status, headers, _body = call(env_for(:get, '/export')) + assert_equal 200, status + assert_equal 'text/csv', headers['Content-Type'] + end + + # Regression guard: finalize_body used to call `@response.body.empty?` + # unconditionally after every action, which raises NoMethodError against + # a StreamingBody (it deliberately has no #empty?, since a producer + # block's total chunk count is not known up front). + def test_after_actions_still_run_for_a_streaming_action + call(env_for(:get, '/export')) + assert_equal ['export'], StreamingExportController.after_log + end + + # Non-streaming actions on the same controller are unaffected by the + # streaming? guard added to finalize_body. + def test_ordinary_action_on_the_same_controller_is_unaffected + status, _headers, body = call(env_for(:get, '/plain')) + assert_equal 200, status + assert_equal ['{"ok":true}'], body + end +end