Strip out more inline javascript code. Encrypt backup codes and treat the backup codes attribute as a json array

This commit is contained in:
Dan Milne
2025-11-04 18:46:11 +11:00
parent bf104a9983
commit fb14ce032f
14 changed files with 336 additions and 248 deletions

View File

@@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase
assert_not user.valid?
assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
end
# Backup codes tests
test "generate_backup_codes returns 10 plain codes and stores BCrypt hashes" do
user = User.create!(
email_address: "test@example.com",
password: "password123"
)
# Generate backup codes
plain_codes = user.send(:generate_backup_codes)
# Should return 10 plain codes
assert_equal 10, plain_codes.length
assert_kind_of Array, plain_codes
# All codes should be 8 characters, alphanumeric, uppercase
plain_codes.each do |code|
assert_equal 8, code.length
assert_match(/\A[A-Z0-9]+\z/, code)
end
# Save user to persist the backup codes
user.save!
# Reload user from database to check stored values
user.reload
stored_hashes = user.backup_codes || []
# Should store 10 BCrypt hashes
assert_equal 10, stored_hashes.length
stored_hashes.each do |hash|
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
end
# Verify each plain code matches its corresponding hash
plain_codes.each_with_index do |code, index|
assert BCrypt::Password.new(stored_hashes[index]) == code, "Plain code should match stored hash"
end
end
test "verify_backup_code works with BCrypt hashes" do
user = User.create!(
email_address: "test@example.com",
password: "password123"
)
# Generate backup codes using the new flow (simulate what happens in controller)
plain_codes = user.send(:generate_backup_codes)
user.save!
user.reload
# Should successfully verify a valid backup code
assert user.verify_backup_code(plain_codes.first), "Should verify first backup code"
# Code should be deleted after use (single-use property)
user.reload
assert user.verify_backup_code(plain_codes.first) == false, "Used code should not be verifiable again"
# Should still verify other unused codes
assert user.verify_backup_code(plain_codes.second), "Should verify second backup code"
end
test "verify_backup_code returns false for invalid codes" do
user = User.create!(
email_address: "test@example.com",
password: "password123"
)
# Generate backup codes
plain_codes = user.send(:generate_backup_codes)
user.save!
user.reload
# Should fail for invalid codes
assert_not user.verify_backup_code("INVALID123"), "Should fail for invalid code"
assert_not user.verify_backup_code(""), "Should fail for empty code"
assert_not user.verify_backup_code(plain_codes.first + "X"), "Should fail for modified valid code"
end
test "verify_backup_code returns false when no backup codes exist" do
user = User.create!(
email_address: "test@example.com",
password: "password123"
)
# Should return false when user has no backup codes
assert_not user.verify_backup_code("ANYCODE123"), "Should fail when no backup codes exist"
end
test "verify_backup_code respects rate limiting" do
# Temporarily use memory store for this test
original_cache_store = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
user = User.create!(
email_address: "test@example.com",
password: "password123"
)
# Generate backup codes
plain_codes = user.send(:generate_backup_codes)
user.save!
user.reload
# Make 5 failed attempts to trigger rate limit
5.times do |i|
result = user.verify_backup_code("INVALID123")
assert_not result, "Failed attempt #{i+1} should return false"
end
# Check that the cache is tracking attempts
attempts = Rails.cache.read("backup_code_failed_attempts_#{user.id}") || 0
assert_equal 5, attempts, "Should have 5 failed attempts tracked"
# 6th attempt should be rate limited (both valid and invalid codes should fail)
assert_not user.verify_backup_code(plain_codes.first), "Valid code should be rate limited after 5 failed attempts"
assert_not user.verify_backup_code("INVALID123"), "Invalid code should also be rate limited"
# Valid code should still work if we clear the rate limit
Rails.cache.delete("backup_code_failed_attempts_#{user.id}")
assert user.verify_backup_code(plain_codes.first), "Should work after clearing rate limit"
# Restore original cache store
Rails.cache = original_cache_store
end
# Note: parsed_backup_codes method and legacy tests removed
# All users now use BCrypt hashes stored in JSON column
end