mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
Six suites (passkey, MFA, SnapTrade, categorize, onboarding and the Active Storage authorization integration tests) share a sign_out helper that deletes the user's sessions through the controller, one HTTP request per session, iterating in unspecified order. The moment the loop deletes the session the test itself is signed in with, every later request in the loop is unauthenticated and silently deletes nothing, so whichever sessions happen to sort after it survive. The sessions fixture belongs to the same user these suites use, so a surviving fixture row then fails every assertion that expects the user to have no sessions. Row order usually favors the fixture, which is why the suites usually pass. Under parallel CI they fail a few times a week, always in this file family, always with the fixture session as the leftover. Forcing newest-first order reproduces it deterministically on current main: ten of the fifteen passkey tests fail. Teardown hygiene is not the behavior under test, so the helpers now destroy the sessions directly, which no order can break. All six suites run green three times in a row.
326 lines
11 KiB
Ruby
326 lines
11 KiB
Ruby
require "test_helper"
|
|
require "webauthn/fake_client"
|
|
|
|
class MfaControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_member)
|
|
sign_in @user
|
|
end
|
|
|
|
def sign_out
|
|
# Deleting sessions through the controller de-authenticates the request the
|
|
# moment our own session dies, so every later delete in the loop is a
|
|
# silent no-op and whichever sessions sort after it survive. The order is
|
|
# unspecified, which made every suite that signs out this way flaky.
|
|
# Teardown hygiene is not the behavior under test, so destroy directly.
|
|
@user.sessions.destroy_all
|
|
end
|
|
|
|
test "redirects to root if MFA already enabled" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
|
|
get new_mfa_path
|
|
assert_redirected_to root_path
|
|
end
|
|
|
|
test "sets up MFA when visiting new" do
|
|
get new_mfa_path
|
|
|
|
assert_response :success
|
|
assert @user.reload.otp_secret.present?
|
|
assert_not @user.otp_required?
|
|
assert_select "svg" # QR code should be present
|
|
end
|
|
|
|
test "enables MFA with valid code" do
|
|
@user.setup_mfa!
|
|
totp = ROTP::TOTP.new(@user.otp_secret, issuer: "Sure Finances")
|
|
|
|
post mfa_path, params: { code: totp.now }
|
|
|
|
assert_response :success
|
|
assert @user.reload.otp_required?
|
|
assert_equal 8, @user.otp_backup_codes.length
|
|
assert @user.otp_backup_codes.all? { |code| code.start_with?("$2") }
|
|
assert_select "div.grid-cols-2" # Check for backup codes grid
|
|
rendered_codes = css_select("div.grid-cols-2 div").map { |node| node.text.strip }
|
|
assert_equal 8, rendered_codes.length
|
|
assert rendered_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
|
|
assert_empty rendered_codes & @user.otp_backup_codes
|
|
end
|
|
|
|
test "does not enable MFA with invalid code" do
|
|
@user.setup_mfa!
|
|
|
|
post mfa_path, params: { code: "invalid" }
|
|
|
|
assert_redirected_to new_mfa_path
|
|
assert_not @user.reload.otp_required?
|
|
assert_empty @user.otp_backup_codes
|
|
end
|
|
|
|
test "verify shows MFA verification page" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
assert_redirected_to verify_mfa_path
|
|
|
|
get verify_mfa_path
|
|
assert_response :success
|
|
assert_select "form[action=?]", verify_mfa_path
|
|
end
|
|
|
|
test "verify shows WebAuthn option when credentials are registered" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
register_webauthn_credential
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
get verify_mfa_path
|
|
|
|
assert_response :success
|
|
assert_select "button", text: I18n.t("mfa.verify.webauthn_button")
|
|
assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("mfa.verify_webauthn.invalid_credential")
|
|
assert_select "p[data-webauthn-authentication-target='error'][aria-live='assertive'][aria-atomic='true'][aria-hidden='true']"
|
|
end
|
|
|
|
test "verify_code authenticates with valid TOTP" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
assert_redirected_to verify_mfa_path
|
|
assert_equal @user.id, session[:mfa_user_id]
|
|
|
|
totp = ROTP::TOTP.new(@user.otp_secret, issuer: "Sure Finances")
|
|
|
|
post verify_mfa_path, params: { code: totp.now }
|
|
|
|
assert_redirected_to root_path
|
|
assert Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "verify_code cannot create a session after the user is deactivated" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
assert_redirected_to verify_mfa_path
|
|
assert_equal @user.id, session[:mfa_user_id]
|
|
|
|
totp = ROTP::TOTP.new(@user.otp_secret, issuer: "Sure Finances")
|
|
@user.update_column(:active, false)
|
|
|
|
assert_no_difference -> { Session.where(user_id: @user.id).count } do
|
|
post verify_mfa_path, params: { code: totp.now }
|
|
end
|
|
assert_redirected_to new_session_path
|
|
end
|
|
|
|
test "verify_code authenticates with valid backup code" do
|
|
@user.setup_mfa!
|
|
backup_code = @user.enable_mfa!.first
|
|
matching_digest = @user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
|
|
assert_not_nil matching_digest
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
|
|
post verify_mfa_path, params: { code: backup_code }
|
|
|
|
assert_redirected_to root_path
|
|
assert Session.exists?(user_id: @user.id)
|
|
assert_equal 7, @user.reload.otp_backup_codes.size
|
|
assert_not_includes @user.otp_backup_codes, matching_digest
|
|
end
|
|
|
|
test "verify_code rejects invalid codes" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post verify_mfa_path, params: { code: "invalid" }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "webauthn_options require a pending MFA session" do
|
|
post webauthn_options_mfa_path, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "verify_webauthn authenticates with a registered credential" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
client = register_webauthn_credential
|
|
stored_credential = @user.webauthn_credentials.first
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post webauthn_options_mfa_path, as: :json
|
|
assert_response :success
|
|
|
|
options = JSON.parse(response.body)
|
|
assertion = client.get(
|
|
challenge: options.fetch("challenge"),
|
|
rp_id: "www.example.com",
|
|
allow_credentials: [ stored_credential.credential_id ]
|
|
)
|
|
|
|
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :success
|
|
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
|
|
assert Session.exists?(user_id: @user.id)
|
|
assert stored_credential.reload.last_used_at.present?
|
|
assert_operator stored_credential.sign_count, :>, 0
|
|
end
|
|
|
|
test "verify_webauthn rejects authentication when session creation fails" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
client = register_webauthn_credential
|
|
stored_credential = @user.webauthn_credentials.first
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post webauthn_options_mfa_path, as: :json
|
|
options = JSON.parse(response.body)
|
|
assertion = client.get(
|
|
challenge: options.fetch("challenge"),
|
|
rp_id: "www.example.com",
|
|
allow_credentials: [ stored_credential.credential_id ]
|
|
)
|
|
MfaController.any_instance.stubs(:create_session_for).returns(false)
|
|
|
|
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_equal I18n.t("mfa.verify_webauthn.invalid_credential"), JSON.parse(response.body).fetch("error")
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "verify_webauthn authenticates with configured relying party id" do
|
|
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
client = register_webauthn_credential(origin: "https://app.example.test", rp_id: "example.test")
|
|
stored_credential = @user.webauthn_credentials.first
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post webauthn_options_mfa_path, as: :json
|
|
assert_response :success
|
|
|
|
options = JSON.parse(response.body)
|
|
assert_equal "example.test", options.fetch("rpId")
|
|
assertion = client.get(
|
|
challenge: options.fetch("challenge"),
|
|
rp_id: "example.test",
|
|
allow_credentials: [ stored_credential.credential_id ]
|
|
)
|
|
|
|
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :success
|
|
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
|
|
end
|
|
end
|
|
|
|
test "verify_webauthn rejects invalid credentials" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
client = register_webauthn_credential
|
|
stored_credential = @user.webauthn_credentials.first
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post webauthn_options_mfa_path, as: :json
|
|
options = JSON.parse(response.body)
|
|
assertion = client.get(
|
|
challenge: options.fetch("challenge"),
|
|
rp_id: "www.example.com",
|
|
allow_credentials: [ stored_credential.credential_id ]
|
|
)
|
|
assertion["id"] = "invalid"
|
|
|
|
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "verify_webauthn rejects malformed credential payloads" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
register_webauthn_credential
|
|
sign_out
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
post webauthn_options_mfa_path, as: :json
|
|
assert_response :success
|
|
|
|
post verify_webauthn_mfa_path, params: { credential: [] }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_equal I18n.t("mfa.verify_webauthn.invalid_credential"), JSON.parse(response.body).fetch("error")
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "disable removes MFA" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
@user.webauthn_credentials.create!(
|
|
nickname: "YubiKey",
|
|
credential_id: "disable-mfa-credential",
|
|
public_key: "public-key"
|
|
)
|
|
|
|
delete disable_mfa_path
|
|
|
|
assert_redirected_to settings_security_path
|
|
assert_not @user.reload.otp_required?
|
|
assert_nil @user.otp_secret
|
|
assert_empty @user.otp_backup_codes
|
|
assert_empty @user.webauthn_credentials
|
|
end
|
|
|
|
private
|
|
def register_webauthn_credential(origin: "http://www.example.com", rp_id: "www.example.com")
|
|
client = WebAuthn::FakeClient.new(origin)
|
|
|
|
post options_settings_webauthn_credentials_path, as: :json
|
|
options = JSON.parse(response.body)
|
|
credential = client.create(challenge: options.fetch("challenge"), rp_id: rp_id)
|
|
post settings_webauthn_credentials_path, params: {
|
|
webauthn_credential: { nickname: "MacBook Touch ID" },
|
|
credential: credential
|
|
}, as: :json
|
|
assert_response :success
|
|
|
|
client
|
|
end
|
|
|
|
def with_webauthn_config(rp_id:, allowed_origins:)
|
|
config = Rails.application.config.x.webauthn
|
|
previous_rp_id = config.rp_id
|
|
previous_allowed_origins = config.allowed_origins
|
|
config.rp_id = rp_id
|
|
config.allowed_origins = allowed_origins
|
|
|
|
yield
|
|
ensure
|
|
config.rp_id = previous_rp_id
|
|
config.allowed_origins = previous_allowed_origins
|
|
end
|
|
end
|