trusty-cms 7.1.3 → 8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +118 -123
  3. data/config/application.rb +5 -1
  4. data/lib/string_extensions/string_extensions.rb +4 -2
  5. data/lib/trusty_cms/version.rb +1 -1
  6. data/spec/controllers/admin/assets_controller_spec.rb +124 -0
  7. data/spec/controllers/admin/changes_controller_spec.rb +72 -0
  8. data/spec/controllers/admin/configuration_controller_spec.rb +49 -0
  9. data/spec/controllers/admin/page_attachments_controller_spec.rb +54 -0
  10. data/spec/controllers/admin/pages_controller_spec.rb +162 -0
  11. data/spec/controllers/admin/preferences_controller_spec.rb +44 -0
  12. data/spec/controllers/admin/security_controller_spec.rb +85 -0
  13. data/spec/controllers/admin/sessions_controller_spec.rb +56 -0
  14. data/spec/controllers/admin/two_factor_controller_spec.rb +63 -0
  15. data/spec/controllers/admin/users_controller_spec.rb +132 -0
  16. data/spec/controllers/page_status_controller_spec.rb +66 -0
  17. data/spec/dummy/bin/rails +3 -3
  18. data/spec/dummy/bin/rake +2 -2
  19. data/spec/dummy/bin/setup +21 -13
  20. data/spec/dummy/config/application.rb +2 -0
  21. data/spec/dummy/config/boot.rb +1 -1
  22. data/spec/dummy/config/cable.yml +10 -0
  23. data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
  24. data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -2
  25. data/spec/dummy/config/initializers/inflections.rb +4 -4
  26. data/spec/dummy/config/initializers/new_framework_defaults_7_2.rb +70 -0
  27. data/spec/dummy/config/initializers/permissions_policy.rb +13 -0
  28. data/spec/dummy/config/puma.rb +34 -0
  29. data/spec/dummy/config/storage.yml +28 -1
  30. data/spec/dummy/db/migrate/20260729201444_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
  31. data/spec/dummy/db/migrate/20260729201445_create_active_storage_variant_records.active_storage.rb +27 -0
  32. data/spec/dummy/db/migrate/20260729201446_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
  33. data/spec/dummy/public/404.html +6 -6
  34. data/spec/dummy/public/406-unsupported-browser.html +66 -0
  35. data/spec/dummy/public/422.html +6 -6
  36. data/spec/dummy/public/500.html +6 -6
  37. data/spec/dummy/public/icon.png +0 -0
  38. data/spec/dummy/public/icon.svg +3 -0
  39. data/spec/dummy/public/robots.txt +1 -0
  40. data/spec/helpers/admin/configuration_helper_spec.rb +62 -0
  41. data/spec/helpers/admin/node_helper_spec.rb +132 -0
  42. data/spec/helpers/admin/pages_helper_spec.rb +84 -0
  43. data/spec/helpers/application_helper_spec.rb +115 -0
  44. data/spec/lib/string_extensions_spec.rb +41 -0
  45. data/spec/support/active_record_encryption.rb +10 -0
  46. data/spec/support/asset_type_registry.rb +29 -0
  47. data/trusty_cms.gemspec +2 -1
  48. metadata +74 -6
  49. data/spec/controllers/assets_controller.rb +0 -66
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::ChangesController, which builds a PaperTrail-backed diff view
4
+ # of recent page changes. Controller specs (views not rendered) let us drive the
5
+ # action and its private diff-building helpers without the admin HTML layout.
6
+ # PaperTrail behaviour is a plausible upgrade casualty, so this pins it down.
7
+ RSpec.describe Admin::ChangesController, type: :controller do
8
+ routes { TrustyCms::Application.routes }
9
+
10
+ let(:user) { create(:admin) }
11
+ let!(:site) { create(:site) }
12
+
13
+ before do
14
+ allow(controller).to receive(:authenticate_user!).and_return(true)
15
+ allow(controller).to receive(:current_user).and_return(user)
16
+ # The multi-site extension (which provides #current_site) isn't loaded in the
17
+ # dummy app, so stub it to the site our fixtures are scoped to.
18
+ allow(controller).to receive(:current_site).and_return(site)
19
+ end
20
+
21
+ # Create a page scoped to the current site and give it a versioned change so
22
+ # the controller has something to diff.
23
+ def page_with_change
24
+ page = create(:home, title: 'Original', site_id: site.id)
25
+ PaperTrail.request(whodunnit: user.id.to_s) do
26
+ page.update!(title: 'Updated Title')
27
+ end
28
+ page
29
+ end
30
+
31
+ # rails-controller-testing (which provides `assigns`) isn't in the bundle, so
32
+ # read the controller's assigned ivars directly.
33
+ def changes
34
+ controller.instance_variable_get(:@changes)
35
+ end
36
+
37
+ describe 'GET #show' do
38
+ it 'succeeds and assigns recent changes' do
39
+ page_with_change
40
+
41
+ get :show
42
+
43
+ expect(response).to have_http_status(:ok)
44
+ expect(changes).to be_present
45
+ end
46
+
47
+ it 'loads a single change when a version_id is given' do
48
+ page = page_with_change
49
+ version = PaperTrail::Version.where(item_type: 'Page', item_id: page.id).last
50
+
51
+ get :show, params: { version_id: version.id }
52
+
53
+ expect(response).to have_http_status(:ok)
54
+ expect(changes.size).to eq(1)
55
+ expect(changes.first[:id]).to eq(version.id)
56
+ end
57
+
58
+ it 'reports an error when the version_id is unknown' do
59
+ get :show, params: { version_id: 0 }
60
+
61
+ expect(changes).to be_empty
62
+ expect(controller.instance_variable_get(:@change_error)).to eq('Version ID not found.')
63
+ end
64
+
65
+ it 'succeeds with no changes present' do
66
+ get :show
67
+
68
+ expect(response).to have_http_status(:ok)
69
+ expect(changes).to eq([])
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::ConfigurationController, which batch-updates TrustyCms::Config
4
+ # entries inside a transaction and is admin-gated. Config plumbing is easy to
5
+ # break in a framework upgrade, so pin the happy path and the auth gate.
6
+ RSpec.describe Admin::ConfigurationController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+
9
+ let(:admin) { create(:admin) }
10
+
11
+ before do
12
+ allow(controller).to receive(:authenticate_user!).and_return(true)
13
+ allow(controller).to receive(:current_user).and_return(admin)
14
+ end
15
+
16
+ describe 'GET #show' do
17
+ it 'succeeds' do
18
+ get :show
19
+ expect(response).to have_http_status(:ok)
20
+ end
21
+ end
22
+
23
+ describe 'GET #edit' do
24
+ it 'succeeds for an admin' do
25
+ get :edit
26
+ expect(response).to have_http_status(:ok)
27
+ end
28
+ end
29
+
30
+ describe 'PUT #update' do
31
+ it 'persists the submitted config values and redirects to show' do
32
+ put :update, params: { trusty_config: { 'admin.title' => 'Renamed CMS' } }
33
+
34
+ expect(response).to redirect_to(action: :show)
35
+ expect(TrustyCms::Config['admin.title']).to eq('Renamed CMS')
36
+ end
37
+ end
38
+
39
+ describe 'authorization' do
40
+ it 'denies a non-admin access to edit' do
41
+ allow(controller).to receive(:current_user).and_return(create(:non_admin, email: 'plain@example.com'))
42
+
43
+ get :edit
44
+
45
+ expect(response).to be_redirect
46
+ expect(flash[:error]).to be_present
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+ require 'rack/test'
3
+
4
+ # Exercises Admin::PageAttachmentsController, whose custom #load_model builds a
5
+ # PageAttachment from an asset_id (+ optional page_id) and rescues a missing
6
+ # asset. Controller spec (views not rendered) drives the action and its
7
+ # before-action without needing the admin HTML layout.
8
+ RSpec.describe Admin::PageAttachmentsController, type: :controller do
9
+ routes { TrustyCms::Application.routes }
10
+
11
+ let(:user) { create(:admin) }
12
+ let(:fixtures_path) { TrustyCms::Engine.root.join('spec', 'fixtures', 'files') }
13
+ let(:asset) do
14
+ Asset.create!(asset: Rack::Test::UploadedFile.new(fixtures_path.join('sample.txt'), 'text/plain'), caption: '')
15
+ end
16
+
17
+ before do
18
+ register_standard_asset_types # shared canonical AssetTypes (see AssetTypeRegistry)
19
+ allow(controller).to receive(:authenticate_user!).and_return(true)
20
+ allow(controller).to receive(:current_user).and_return(user)
21
+ end
22
+
23
+ describe 'GET #new' do
24
+ it 'builds a PageAttachment for the asset with a new (unsaved) page' do
25
+ get :new, params: { asset_id: asset.id }
26
+
27
+ expect(response).to have_http_status(:ok)
28
+ model = controller.instance_variable_get(:@page_attachment)
29
+ expect(model).to be_a(PageAttachment)
30
+ expect(model.asset).to eq(asset)
31
+ expect(model.page).to be_a(Page).and be_new_record
32
+ end
33
+
34
+ it 'associates an existing page when a page_id is given' do
35
+ page = create(:home, title: 'Attach Target')
36
+
37
+ get :new, params: { asset_id: asset.id, page_id: page.id }
38
+
39
+ expect(controller.instance_variable_get(:@page)).to eq(page)
40
+ end
41
+
42
+ # NOTE: latent bug — load_model rescues a missing asset with
43
+ # `render nothing: true`, but the `nothing:` option was removed in Rails 5.1.
44
+ # It's now ignored, so Rails falls through to rendering the (nonexistent)
45
+ # `new` template and raises MissingTemplate instead of returning an empty
46
+ # body. Characterizing current behavior; the fix is `head :no_content`.
47
+ # This is exactly the kind of breakage a Rails 8 upgrade should clean up.
48
+ it 'raises MissingTemplate for a missing asset (broken rescue path)' do
49
+ expect {
50
+ get :new, params: { asset_id: 0 }
51
+ }.to raise_error(ActionView::MissingTemplate)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,162 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::PagesController — the most heavily customized ResourceController
4
+ # subclass (search, restore, per-site edit guards, meta-row setup, ordering).
5
+ # Controller specs (views not rendered) let us drive the actions and their many
6
+ # private helpers without the asset-pipeline-heavy admin HTML layout. This is the
7
+ # highest-blast-radius controller in the app, so keep it green across the Rails 8 bump.
8
+ RSpec.describe Admin::PagesController, type: :controller do
9
+ routes { TrustyCms::Application.routes }
10
+
11
+ let(:admin) { create(:admin) }
12
+ let!(:home) { create(:home, title: 'Home') }
13
+
14
+ before do
15
+ allow(controller).to receive(:authenticate_user!).and_return(true)
16
+ allow(controller).to receive(:current_user).and_return(admin)
17
+ # #current_site / Page.current_site / Page.homepage come from the multi-site
18
+ # extension, which isn't loaded in the dummy app; stub the touch points.
19
+ allow(Page).to receive(:current_site).and_return(nil)
20
+ allow(Page).to receive(:homepage).and_return(home)
21
+ end
22
+
23
+ describe 'GET #index' do
24
+ it 'succeeds and sets up the site/homepage/search' do
25
+ get :index
26
+ expect(response).to have_http_status(:ok)
27
+ expect(controller.instance_variable_get(:@homepage)).to eq(home)
28
+ end
29
+ end
30
+
31
+ describe 'GET #new' do
32
+ it 'builds a new page with defaults' do
33
+ get :new
34
+ expect(response).to have_http_status(:ok)
35
+ page = controller.instance_variable_get(:@page)
36
+ expect(page).to be_a(Page).and be_new_record
37
+ end
38
+
39
+ it 'roots a top-level page at "/" when no parent is given' do
40
+ get :new
41
+ expect(controller.instance_variable_get(:@page).slug).to eq('/')
42
+ end
43
+
44
+ it 'sets the parent_id from page_id' do
45
+ get :new, params: { page_id: home.id }
46
+ expect(controller.instance_variable_get(:@page).parent_id).to eq(home.id)
47
+ end
48
+ end
49
+
50
+ describe 'GET #edit' do
51
+ context 'when the page belongs to a site the admin can access' do
52
+ let(:site) { create(:site) }
53
+ let(:page) { create(:page, title: 'Editable', parent: home, site_id: site.id) }
54
+
55
+ before { admin.sites << site }
56
+
57
+ it 'succeeds and loads edit assigns' do
58
+ get :edit, params: { id: page.id }
59
+
60
+ expect(response).to have_http_status(:ok)
61
+ expect(controller.instance_variable_get(:@page)).to eq(page)
62
+ expect(controller.instance_variable_get(:@page_url)).to be_present
63
+ end
64
+ end
65
+
66
+ context 'when the page belongs to a site the admin cannot access' do
67
+ let(:site) { create(:site) }
68
+ let(:page) { create(:page, title: 'Forbidden', parent: home, site_id: site.id) }
69
+
70
+ it 'redirects back to the pages index' do
71
+ get :edit, params: { id: page.id }
72
+ expect(response).to redirect_to(admin_pages_url)
73
+ end
74
+ end
75
+ end
76
+
77
+ describe 'POST #create' do
78
+ it 'creates a page and redirects' do
79
+ expect {
80
+ post :create, params: { page: { title: 'Fresh', slug: 'fresh', breadcrumb: 'Fresh', parent_id: home.id } }
81
+ }.to change(Page, :count).by(1)
82
+
83
+ expect(response).to be_redirect
84
+ end
85
+
86
+ it 're-renders new via the rescue_from validation_error handler when invalid' do
87
+ expect {
88
+ post :create, params: { page: { title: '', slug: '', breadcrumb: '', parent_id: home.id } }
89
+ }.not_to change(Page, :count)
90
+
91
+ expect(flash[:error]).to be_present
92
+ end
93
+ end
94
+
95
+ describe 'PUT #update' do
96
+ it 'updates the page and redirects' do
97
+ page = create(:page, title: 'Before', parent: home)
98
+
99
+ put :update, params: { id: page.id, page: { title: 'After' } }
100
+
101
+ expect(response).to be_redirect
102
+ expect(page.reload.title).to eq('After')
103
+ end
104
+ end
105
+
106
+ describe 'DELETE #destroy' do
107
+ it 'destroys the page and counts descendants' do
108
+ page = create(:page, title: 'Doomed', parent: home)
109
+
110
+ expect {
111
+ delete :destroy, params: { id: page.id }
112
+ }.to change(Page, :count).by(-1)
113
+
114
+ expect(response).to be_redirect
115
+ end
116
+ end
117
+
118
+ describe 'GET #search' do
119
+ it 'returns matching pages when a query is present' do
120
+ create(:page, title: 'Findable', slug: 'findable', parent: home)
121
+
122
+ get :search, params: { site_id: nil, search: { query: 'Findable' } }
123
+
124
+ expect(response).to have_http_status(:ok)
125
+ titles = controller.instance_variable_get(:@pages).map(&:title)
126
+ expect(titles).to include('Findable')
127
+ end
128
+
129
+ it 'does not run a query when none is given' do
130
+ get :search, params: { site_id: '' }
131
+ expect(controller.instance_variable_get(:@pages)).to be_nil
132
+ end
133
+ end
134
+
135
+ describe 'POST #save_table_position' do
136
+ it 'saves the new order and returns ok' do
137
+ allow(Page).to receive(:save_order)
138
+ post :save_table_position, params: { new_position: %w[1 2 3] }
139
+ expect(response).to have_http_status(:ok)
140
+ expect(Page).to have_received(:save_order).with(%w[1 2 3])
141
+ end
142
+ end
143
+
144
+ describe 'PUT #restore' do
145
+ # NOTE: latent bug / Rails 8 upgrade blocker — restore_page_version calls
146
+ # PaperTrail's `reify`, which YAML-loads the stored version. Under Psych 4+
147
+ # (psych 5.4 is pinned) safe-loading rejects ActiveSupport::TimeWithZone
148
+ # because no permitted classes are configured, so restoring any page that has
149
+ # a timestamp in its versioned state raises Psych::DisallowedClass. Page
150
+ # restore is effectively broken until paper_trail's serializer is configured
151
+ # with permitted classes (e.g. via ActiveRecord::Base.yaml_column_permitted_classes
152
+ # or a custom serializer). Characterizing current behavior.
153
+ it 'currently fails to reify a versioned page (Psych safe-load blocker)' do
154
+ page = create(:page, title: 'V1', parent: home)
155
+ PaperTrail.request(whodunnit: admin.id.to_s) { page.update!(title: 'V2') }
156
+
157
+ expect {
158
+ put :restore, params: { id: page.id, version_index: 1 }
159
+ }.to raise_error(Psych::DisallowedClass, /TimeWithZone/)
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::PreferencesController, which lets the current user edit their
4
+ # own profile/preferences. Simple ApplicationController subclass; covers the
5
+ # show/edit render paths and the valid/invalid update branches.
6
+ RSpec.describe Admin::PreferencesController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+
9
+ let(:admin) { create(:admin) }
10
+
11
+ before do
12
+ allow(controller).to receive(:authenticate_user!).and_return(true)
13
+ allow(controller).to receive(:current_user).and_return(admin)
14
+ end
15
+
16
+ describe 'GET #show' do
17
+ it 'succeeds' do
18
+ get :show
19
+ expect(response).to have_http_status(:ok)
20
+ end
21
+ end
22
+
23
+ describe 'GET #edit' do
24
+ it 'succeeds' do
25
+ get :edit
26
+ expect(response).to have_http_status(:ok)
27
+ end
28
+ end
29
+
30
+ describe 'PUT #update' do
31
+ it 'updates the current user and redirects to configuration' do
32
+ put :update, params: { user: { first_name: 'Preferred' } }
33
+
34
+ expect(response).to redirect_to(admin_configuration_path)
35
+ expect(admin.reload.first_name).to eq('Preferred')
36
+ end
37
+
38
+ it 're-renders edit with an error when the update is invalid' do
39
+ put :update, params: { user: { email: 'not-an-email' } }
40
+
41
+ expect(flash[:error]).to be_present
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::SecurityController — password changes and TOTP two-factor
4
+ # enable/disable/verify for the current user. Touches devise-two-factor and QR
5
+ # provisioning, both plausible upgrade casualties.
6
+ RSpec.describe Admin::SecurityController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+
9
+ let(:user) { create(:admin) }
10
+
11
+ before do
12
+ allow(controller).to receive(:authenticate_user!).and_return(true)
13
+ allow(controller).to receive(:current_user).and_return(user)
14
+ # sign_out/sign_in need Warden wiring we don't set up here; the auth side
15
+ # effect isn't what these specs are checking.
16
+ allow(controller).to receive(:sign_out)
17
+ end
18
+
19
+ describe 'GET #show' do
20
+ it 'generates an OTP secret and QR provisioning data' do
21
+ expect(user.otp_secret).to be_blank
22
+
23
+ get :show
24
+
25
+ expect(response).to have_http_status(:ok)
26
+ expect(user.reload.otp_secret).to be_present
27
+ expect(controller.instance_variable_get(:@qr_png_data)).to start_with('data:image/png')
28
+ end
29
+ end
30
+
31
+ describe 'GET #edit' do
32
+ it 'succeeds' do
33
+ get :edit
34
+ expect(response).to have_http_status(:ok)
35
+ end
36
+ end
37
+
38
+ describe 'PUT #update' do
39
+ it 'updates the password, signs out, and redirects to sign-in' do
40
+ put :update, params: { user: { password: 'NewComplex1!', password_confirmation: 'NewComplex1!' } }
41
+
42
+ expect(controller).to have_received(:sign_out).with(user)
43
+ expect(response).to redirect_to(new_user_session_path)
44
+ end
45
+
46
+ it 're-renders edit when the password change is invalid' do
47
+ put :update, params: { user: { password: 'x', password_confirmation: 'y' } }
48
+
49
+ expect(flash[:error]).to be_present
50
+ end
51
+ end
52
+
53
+ describe 'POST #verify_two_factor' do
54
+ it 'enables 2FA when the OTP is valid' do
55
+ allow(user).to receive(:validate_and_consume_otp!).with('123456').and_return(true)
56
+
57
+ post :verify_two_factor, params: { otp_attempt: '123456' }
58
+
59
+ expect(user.reload.otp_required_for_login).to be(true)
60
+ expect(response).to redirect_to(admin_security_path)
61
+ end
62
+
63
+ it 'reports an error when the OTP is invalid' do
64
+ allow(user).to receive(:validate_and_consume_otp!).and_return(false)
65
+
66
+ post :verify_two_factor, params: { otp_attempt: 'bad' }
67
+
68
+ expect(flash[:error]).to be_present
69
+ expect(response).to redirect_to(admin_security_path)
70
+ end
71
+ end
72
+
73
+ describe 'POST #disable_two_factor' do
74
+ it 'clears the OTP settings and redirects' do
75
+ user.update!(otp_required_for_login: true, otp_secret: User.generate_otp_secret)
76
+
77
+ post :disable_two_factor
78
+
79
+ user.reload
80
+ expect(user.otp_required_for_login).to be(false)
81
+ expect(user.otp_secret).to be_nil
82
+ expect(response).to redirect_to(admin_security_path)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::SessionsController#create — the custom login flow that
4
+ # branches into the two-factor handoff for OTP-enabled users. Overriding a
5
+ # Devise controller is exactly the sort of thing that can break across a Devise
6
+ # major, so pin the three branches (2FA, plain sign-in, bad credentials).
7
+ RSpec.describe Admin::SessionsController, type: :controller do
8
+ routes { TrustyCms::Application.routes }
9
+ include Devise::Test::ControllerHelpers
10
+
11
+ let(:password) { 'ComplexPass1!' }
12
+
13
+ before do
14
+ request.env['devise.mapping'] = Devise.mappings[:user]
15
+ # sign_in needs Warden wiring that isn't the focus here.
16
+ allow(controller).to receive(:sign_in)
17
+ end
18
+
19
+ describe 'POST #create' do
20
+ it 'signs in a valid non-2FA user and redirects' do
21
+ user = create(:admin, password: password)
22
+
23
+ post :create, params: { user: { email: user.email, password: password } }
24
+
25
+ expect(controller).to have_received(:sign_in).with(:user, user)
26
+ expect(response).to redirect_to(admin_pages_path)
27
+ end
28
+
29
+ it 'starts the 2FA handoff for an OTP-enabled user' do
30
+ user = create(:admin, password: password, otp_required_for_login: true)
31
+
32
+ post :create, params: { user: { email: user.email, password: password } }
33
+
34
+ expect(response).to redirect_to(admin_two_factor_path)
35
+ expect(session[:pre_2fa_user_id]).to eq(user.id)
36
+ expect(session[:pre_2fa_started_at]).to be_present
37
+ expect(controller).not_to have_received(:sign_in)
38
+ end
39
+
40
+ it 're-renders the sign-in form on bad credentials' do
41
+ user = create(:admin, password: password)
42
+
43
+ post :create, params: { user: { email: user.email, password: 'wrong' } }
44
+
45
+ expect(controller).not_to have_received(:sign_in)
46
+ expect(flash.now[:alert]).to be_present
47
+ end
48
+
49
+ it 're-renders the sign-in form for an unknown email' do
50
+ post :create, params: { user: { email: 'nobody@example.com', password: password } }
51
+
52
+ expect(controller).not_to have_received(:sign_in)
53
+ expect(flash.now[:alert]).to be_present
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::TwoFactorController — the mid-login TOTP challenge. It runs
4
+ # BEFORE the user is fully signed in (authenticate_user! is skipped) and drives
5
+ # the flow off a short-lived session handoff (:pre_2fa_user_id / :started_at).
6
+ RSpec.describe Admin::TwoFactorController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+ # current_user is read via Devise even though authenticate_user! is skipped.
9
+ include Devise::Test::ControllerHelpers
10
+
11
+ let(:user) { create(:admin, otp_required_for_login: true) }
12
+
13
+ # Put the user mid-2FA: pending id in the session, started just now.
14
+ def begin_2fa!(started_at: Time.current.to_i)
15
+ session[:pre_2fa_user_id] = user.id
16
+ session[:pre_2fa_started_at] = started_at
17
+ end
18
+
19
+ before { allow(controller).to receive(:sign_in) }
20
+
21
+ describe 'GET #show' do
22
+ it 'renders the challenge for a user mid-2FA' do
23
+ begin_2fa!
24
+ get :show
25
+ expect(response).to have_http_status(:ok)
26
+ end
27
+
28
+ it 'redirects to sign-in when there is no pending 2FA session' do
29
+ get :show
30
+ expect(response).to redirect_to(new_user_session_path)
31
+ expect(flash[:alert]).to be_present
32
+ end
33
+
34
+ it 'redirects to sign-in when the 2FA session has expired' do
35
+ begin_2fa!(started_at: 10.minutes.ago.to_i)
36
+ get :show
37
+ expect(response).to redirect_to(new_user_session_path)
38
+ end
39
+ end
40
+
41
+ describe 'POST #create' do
42
+ before { begin_2fa! }
43
+
44
+ it 'signs the user in and clears the handoff when the code is valid' do
45
+ allow_any_instance_of(User).to receive(:validate_and_consume_otp!).with('123456').and_return(true)
46
+
47
+ post :create, params: { otp_attempt: '123456' }
48
+
49
+ expect(controller).to have_received(:sign_in).with(:user, an_instance_of(User))
50
+ expect(response).to redirect_to(admin_pages_path)
51
+ expect(session[:pre_2fa_user_id]).to be_nil
52
+ end
53
+
54
+ it 'resets the session and redirects when the code is invalid' do
55
+ allow_any_instance_of(User).to receive(:validate_and_consume_otp!).and_return(false)
56
+
57
+ post :create, params: { otp_attempt: 'wrong' }
58
+
59
+ expect(response).to redirect_to(new_user_session_path)
60
+ expect(flash[:alert]).to be_present
61
+ end
62
+ end
63
+ end