diff --git a/test/test_rate_limiter_pstore_store.rb b/test/test_rate_limiter_pstore_store.rb new file mode 100644 index 0000000..7dca2dc --- /dev/null +++ b/test/test_rate_limiter_pstore_store.rb @@ -0,0 +1,96 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class RateLimiterPStoreStoreTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-rate-limiter') + @path = File.join(@dir, 'nested', 'rate_limits.pstore') + @store = Prism::RateLimiter::PStoreStore.new(@path) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_creates_parent_directories + assert File.directory?(File.dirname(@path)) + end + + def test_count_increments_within_the_same_window + assert_equal 1, @store.count('ip:1', 60) + assert_equal 2, @store.count('ip:1', 60) + end + + def test_count_resets_after_the_window_elapses + assert_equal 1, @store.count('ip:1', 0.05) + sleep 0.08 + assert_equal 1, @store.count('ip:1', 0.05) + end + + def test_ban_makes_banned_true_until_it_expires + @store.ban!('ip:1', 0.05) + assert @store.banned?('ip:1') + sleep 0.08 + refute @store.banned?('ip:1') + end + + def test_state_survives_reopening_the_same_file + @store.count('ip:1', 60) + @store.count('ip:1', 60) + @store.ban!('ip:2', 60) + + reopened = Prism::RateLimiter::PStoreStore.new(@path) + assert_equal 3, reopened.count('ip:1', 60) + assert reopened.banned?('ip:2') + end + + def test_reset_clears_windows_and_bans + @store.count('ip:1', 60) + @store.ban!('ip:2', 60) + @store.reset! + assert_equal 1, @store.count('ip:1', 60) + refute @store.banned?('ip:2') + end + + # Real cross-process race: #count reads the current window, bumps it, and + # writes it back inside a single PStore#transaction, which flocks the file + # for the duration. If that were split into separate read/write steps (the + # exact bug found and fixed in Jobs::Queue#claim_next! two sessions ago), + # several OS processes hammering the same key would stomp on each other's + # increments and the final count would fall short of the true total. + def test_count_is_safe_across_real_concurrent_processes + store_path = File.join(@dir, 'shared.pstore') + process_count = 4 + increments_per_process = 25 + + counter_script = File.join(@dir, 'counter.rb') + File.write(counter_script, <<~RUBY) + require 'prism' + store = Prism::RateLimiter::PStoreStore.new(#{store_path.inspect}) + results = Array.new(#{increments_per_process}) { store.count('shared-key', 3600) } + File.write(File.join(#{@dir.inspect}, "\#{Process.pid}.result"), results.join(',')) + RUBY + + pids = process_count.times.map do + Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), counter_script, + out: File::NULL, err: File::NULL) + end + pids.each { |pid| Process.waitpid(pid) } + + observed = pids.flat_map do |pid| + path = File.join(@dir, "#{pid}.result") + File.read(path).split(',').map(&:to_i) + end + + expected_total = process_count * increments_per_process + assert_equal expected_total, observed.size + assert_equal (1..expected_total).to_a.sort, observed.sort, + 'every value from 1..total should appear exactly once - a lost update would ' \ + 'produce a duplicate and leave a gap' + + final_store = Prism::RateLimiter::PStoreStore.new(store_path) + assert_equal expected_total + 1, final_store.count('shared-key', 3600) + end +end diff --git a/test/test_render_errors.rb b/test/test_render_errors.rb new file mode 100644 index 0000000..5438d92 --- /dev/null +++ b/test/test_render_errors.rb @@ -0,0 +1,96 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'support' + +class RenderErrorsGadget < Prism::Orm::Model + table_name :gadgets + validates :name, presence: true, length: { minimum: 3 } +end + +class GadgetsController < Prism::Controller + def create + gadget = RenderErrorsGadget.new(name: params['name']) + + if gadget.save + respond_to do |format| + format.json { json(id: gadget.id, name: gadget.name) } + end + else + respond_to do |format| + format.json { render_errors(gadget) } + end + end + end + + def show + gadget = RenderErrorsGadget.find(params['id']) + respond_to do |format| + format.json { json(id: gadget.id, name: gadget.name) } + end + rescue Prism::Orm::RecordNotFound + respond_to do |format| + format.json { render_not_found('gadget not found') } + end + end +end + +class GadgetsApp < Prism::Application + post '/gadgets', to: 'gadgets#create' + get '/gadgets/:id', to: 'gadgets#show' +end + +class RenderErrorsTest < Minitest::Test + include TestSupport + + def setup + @dir = Dir.mktmpdir('prism-render-errors-store') + store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + store.create_table(:gadgets, %w[id name]) + RenderErrorsGadget.store = store + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def call(method, path, form: {}, **opts) + body = form.map { |k, v| "#{k}=#{v}" }.join('&') + headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }.merge(opts.delete(:headers) || {}) + GadgetsApp.call(env_for(method, path, body: body, headers: headers, **opts)) + end + + def test_valid_record_responds_with_201_style_json_payload + status, headers, body = call('POST', '/gadgets', form: { name: 'wrench' }) + assert_equal 200, status + assert_equal 'application/json', headers['Content-Type'] + payload = JSON.parse(body.join) + assert_equal 'wrench', payload['name'] + end + + def test_invalid_record_responds_with_422_and_jsonapi_error_array + status, headers, body = call('POST', '/gadgets', form: { name: '' }) + assert_equal 422, status + assert_equal 'application/json', headers['Content-Type'] + + payload = JSON.parse(body.join) + errors = payload['errors'] + refute_nil errors + assert(errors.any? { |e| e['source']['pointer'] == '/data/attributes/name' }) + assert(errors.all? { |e| e['status'] == '422' }) + end + + def test_missing_record_responds_with_404_and_jsonapi_error_object + status, headers, body = call('GET', '/gadgets/999999') + assert_equal 404, status + assert_equal 'application/json', headers['Content-Type'] + + payload = JSON.parse(body.join) + errors = payload['errors'] + refute_nil errors + assert_equal 1, errors.size + assert_equal '404', errors.first['status'] + assert_nil errors.first['source'] + assert_equal 'gadget not found', errors.first['detail'] + end +end