22
33require "rails_helper"
44
5- # Failing spec for HIGH #2 — disabled user persistence .
5+ # Disabled- user provisioning behaviour .
66#
7- # UserProvisioner#save! runs BEFORE OmniauthCallbacksController#oidc
8- # checks `active_for_authentication?`. If the on_login hook flips a
9- # user's `enabled` flag (or any other Devise inactivity guard) and
10- # returns truthy, the gem still persists the record — only then does
11- # the controller reject the sign-in. Repeated hostile attempts leave
12- # a growing pile of provisional AdminUser rows.
13- #
14- # Acceptance criterion: a user the host hook marks inactive must NOT
15- # be persisted to the database.
7+ # UserProvisioner must enforce `active_for_authentication?` BEFORE
8+ # `save!`. If the on_login hook flips a user's inactivity flag (e.g.
9+ # `enabled = false`) and returns truthy, the gem must NOT persist
10+ # the record — otherwise repeated hostile attempts grow the table
11+ # with provisional rows that can never sign in.
1612RSpec . describe "OIDC callback: disabled user not persisted" , type : :request do
17- before do
18- OmniAuth . config . test_mode = true
19- OmniAuth . config . mock_auth [ :oidc ] = nil
20-
21- AdminUser . delete_all
22- end
23-
24- after { OmniAuth . config . mock_auth [ :oidc ] = nil }
25-
26- def build_auth_hash ( uid :, email :)
13+ let ( :uid ) { "mallory-sub" }
14+ let ( :email ) { "mallory@example.com" }
15+ let ( :auth_hash ) do
2716 OmniAuth ::AuthHash . new (
2817 provider : "oidc" ,
2918 uid : uid ,
@@ -32,72 +21,118 @@ def build_auth_hash(uid:, email:)
3221 )
3322 end
3423
35- it "does NOT persist a row when on_login sets enabled=false and returns truthy" do
36- ActiveAdmin ::Oidc . configure do |c |
37- c . issuer = "https://idp.example.com"
38- c . client_id = "client-abc"
39- c . on_login = lambda do |admin_user , _claims |
40- admin_user . enabled = false
41- true # truthy → current code persists the row
42- end
24+ let ( :disabling_hook ) do
25+ lambda do |admin_user , _claims |
26+ admin_user . enabled = false
27+ true # truthy → pre-fix code persisted the row
4328 end
29+ end
4430
45- OmniAuth . config . mock_auth [ :oidc ] =
46- build_auth_hash ( uid : "mallory-sub" , email : "mallory@example.com" )
31+ let ( :noop_hook ) { -> ( *) { true } }
4732
48- expect {
49- post "/admin/auth/oidc"
50- follow_redirect! if response . redirect?
51- } . not_to change ( AdminUser , :count ) ,
52- "disabled-by-hook user was persisted to AdminUser — repeated attempts grow the table"
53- end
33+ before do
34+ OmniAuth . config . test_mode = true
35+ OmniAuth . config . mock_auth [ :oidc ] = nil
36+ AdminUser . delete_all
5437
55- it "still redirects the disabled user to the login page" do
5638 ActiveAdmin ::Oidc . configure do |c |
5739 c . issuer = "https://idp.example.com"
5840 c . client_id = "client-abc"
59- c . on_login = lambda do |admin_user , _claims |
60- admin_user . enabled = false
61- true
62- end
41+ c . on_login = disabling_hook
6342 end
6443
65- OmniAuth . config . mock_auth [ :oidc ] =
66- build_auth_hash ( uid : "mallory-sub" , email : "mallory@example.com" )
44+ OmniAuth . config . mock_auth [ :oidc ] = auth_hash
45+ end
46+
47+ after { OmniAuth . config . mock_auth [ :oidc ] = nil }
6748
68- post "/admin/auth/oidc"
49+ def post_callback
50+ post "#{ OmniAuth . config . path_prefix } /oidc"
6951 follow_redirect! if response . redirect?
52+ end
7053
71- expect ( response ) . to redirect_to ( "/admin/login" )
54+ it "does NOT persist a row when on_login sets enabled=false and returns truthy" do
55+ expect { post_callback } . not_to change ( AdminUser , :count ) ,
56+ "disabled-by-hook user was persisted to AdminUser — repeated attempts grow the table"
7257 end
7358
74- # The HIGH #2 fix raised ProvisioningError when the hook flipped
75- # the inactivity flag, but the controller's generic rescue replaced
76- # the model's I18n-translated inactive_message with the generic
77- # access_denied_message. The disabled user lost the specific reason
78- # ("Your account has not been activated yet") and saw the catch-all
79- # denial flash instead. Surface the original reason via a dedicated
80- # error class.
81- it "shows the model's I18n-translated inactive_message in the flash" do
82- ActiveAdmin ::Oidc . configure do |c |
83- c . issuer = "https://idp.example.com"
84- c . client_id = "client-abc"
85- c . on_login = lambda do |admin_user , _claims |
86- admin_user . enabled = false
87- true
59+ it "still redirects the disabled user to the login page" do
60+ post_callback
61+ expect ( response ) . to redirect_to ( new_admin_user_session_path )
62+ end
63+
64+ # The retry short-circuit (`return admin_user if @retried`) skips
65+ # the `active_for_authentication?` guard in the provisioner. If a
66+ # host-side trigger flips the winner's row to inactive between the
67+ # concurrent insert and our retry read, the loser thread would
68+ # sign in silently — except Devise's after_set_user callback also
69+ # checks active_for_authentication? and intercepts. This spec
70+ # pins that safety net so a future refactor can't quietly remove
71+ # it.
72+ it "rejects an inactive winner row on the retry leg" do
73+ ActiveAdmin ::Oidc . config . on_login = noop_hook
74+
75+ # First save! simulates a lost race: the "other thread" inserts
76+ # the row as ACTIVE, then a host-side trigger flips it inactive
77+ # before our retry read. RecordNotUnique sends us through the
78+ # retry path, where find_by(provider, uid) returns the now-
79+ # inactive row.
80+ raise_once = true
81+ allow_any_instance_of ( AdminUser ) . to receive ( :save! ) . and_wrap_original do |original , *args |
82+ if raise_once
83+ raise_once = false
84+ winner = AdminUser . create! ( provider : "oidc" , uid : uid , email : email )
85+ winner . update_column ( :enabled , false )
86+ raise ActiveRecord ::RecordNotUnique , "duplicate (provider, uid)"
87+ else
88+ original . call ( *args )
8889 end
8990 end
9091
91- OmniAuth . config . mock_auth [ :oidc ] =
92- build_auth_hash ( uid : "mallory-sub" , email : "mallory@example.com" )
93-
94- expected = I18n . t ( "devise.failure.inactive" )
92+ post_callback
9593
96- post "/admin/auth/oidc"
97- follow_redirect! if response . redirect? # OmniAuth → /callback → our controller
94+ expect ( response ) . to redirect_to ( new_admin_user_session_path )
95+ # Tighter than the redirect check: confirm the inactive winner
96+ # did NOT end up in the Warden session. If they did, subsequent
97+ # protected pages would honor the session until the next
98+ # active_for_authentication? check, and the user would briefly
99+ # appear signed-in.
100+ expect ( session . to_h . keys . grep ( /warden/i ) ) . to be_empty ,
101+ "retry leg signed in an inactive winner row — active_for_authentication? was skipped"
102+ end
98103
99- expect ( flash [ :alert ] ) . to eq ( expected ) ,
104+ # The flash for a disabled user must carry Devise's translated
105+ # inactive_message ("Your account is not activated yet."), not the
106+ # gem's generic access_denied_message — otherwise users lose the
107+ # specific reason for the rejection.
108+ it "shows the model's I18n-translated inactive_message in the flash" do
109+ post_callback
110+ expect ( flash [ :alert ] ) . to eq ( I18n . t ( "devise.failure.inactive" ) ) ,
100111 "expected the disabled user to see Devise's translated inactive " \
101112 "message, but the controller used the generic denial flash"
102113 end
114+
115+ # A host's override may legitimately return nil from
116+ # inactive_message on some branches (Devise itself returns :inactive
117+ # from the base, but a subclass overriding for custom branches can
118+ # return nil). The flash must still carry a reason, not collapse to
119+ # I18n.t("devise.failure.") = "".
120+ it "defaults to :inactive when the model's inactive_message is blank" do
121+ allow_any_instance_of ( AdminUser ) . to receive ( :inactive_message ) . and_return ( nil )
122+ post_callback
123+ expect ( flash [ :alert ] ) . to eq ( I18n . t ( "devise.failure.inactive" ) ) ,
124+ "blank inactive_message produced an empty flash"
125+ end
126+
127+ # If the model returns a custom symbol with no translation (e.g.
128+ # :locked_by_admin without a devise.failure.locked_by_admin key),
129+ # the raw symbol name must NOT land in the flash visible to
130+ # unauthenticated visitors — it would leak host-internal state.
131+ it "hides custom inactive_message symbols when the translation is missing" do
132+ allow_any_instance_of ( AdminUser ) . to receive ( :inactive_message ) . and_return ( :locked_by_admin )
133+ post_callback
134+ expect ( flash [ :alert ] ) . not_to include ( "locked_by_admin" ) ,
135+ "raw inactive_message symbol leaked into the public flash"
136+ expect ( flash [ :alert ] ) . to eq ( I18n . t ( "devise.failure.inactive" ) )
137+ end
103138end
0 commit comments