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.
250 lines
8.6 KiB
Ruby
250 lines
8.6 KiB
Ruby
require "test_helper"
|
|
require "webauthn/fake_client"
|
|
|
|
class PasskeySessionsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@user.webauthn_credentials.destroy_all
|
|
sign_in @user
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
@client = register_webauthn_credential
|
|
@stored_credential = @user.webauthn_credentials.reload.first
|
|
sign_out
|
|
end
|
|
|
|
test "signs in with a discoverable passkey, skipping password and TOTP" do
|
|
assertion = passkey_assertion
|
|
|
|
post passkey_session_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 "rejects passkey authentication when session creation fails" do
|
|
PasskeySessionsController.any_instance.stubs(:create_session_for).returns(false)
|
|
|
|
post passkey_session_path, params: { credential: passkey_assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_equal I18n.t("passkey_sessions.invalid_credential"), JSON.parse(response.body).fetch("error")
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
# The pending invitation lives in the Rack session, and complete_sign_in reads
|
|
# it immediately after creating the session. Anything that clears the session
|
|
# in between — a reset_session added to "fix" session fixation, say — drops the
|
|
# invitee into their own family with no error and no failing test.
|
|
test "accepts a pending invitation stored before the passkey sign-in" do
|
|
invitation = Invitation.create!(
|
|
email: @user.email,
|
|
role: "member",
|
|
family: @user.family,
|
|
inviter: @user
|
|
)
|
|
|
|
get new_session_path(invitation: invitation.token)
|
|
assert_response :success
|
|
|
|
post passkey_session_path, params: { credential: passkey_assertion }, as: :json
|
|
|
|
assert_response :success
|
|
assert invitation.reload.accepted_at.present?, "invitation was not accepted during passkey sign-in"
|
|
assert_equal "member", @user.reload.role
|
|
end
|
|
|
|
test "rejects an assertion without user verification" do
|
|
assertion = passkey_assertion(user_verified: false)
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_equal I18n.t("passkey_sessions.invalid_credential"), JSON.parse(response.body).fetch("error")
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "rejects an unknown user handle" do
|
|
assertion = passkey_assertion(user_handle: WebAuthn.generate_user_id)
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
# A blank handle must not fall through to `find_by(webauthn_id: nil)`, which
|
|
# would match every user who never registered a credential.
|
|
test "rejects a blank user handle" do
|
|
other_user = users(:family_member)
|
|
assert_nil other_user.webauthn_id
|
|
|
|
assertion = passkey_assertion
|
|
assertion["response"]["userHandle"] = nil
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_empty Session.where(user_id: [ @user.id, other_user.id ])
|
|
end
|
|
|
|
test "rejects a credential that belongs to a different user than the handle" do
|
|
other_user = users(:family_member)
|
|
other_user.ensure_webauthn_id!
|
|
|
|
assertion = passkey_assertion(user_handle: other_user.reload.webauthn_id)
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_empty Session.where(user_id: [ @user.id, other_user.id ])
|
|
end
|
|
|
|
test "rejects a deactivated user" do
|
|
@user.update_column(:active, false)
|
|
assertion = passkey_assertion
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "rejects a replayed assertion" do
|
|
assertion = passkey_assertion
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
assert_response :success
|
|
|
|
Session.where(user_id: @user.id).destroy_all
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "rejects an assertion with no challenge in the session" do
|
|
assertion = passkey_assertion
|
|
|
|
reset!
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "rejects malformed credential payloads" do
|
|
post passkey_session_options_path, as: :json
|
|
assert_response :success
|
|
|
|
post passkey_session_path, params: { credential: "not-json" }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "rejects users who are not allowed to use local login" do
|
|
AuthConfig.stubs(:local_login_allowed_for?).returns(false)
|
|
assertion = passkey_assertion
|
|
|
|
post passkey_session_path, params: { credential: assertion }, as: :json
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "both endpoints are unavailable when passkey login is disabled" do
|
|
AuthConfig.stubs(:passkey_login_enabled?).returns(false)
|
|
|
|
post passkey_session_options_path, as: :json
|
|
assert_response :forbidden
|
|
|
|
post passkey_session_path, params: { credential: {} }, as: :json
|
|
assert_response :forbidden
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "requests a discoverable credential with user verification required" do
|
|
post passkey_session_options_path, as: :json
|
|
|
|
assert_response :success
|
|
options = JSON.parse(response.body)
|
|
assert_equal "www.example.com", options.fetch("rpId")
|
|
assert_equal "required", options.fetch("userVerification")
|
|
assert_empty options.fetch("allowCredentials")
|
|
end
|
|
|
|
test "options use the configured relying party id" do
|
|
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
|
|
post passkey_session_options_path, as: :json
|
|
|
|
assert_response :success
|
|
assert_equal "example.test", JSON.parse(response.body).fetch("rpId")
|
|
end
|
|
end
|
|
|
|
private
|
|
# Runs a full options -> get -> assertion cycle against the passwordless
|
|
# endpoint, so each assertion is bound to a freshly minted challenge.
|
|
def passkey_assertion(user_verified: true, user_handle: nil)
|
|
post passkey_session_options_path, as: :json
|
|
assert_response :success
|
|
options = JSON.parse(response.body)
|
|
|
|
@client.get(
|
|
challenge: options.fetch("challenge"),
|
|
rp_id: "www.example.com",
|
|
user_verified: user_verified,
|
|
user_handle: raw_user_handle(user_handle || @user.reload.webauthn_id)
|
|
)
|
|
end
|
|
|
|
# FakeClient encodes whatever it is handed, but `webauthn_id` is already a
|
|
# base64url string, so it has to be decoded back to raw bytes first.
|
|
def raw_user_handle(webauthn_id)
|
|
WebAuthn.standard_encoder.decode(webauthn_id)
|
|
end
|
|
|
|
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 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
|
|
|
|
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
|