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.
- checksums.yaml +4 -4
- data/Gemfile.lock +118 -123
- data/config/application.rb +5 -1
- data/lib/string_extensions/string_extensions.rb +4 -2
- data/lib/trusty_cms/version.rb +1 -1
- data/spec/controllers/admin/assets_controller_spec.rb +124 -0
- data/spec/controllers/admin/changes_controller_spec.rb +72 -0
- data/spec/controllers/admin/configuration_controller_spec.rb +49 -0
- data/spec/controllers/admin/page_attachments_controller_spec.rb +54 -0
- data/spec/controllers/admin/pages_controller_spec.rb +162 -0
- data/spec/controllers/admin/preferences_controller_spec.rb +44 -0
- data/spec/controllers/admin/security_controller_spec.rb +85 -0
- data/spec/controllers/admin/sessions_controller_spec.rb +56 -0
- data/spec/controllers/admin/two_factor_controller_spec.rb +63 -0
- data/spec/controllers/admin/users_controller_spec.rb +132 -0
- data/spec/controllers/page_status_controller_spec.rb +66 -0
- data/spec/dummy/bin/rails +3 -3
- data/spec/dummy/bin/rake +2 -2
- data/spec/dummy/bin/setup +21 -13
- data/spec/dummy/config/application.rb +2 -0
- data/spec/dummy/config/boot.rb +1 -1
- data/spec/dummy/config/cable.yml +10 -0
- data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -2
- data/spec/dummy/config/initializers/inflections.rb +4 -4
- data/spec/dummy/config/initializers/new_framework_defaults_7_2.rb +70 -0
- data/spec/dummy/config/initializers/permissions_policy.rb +13 -0
- data/spec/dummy/config/puma.rb +34 -0
- data/spec/dummy/config/storage.yml +28 -1
- data/spec/dummy/db/migrate/20260729201444_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
- data/spec/dummy/db/migrate/20260729201445_create_active_storage_variant_records.active_storage.rb +27 -0
- data/spec/dummy/db/migrate/20260729201446_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
- data/spec/dummy/public/404.html +6 -6
- data/spec/dummy/public/406-unsupported-browser.html +66 -0
- data/spec/dummy/public/422.html +6 -6
- data/spec/dummy/public/500.html +6 -6
- data/spec/dummy/public/icon.png +0 -0
- data/spec/dummy/public/icon.svg +3 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/helpers/admin/configuration_helper_spec.rb +62 -0
- data/spec/helpers/admin/node_helper_spec.rb +132 -0
- data/spec/helpers/admin/pages_helper_spec.rb +84 -0
- data/spec/helpers/application_helper_spec.rb +115 -0
- data/spec/lib/string_extensions_spec.rb +41 -0
- data/spec/support/active_record_encryption.rb +10 -0
- data/spec/support/asset_type_registry.rb +29 -0
- data/trusty_cms.gemspec +2 -1
- metadata +74 -6
- 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
|