# Automatically generated by the orthodox gem (https://github.com/katanacode/orthodox) # (c) Copyright 2019 Katana Code Ltd. All Rights Reserved. # # == Schema Information # # Table name: otp_credentials # # id :bigint not null, primary key # authable_type :string not null # last_used_at :datetime # recovery_codes :json # secret :string(32) # authable_id :bigint not null # # Indexes # # index_otp_credentials_on_authable_type_and_authable_id (authable_type,authable_id) # require 'rails_helper' RSpec.describe OtpCredential, type: :model do include ActiveSupport::Testing::TimeHelpers describe "on create" do subject do record = build(:otp_credential, authable_type: "User", authable_id: 1) record.save(validate: false) record end it "generates a random secret" do expect(subject.secret).to be_present end it "generates 10 recovery codes" do expect(subject.recovery_codes.size).to eql(10) end it "sets last_used_at to a past time" do expect(subject.last_used_at).to be_past end end describe "#url" do let(:otp_credential) do record = build(:otp_credential, authable_type: "User", authable_id: 1) record.save(validate: false) record end subject { URI(otp_credential.url) } before do test_user = instance_double("TestUser", email: "user-email@example.com") allow(otp_credential).to receive(:authable).and_return(test_user) end it "contains the otpauth scheme" do expect(subject.scheme).to eql("otpauth") end it "contains the autheticateable's email" do expect(subject.to_s).to include("user-email@example.com") end it "contains the application name" do expect(subject.to_s).to include(Rails.application.class.module_parent.name) end end describe "valid_otp?" do let!(:otp_credential) do record = build(:otp_credential, authable_type: "User", authable_id: 1) record.save(validate: false) record end subject { otp_credential.valid_otp?(code) } context "when code is correct" do let(:code) { otp_credential.send(:current_otp) } it { is_expected.to be_truthy } end context "when code is correct but already used" do let(:code) { otp_credential.send(:current_otp) } before do otp_credential.valid_otp?(code) end it { is_expected.to be_falsey } end context "when code is correct and expired less than DRIFT_ALLOWANCE" do let!(:code) { otp_credential.send(:current_otp) } it "is exepected to be truthy" do time_to_travel = seconds_until_next_otp + 14 travel(time_to_travel.seconds) { expect(otp_credential).to be_valid_otp(code) } end end context "when code is correct but expired" do let!(:code) { otp_credential.send(:current_otp) } it "is exepected to be truthy" do time_to_travel = seconds_until_next_otp + 15 travel(time_to_travel.seconds) { expect(subject).to be_falsey } end end context "when otp is incorrect" do let(:code) { "99999" } it { is_expected.to be_falsey } end end describe "valid_recovery_code?" do let!(:otp_credential) do record = build(:otp_credential, authable_type: "User", authable_id: 1) record.save(validate: false) record end context "when recovery_code is in the list" do it "is expected to return true" do recovery_code = otp_credential.recovery_codes.sample expect(otp_credential.valid_recovery_code?(recovery_code)).to be_truthy end end context "when recovery_code is not in the list" do it "is expected to return false" do recovery_code = "not-in-list" expect(otp_credential.valid_recovery_code?(recovery_code)).to be_falsey end end end describe "consume_recovery_code!" do let(:otp_credential) do record = build(:otp_credential, authable_type: "User", authable_id: 1) record.save(validate: false) record end context "when recovery_code is in the list" do it "removes it from the list" do recovery_code = otp_credential.recovery_codes.sample expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(true) otp_credential.consume_recovery_code!(recovery_code) expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false) end end context "when recovery_code is not in the list" do it "fails silently" do recovery_code = "not-in-list" expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false) otp_credential.consume_recovery_code!(recovery_code) expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false) end end end private def seconds_until_next_otp change_window = 30 # seconds diff_since_last_change = Time.now.sec % change_window change_window - diff_since_last_change end end