diff --git a/test/test_orm_token_for.rb b/test/test_orm_token_for.rb new file mode 100644 index 0000000..f685673 --- /dev/null +++ b/test/test_orm_token_for.rb @@ -0,0 +1,110 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmTokenForTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-token-for') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:token_for_users, %w[id email password_digest]) + + Prism::Orm::TokenFor.primary_secret = 'primary-secret' + Prism::Orm::TokenFor.previous_secrets = [] + + user_class = Class.new(Prism::Orm::Model) do + table_name :token_for_users + has_secure_password + generates_token_for(:password_reset, expires_in: 900) { password_digest } + generates_token_for(:email_confirmation) + # negative expires_in => already in the past the instant it is minted, + # letting the expiry path be tested without stubbing the clock. + generates_token_for(:already_expiring, expires_in: -1) + end + user_class.store = @store + Object.const_set(:TokenForUser, user_class) + + @user = TokenForUser.create!(email: 'foksiu@example.com', password: 'hunter2') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :TokenForUser) if Object.const_defined?(:TokenForUser) + Prism::Orm::TokenFor.primary_secret = nil + Prism::Orm::TokenFor.previous_secrets = [] + end + + def test_token_round_trips_to_the_record + token = @user.generate_token_for(:password_reset) + found = TokenForUser.find_by_token_for(:password_reset, token) + assert_equal @user.id, found&.id + end + + def test_token_is_scoped_to_its_own_purpose + token = @user.generate_token_for(:password_reset) + assert_nil TokenForUser.find_by_token_for(:email_confirmation, token) + end + + def test_expired_token_is_rejected + token = @user.generate_token_for(:already_expiring) + assert_nil TokenForUser.find_by_token_for(:already_expiring, token) + end + + def test_token_invalidated_by_changing_fingerprint_after_generation + token = @user.generate_token_for(:password_reset) + @user.password = 'a completely different passphrase' + @user.save! + + assert_nil TokenForUser.find_by_token_for(:password_reset, token) + end + + def test_token_without_a_fingerprint_block_is_unaffected_by_record_changes + token = @user.generate_token_for(:email_confirmation) + @user.update!(email: 'someone-else@example.com') + + found = TokenForUser.find_by_token_for(:email_confirmation, token) + assert_equal @user.id, found&.id + end + + def test_garbage_token_is_rejected + assert_nil TokenForUser.find_by_token_for(:password_reset, 'not-a-real-token') + assert_nil TokenForUser.find_by_token_for(:password_reset, nil) + end + + def test_token_for_a_deleted_record_no_longer_resolves + token = @user.generate_token_for(:password_reset) + @store.delete(:token_for_users, @user.id) + + assert_nil TokenForUser.find_by_token_for(:password_reset, token) + end + + def test_unknown_purpose_raises + assert_raises(Prism::Orm::TokenFor::Error) { @user.generate_token_for(:not_declared) } + assert_raises(Prism::Orm::TokenFor::Error) { TokenForUser.find_by_token_for(:not_declared, 'x') } + end + + def test_token_minted_under_a_retired_secret_still_verifies_after_rotation + token = @user.generate_token_for(:email_confirmation) + + Prism::Orm::TokenFor.previous_secrets = ['primary-secret'] + Prism::Orm::TokenFor.primary_secret = 'rotated-secret' + + found = TokenForUser.find_by_token_for(:email_confirmation, token) + assert_equal @user.id, found&.id + end + + def test_token_cannot_be_replayed_across_two_unrelated_models + @store.create_table(:token_for_widgets, %w[id name]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :token_for_widgets + generates_token_for(:password_reset, expires_in: 900) + end + widget_class.store = @store + Object.const_set(:TokenForWidget, widget_class) + + token = @user.generate_token_for(:password_reset) + assert_nil TokenForWidget.find_by_token_for(:password_reset, token) + ensure + Object.send(:remove_const, :TokenForWidget) if Object.const_defined?(:TokenForWidget) + end +end