diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:11:51 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:11:51 +0200 |
| commit | 02e1aefa24cdd0339995d14431713822f4bf4718 (patch) | |
| tree | a622727150214f8e9dab165ae48803180c38403d /test/models/user_otp_test.rb | |
| parent | 38920de3910705dac42af2370da7b3ba504577a9 (diff) | |
Add TOTP enrollment and verification to User, witnessed in the action log
Diffstat (limited to 'test/models/user_otp_test.rb')
| -rw-r--r-- | test/models/user_otp_test.rb | 83 |
1 files changed, 83 insertions, 0 deletions
diff --git a/test/models/user_otp_test.rb b/test/models/user_otp_test.rb new file mode 100644 index 00000000..81f25575 --- /dev/null +++ b/test/models/user_otp_test.rb | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class UserOtpTest < ActiveSupport::TestCase | ||
| 4 | fixtures :users | ||
| 5 | |||
| 6 | def setup | ||
| 7 | @user = users(:quentin) | ||
| 8 | end | ||
| 9 | |||
| 10 | test "begin_otp_enrollment! stores a pending secret and yields a provisioning URI" do | ||
| 11 | uri = @user.begin_otp_enrollment! | ||
| 12 | assert @user.otp_pending_secret.present? | ||
| 13 | assert_not @user.otp_enrolled? | ||
| 14 | assert_match %r{\Aotpauth://totp/}, uri | ||
| 15 | assert_includes uri, "issuer=" | ||
| 16 | end | ||
| 17 | |||
| 18 | test "confirm_otp_enrollment! promotes the pending secret and witnesses it" do | ||
| 19 | @user.begin_otp_enrollment! | ||
| 20 | code = ROTP::TOTP.new(@user.otp_pending_secret).now | ||
| 21 | |||
| 22 | assert @user.confirm_otp_enrollment!(code) | ||
| 23 | assert @user.otp_enrolled? | ||
| 24 | assert_nil @user.otp_pending_secret | ||
| 25 | |||
| 26 | action = NodeAction.where(:action => "otp_enroll").last | ||
| 27 | assert_equal @user, action.user | ||
| 28 | assert_equal [["User", @user.id]], | ||
| 29 | action.action_participants.map { |p| [p.subject_type, p.subject_id] } | ||
| 30 | end | ||
| 31 | |||
| 32 | test "confirm_otp_enrollment! rejects a wrong code and stays unenrolled" do | ||
| 33 | @user.begin_otp_enrollment! | ||
| 34 | assert_not @user.confirm_otp_enrollment!("000000") | ||
| 35 | assert_not @user.otp_enrolled? | ||
| 36 | assert @user.otp_pending_secret.present? | ||
| 37 | end | ||
| 38 | |||
| 39 | test "verify_otp! accepts a current code exactly once" do | ||
| 40 | @user.update!(:otp_secret => ROTP::Base32.random) | ||
| 41 | code = ROTP::TOTP.new(@user.otp_secret).now | ||
| 42 | |||
| 43 | assert @user.verify_otp!(code) | ||
| 44 | assert_not @user.verify_otp!(code), "replayed code must be rejected" | ||
| 45 | end | ||
| 46 | |||
| 47 | test "the confirmation code cannot be replayed at login" do | ||
| 48 | @user.begin_otp_enrollment! | ||
| 49 | code = ROTP::TOTP.new(@user.otp_pending_secret).now | ||
| 50 | @user.confirm_otp_enrollment!(code) | ||
| 51 | assert_not @user.verify_otp!(code) | ||
| 52 | end | ||
| 53 | |||
| 54 | test "verify_otp! rejects wrong codes and unenrolled users" do | ||
| 55 | assert_not @user.verify_otp!("123456") | ||
| 56 | enroll!(@user) | ||
| 57 | assert_not @user.verify_otp!("000000") | ||
| 58 | end | ||
| 59 | |||
| 60 | test "disable_otp! by the user themselves is witnessed as otp_disable" do | ||
| 61 | enroll!(@user) | ||
| 62 | assert @user.disable_otp!(:actor => @user) | ||
| 63 | assert_not @user.otp_enrolled? | ||
| 64 | assert_equal "otp_disable", NodeAction.last.action | ||
| 65 | end | ||
| 66 | |||
| 67 | test "an admin clearing another user's factor is witnessed as otp_reset" do | ||
| 68 | enroll!(@user) | ||
| 69 | admin = users(:aaron) | ||
| 70 | assert @user.disable_otp!(:actor => admin) | ||
| 71 | |||
| 72 | action = NodeAction.where(:action => "otp_reset").last | ||
| 73 | assert_equal admin, action.user | ||
| 74 | assert_equal @user.login, action.metadata["target_login"] | ||
| 75 | end | ||
| 76 | |||
| 77 | private | ||
| 78 | |||
| 79 | def enroll!(user) | ||
| 80 | user.begin_otp_enrollment! | ||
| 81 | user.confirm_otp_enrollment!(ROTP::TOTP.new(user.otp_pending_secret).now) | ||
| 82 | end | ||
| 83 | end | ||
