Last active
March 8, 2024 05:32
-
-
Save AlexB52/b991403047b0b89530490477fbf572c0 to your computer and use it in GitHub Desktop.
How to secure a unique random code in Rails without hitting a race condition
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Made as an answer to the question is rails forums | |
# https://discuss.rubyonrails.org/t/how-would-you-handle-gracefully-validating-a-generated-unique-code-until-its-guaranteed-to-be-valid/84919/9 | |
require "bundler/inline" | |
gemfile(true) do | |
source "https://rubygems.org" | |
gem "rails" | |
gem "sqlite3" | |
gem "debug" | |
end | |
require "active_record" | |
require "minitest/autorun" | |
require "debug" | |
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') | |
# ActiveRecord::Base.logger = Logger.new(STDOUT) | |
ActiveRecord::Schema.define do | |
create_table :license_reserved_numbers, id: false, force: true do |t| | |
t.string :number, primary_key: true | |
end | |
create_table :licenses, force: true do |t| | |
t.string :number, null: false | |
t.index [:number], unique: true | |
end | |
add_foreign_key :licenses, :license_reserved_numbers, column: :number, primary_key: :number | |
end | |
class RandomCode | |
def initialize | |
@enum = ('a'..'z').each | |
end | |
def random_code | |
@enum.next | |
end | |
end | |
class License < ActiveRecord::Base | |
validates :number, presence: true, uniqueness: true | |
before_validation ->(license) { license[:number] = ReservedNumber.reserve }, on: :create | |
def number=(value) | |
raise NotImplementedError, 'number cannot be explicitly set' | |
end | |
end | |
class License::ReservedNumber < ActiveRecord::Base | |
validates :number, presence: true, uniqueness: true | |
def self.reserve(generator: RandomCode.new) | |
record = new(number: nil) | |
until record.save | |
record.number = generator.random_code | |
end | |
record.number | |
end | |
end | |
class TestLicenseNumber < Minitest::Test | |
def teardown | |
License.destroy_all | |
License::ReservedNumber.destroy_all | |
end | |
def test_presence_validations | |
subject = License::ReservedNumber.new | |
subject.valid? | |
assert_equal ['Number can\'t be blank'], subject.errors.full_messages | |
end | |
def test_unique_validation | |
License::ReservedNumber.create!(number: 'a code') | |
subject = License::ReservedNumber.create(number: 'a code') | |
subject.valid? | |
assert_equal ['Number has already been taken'], subject.errors.full_messages | |
end | |
end | |
class TestLicense < Minitest::Test | |
def teardown | |
License.destroy_all | |
License::ReservedNumber.destroy_all | |
end | |
def test_setting_the_number_directly | |
assert_raises(NotImplementedError) do | |
License.create!(number: 'a code') | |
end | |
assert_raises(NotImplementedError) do | |
license = License.new | |
license.number = 'a code' | |
end | |
end | |
def test_self_attribution_of_license_number | |
subject = License.create | |
assert_equal License::ReservedNumber.last.number, subject.number | |
end | |
def test_assignment_of_last_existing_code | |
('a'..'y').each { |number| License.create! } | |
assert_equal 'z', License.create!.number | |
end | |
def test_destroy_assigned_reserved_number | |
subject = License::ReservedNumber.create!(number: 'a code') | |
subject.destroy! | |
assert_raises(ActiveRecord::InvalidForeignKey) do | |
license = License.create! | |
License::ReservedNumber.find(license.number).destroy | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment