Modeling concepts with software requires capturing and codifying constraints in a way that ensures both correctness and resistance to invalid states. Testing for correctness, then, benefits from approaches more aligned with defining the rules of behavior, where the test harness pushes at the edges of boundaries automatically.
Object invariants define these rules.
Access Codes
Let's start with an example: access codes.
In this domain, access codes:
- are case-insensitive (
"ABC"
,"abc"
,"AbC"
, and"abC"
are equivalent) - are not equal if the values are blank / empty / nil (
"\n"
,""
," "
, andnil
are not equivalent to each other or themselves) - should not be susceptible to timing attacks when values are compared
Testing
In this example, I'll be using the Ruby gem PropCheck alongside RSpec.
Defining Data Generators
PropCheck provides a mechanism for generating data within certain constraints; based on our domain, two data construction techniques are required:
def present_string
alphanumeric_string.where(&:present?)
end
def empty_value
one_of(constant(nil), constant(""), constant("\n"), constant(" "))
end
Simple Assertions
Let's start with two assertions around AccessCode#blank?
returning true
for
an empty value and false
for values that are present.
it "is blank for all empty values" do
forall(code: empty_value) do |code:|
expect(AccessCode.new(code)).to be_blank
end
end
it "is not blank for all present values" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code)).not_to be_blank
end
end
This introduces the forall
method, which accepts a generator and makes
instances available to the block.
Next, let's test case-insensitivity:
it "is equal even when casing differs" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code.downcase)).to eq AccessCode.new(code)
expect(AccessCode.new(code)).to eq AccessCode.new(code.downcase)
expect(AccessCode.new(code.upcase)).to eq AccessCode.new(code)
expect(AccessCode.new(code)).to eq AccessCode.new(code.upcase)
end
end
We'll also ensure if a string hasn't been cast at first, it still checks equality as expected.
it "is equal even if the compared value isn't an access code" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code)).to eq code
end
end
Finally, we'll round out with a couple of other obvious rules around nil
and
blank values, and unmodified codes being equal:
it "is not equal when comparing against nil" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code)).not_to eq AccessCode.new(nil)
expect(AccessCode.new(nil)).not_to eq AccessCode.new(code)
end
end
it "is equal if the value is present and the same" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code)).to eq AccessCode.new(code)
end
end
it "does not equal itself when blank" do
forall(left: empty_value, right: empty_value) do |left:, right:|
expect(AccessCode.new(left)).not_to eq AccessCode.new(right)
expect(AccessCode.new(left)).not_to eq AccessCode.new(left)
expect(AccessCode.new(right)).not_to eq AccessCode.new(right)
end
end
Object Identity Assertions
More challenging are assertions around object identity. My former coworker Joël discussed this in his post about value object semantics, but let's get a few things in place here:
it "is #equal? IFF object identity is the same" do
forall(code: present_string) do |code:|
item = AccessCode.new(code)
expect(item).to be_equal(item)
expect(item).not_to be_equal(AccessCode.new(code))
end
end
it "hashes the same for any empty value" do
forall(left: empty_value, right: empty_value) do |left:, right:|
expect(AccessCode.new(left).hash).to eq AccessCode.new(right).hash
expect(AccessCode.new(left).hash).to eq AccessCode.new(left).hash
expect(AccessCode.new(right).hash).to eq AccessCode.new(right).hash
end
end
Finally, we'll assert against hash behavior for "present" values:
it "is represented as a hash correctly" do
forall(code: present_string) do |code:|
expect(AccessCode.new(code).hash).to eq AccessCode.new(code).hash
expect(AccessCode.new(code.downcase).hash).to eq AccessCode.new(code).hash
expect(AccessCode.new(code).hash).to eq AccessCode.new(code.downcase).hash
expect(AccessCode.new(code.upcase).hash).to eq AccessCode.new(code).hash
expect(AccessCode.new(code).hash).to eq AccessCode.new(code.upcase).hash
expect(AccessCode.new(code).hash).not_to eq AccessCode.new(" #{code}").hash
hash = {}
hash[AccessCode.new(code)] = 1
hash[AccessCode.new(code)] = 2
expect(hash.keys.count).to eq 1
end
end
Testing to Reduce Likelihood of Timing Attacks
Within ActiveSupport, we can use secure_compare
; rather than property
testing, we'll have to stick to some stubbing:
it "uses ActiveSupport::SecurityUtils to compare values" do
result = double("compare result")
allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("foo", "bar").and_return(result)
comparison = AccessCode.new("foo") == AccessCode.new("bar")
expect(comparison).to eq result
end
This test asserts that:
ActiveSupport::SecurityUtils.secure_compare
is called with two exact arguments- the return value of
#==
is the outcome of thesecure_compare
call
While this test is brittle / asserts directly against the implementation, it's a small trade-off to ensure we're interacting with the correct comparison behavior.
Wrapping Up
With this final test, our rules have been properly defined with tests in place to verify.
With property tests, we can assert truths about AccessCode
behavior,
regardless of what values are used, and have confidence that the implementation
is correct for various edge-cases. By capturing these object invariants in our
class, constructing an access code from a field in the database or value from
params
or session
within a Rails controller will help enforce
case-insensitivity and secure comparison of strings.
An implementation of AccessCode
that gets all the tests to pass is:
class AccessCode
def initialize(value)
@value = value.to_s.downcase
end
def blank?
value.blank?
end
def ==(other)
if blank? && other.blank?
false
else
if !other.is_a?(AccessCode)
other = AccessCode.new(other)
end
ActiveSupport::SecurityUtils.secure_compare(value, other.value)
end
end
alias_method :eql?, :==
def hash
if blank?
nil.hash
else
value.hash
end
end
protected
attr_reader :value
end
All of the code can be found here.