trusty-cms 7.1.2 → 7.1.3
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 +4 -3
- data/Gemfile.lock +58 -46
- data/README.md +2 -0
- data/app/controllers/admin/resource_controller.rb +1 -1
- data/app/controllers/application_controller.rb +15 -0
- data/app/models/asset.rb +29 -5
- data/app/models/standard_tags.rb +13 -7
- data/app/models/user.rb +13 -0
- data/lib/trusty_cms/site_scope_auth_reporter.rb +108 -0
- data/lib/trusty_cms/version.rb +1 -1
- data/package.json +1 -1
- data/spec/controllers/admin/snippets_controller_spec.rb +95 -0
- data/spec/controllers/site_controller_spec.rb +63 -0
- data/spec/factories/site.rb +8 -0
- data/spec/helpers/admin/references_helper_spec.rb +54 -0
- data/spec/helpers/admin/url_helper_spec.rb +71 -0
- data/spec/helpers/application_helper_spec.rb +102 -0
- data/spec/helpers/scoped_helper_spec.rb +60 -0
- data/spec/lib/active_record_extensions_spec.rb +65 -0
- data/spec/lib/login_system_spec.rb +61 -0
- data/spec/lib/ostruct_spec.rb +33 -0
- data/spec/lib/string_extensions_spec.rb +48 -0
- data/spec/lib/symbol_extensions_spec.rb +14 -0
- data/spec/lib/trusty_cms/config_cache_spec.rb +73 -0
- data/spec/models/admins_site_spec.rb +19 -0
- data/spec/models/asset_spec.rb +224 -0
- data/spec/models/asset_type_spec.rb +137 -0
- data/spec/models/file_not_found_page_spec.rb +33 -0
- data/spec/models/haml_filter_spec.rb +30 -0
- data/spec/models/menu_renderer_spec.rb +63 -0
- data/spec/models/page_attachment_spec.rb +29 -0
- data/spec/models/page_context_spec.rb +57 -0
- data/spec/models/page_field_spec.rb +15 -0
- data/spec/models/page_part_spec.rb +33 -0
- data/spec/models/page_spec.rb +184 -0
- data/spec/models/rails_page_spec.rb +32 -0
- data/spec/models/site_spec.rb +93 -0
- data/spec/models/snippet_finder_spec.rb +27 -0
- data/spec/models/snippet_tags_spec.rb +45 -0
- data/spec/models/standard_tags/children_spec.rb +159 -0
- data/spec/models/standard_tags/content_spec.rb +168 -0
- data/spec/models/standard_tags/meta_spec.rb +86 -0
- data/spec/models/standard_tags_spec.rb +32 -0
- data/spec/models/status_spec.rb +63 -0
- data/spec/models/text_filter_spec.rb +47 -0
- data/spec/models/trusty_cms/config_spec.rb +69 -0
- data/spec/models/trusty_cms/page_response_cache_director_spec.rb +72 -0
- data/spec/models/user_action_observer_spec.rb +39 -0
- data/spec/models/user_serialize_telemetry_spec.rb +124 -0
- data/spec/rails_helper.rb +13 -6
- data/spec/requests/admin/snippets_spec.rb +81 -0
- data/spec/spec_helper.rb +17 -3
- data/trusty_cms.gemspec +3 -3
- data/yarn.lock +273 -259
- metadata +91 -8
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# Exercises the full request cycle through Admin::ResourceController (the base
|
|
4
|
+
# for most admin CRUD): before-action chain, permitted_params, response_for
|
|
5
|
+
# dispatch, and the redirect/re-render paths. High blast radius for a Rails
|
|
6
|
+
# upgrade, so keep this green before and after the bump.
|
|
7
|
+
RSpec.describe Admin::SnippetsController, type: :controller do
|
|
8
|
+
routes { TrustyCms::Application.routes }
|
|
9
|
+
|
|
10
|
+
let(:user) { create(:admin) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
allow(controller).to receive(:authenticate_user!).and_return(true)
|
|
14
|
+
allow(controller).to receive(:current_user).and_return(user)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe 'GET #index' do
|
|
18
|
+
it 'succeeds for an authorized user' do
|
|
19
|
+
create(:snippet, name: 'listed')
|
|
20
|
+
get :index
|
|
21
|
+
expect(response).to have_http_status(:ok)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe 'GET #new' do
|
|
26
|
+
it 'succeeds' do
|
|
27
|
+
get :new
|
|
28
|
+
expect(response).to have_http_status(:ok)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe 'POST #create' do
|
|
33
|
+
it 'creates a snippet and redirects to the index' do
|
|
34
|
+
expect {
|
|
35
|
+
post :create, params: { snippet: { name: 'greeting', content: 'Hello' } }
|
|
36
|
+
}.to change(Snippet, :count).by(1)
|
|
37
|
+
|
|
38
|
+
expect(response).to be_redirect
|
|
39
|
+
expect(Snippet.find_by(name: 'greeting').content).to eq('Hello')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'records the creating user' do
|
|
43
|
+
post :create, params: { snippet: { name: 'owned', content: 'x' } }
|
|
44
|
+
expect(Snippet.find_by(name: 'owned').created_by_id).to eq(user.id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# NOTE: Admin::ResourceController defines #rescue_action, but that is a
|
|
48
|
+
# Rails 2 hook the framework no longer calls, and it is not re-wired via
|
|
49
|
+
# rescue_from (only Admin::PagesController does that). So an invalid create
|
|
50
|
+
# currently raises RecordInvalid unhandled (a 500 in production) instead of
|
|
51
|
+
# re-rendering the form via `response_for :invalid`. Characterizing the
|
|
52
|
+
# current behavior; revisit if rescue_action is ever wired up.
|
|
53
|
+
it 'raises on invalid input and persists nothing (latent bug: rescue_action is dead)' do
|
|
54
|
+
expect {
|
|
55
|
+
post :create, params: { snippet: { name: '', content: 'x' } }
|
|
56
|
+
}.to raise_error(ActiveRecord::RecordInvalid)
|
|
57
|
+
|
|
58
|
+
expect(Snippet.where(name: '')).to be_empty
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe 'PUT #update' do
|
|
63
|
+
it 'updates the snippet and redirects' do
|
|
64
|
+
snippet = create(:snippet, name: 'editme', content: 'old')
|
|
65
|
+
|
|
66
|
+
put :update, params: { id: snippet.id, snippet: { content: 'new' } }
|
|
67
|
+
|
|
68
|
+
expect(response).to be_redirect
|
|
69
|
+
expect(snippet.reload.content).to eq('new')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe 'DELETE #destroy' do
|
|
74
|
+
it 'destroys the snippet and redirects' do
|
|
75
|
+
snippet = create(:snippet, name: 'goner')
|
|
76
|
+
|
|
77
|
+
expect {
|
|
78
|
+
delete :destroy, params: { id: snippet.id }
|
|
79
|
+
}.to change(Snippet, :count).by(-1)
|
|
80
|
+
|
|
81
|
+
expect(response).to be_redirect
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe 'authorization' do
|
|
86
|
+
it 'denies a user without editor privileges' do
|
|
87
|
+
allow(controller).to receive(:current_user).and_return(create(:user, email: 'plain@example.com'))
|
|
88
|
+
|
|
89
|
+
get :index
|
|
90
|
+
|
|
91
|
+
expect(response).to be_redirect
|
|
92
|
+
expect(flash[:error]).to be_present
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe SiteController, type: :request do
|
|
4
|
+
let!(:home) { FactoryBot.create(:home, title: 'Home') }
|
|
5
|
+
|
|
6
|
+
before { home.parts.create(name: 'body', content: 'Welcome <r:title />') }
|
|
7
|
+
|
|
8
|
+
describe 'serving pages' do
|
|
9
|
+
it 'renders the home page at the root path' do
|
|
10
|
+
get '/'
|
|
11
|
+
expect(response).to have_http_status(:ok)
|
|
12
|
+
expect(response.body).to include('Welcome Home')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'renders a published child page by its path' do
|
|
16
|
+
about = FactoryBot.create(:page, title: 'About', slug: 'about', parent: home, status_id: Status[:published].id)
|
|
17
|
+
about.parts.create(name: 'body', content: 'About <r:title />')
|
|
18
|
+
|
|
19
|
+
get '/about'
|
|
20
|
+
expect(response).to have_http_status(:ok)
|
|
21
|
+
expect(response.body).to include('About About')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'renders a nested published page' do
|
|
25
|
+
section = FactoryBot.create(:page, title: 'Section', slug: 'section', parent: home, status_id: Status[:published].id)
|
|
26
|
+
article = FactoryBot.create(:page, title: 'Article', slug: 'article', parent: section, status_id: Status[:published].id)
|
|
27
|
+
article.parts.create(name: 'body', content: 'Deep content')
|
|
28
|
+
|
|
29
|
+
get '/section/article'
|
|
30
|
+
expect(response).to have_http_status(:ok)
|
|
31
|
+
expect(response.body).to include('Deep content')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe 'unknown paths' do
|
|
36
|
+
it 'renders the not-found template with a 404 status' do
|
|
37
|
+
get '/does/not/exist'
|
|
38
|
+
expect(response).to have_http_status(:not_found)
|
|
39
|
+
expect(response.body).to include('could not be located')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe 'content type' do
|
|
44
|
+
it 'sets the response Content-Type from the page layout' do
|
|
45
|
+
layout = Layout.create!(name: 'Plain', content: '<r:content />', content_type: 'text/plain')
|
|
46
|
+
page = FactoryBot.create(:page, title: 'Raw', slug: 'raw', parent: home, status_id: Status[:published].id, layout: layout)
|
|
47
|
+
page.parts.create(name: 'body', content: 'plain body')
|
|
48
|
+
|
|
49
|
+
get '/raw'
|
|
50
|
+
expect(response.media_type).to eq('text/plain')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '.cache_timeout' do
|
|
55
|
+
it 'round-trips through the response cache director' do
|
|
56
|
+
original = SiteController.cache_timeout
|
|
57
|
+
SiteController.cache_timeout = 5.minutes
|
|
58
|
+
expect(SiteController.cache_timeout).to eq(5.minutes)
|
|
59
|
+
ensure
|
|
60
|
+
SiteController.cache_timeout = original
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :site do
|
|
3
|
+
sequence(:name) { |n| "Site #{n}" }
|
|
4
|
+
sequence(:base_domain) { |n| "site#{n}.example.com" }
|
|
5
|
+
# domain is stored as a regexp string and validated for uniqueness.
|
|
6
|
+
sequence(:domain) { |n| "site#{n}\\.example\\.com" }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe Admin::ReferencesHelper, type: :helper do
|
|
4
|
+
describe '#class_of_page' do
|
|
5
|
+
it 'defaults to Page when no class_name param is present' do
|
|
6
|
+
allow(helper).to receive(:params).and_return({})
|
|
7
|
+
expect(helper.class_of_page).to eq(Page)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'constantizes the class_name param' do
|
|
11
|
+
allow(helper).to receive(:params).and_return(class_name: 'FileNotFoundPage')
|
|
12
|
+
expect(helper.class_of_page).to eq(FileNotFoundPage)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#filter' do
|
|
17
|
+
it 'is nil for an unknown filter name' do
|
|
18
|
+
allow(helper).to receive(:params).and_return(filter_name: 'No Such Filter')
|
|
19
|
+
expect(helper.filter).to be_nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'finds a text filter by its filter_name' do
|
|
23
|
+
HamlFilter # ensure the descendant is autoloaded
|
|
24
|
+
allow(helper).to receive(:params).and_return(filter_name: 'Haml')
|
|
25
|
+
expect(helper.filter).to eq(HamlFilter)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe '#filter_reference' do
|
|
30
|
+
it 'explains when there is no filter on the page part' do
|
|
31
|
+
allow(helper).to receive(:params).and_return({})
|
|
32
|
+
expect(helper.filter_reference).to eq('There is no filter on the current page part.')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'explains when a filter has no documentation' do
|
|
36
|
+
HamlFilter
|
|
37
|
+
allow(helper).to receive(:params).and_return(filter_name: 'Haml')
|
|
38
|
+
expect(helper.filter_reference).to eq('There is no documentation on this filter.')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#_display_name' do
|
|
43
|
+
it 'returns the page class display name for tags' do
|
|
44
|
+
allow(helper).to receive(:params).and_return(type: 'tags')
|
|
45
|
+
expect(helper._display_name).to eq(Page.display_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'returns the filter name for filters' do
|
|
49
|
+
HamlFilter
|
|
50
|
+
allow(helper).to receive(:params).and_return(type: 'filters', filter_name: 'Haml')
|
|
51
|
+
expect(helper._display_name).to eq('Haml')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe Admin::UrlHelper, type: :helper do
|
|
4
|
+
describe '#format_path' do
|
|
5
|
+
it 'returns an empty string for a root or empty path' do
|
|
6
|
+
expect(helper.format_path('')).to eq('')
|
|
7
|
+
expect(helper.format_path('/')).to eq('')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'returns "Root" for a single segment' do
|
|
11
|
+
expect(helper.format_path('foo')).to eq('Root')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns "/" for two segments' do
|
|
15
|
+
expect(helper.format_path('foo/bar')).to eq('/')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'returns the middle subpath for deeper paths' do
|
|
19
|
+
expect(helper.format_path('foo/bar/baz')).to eq('/bar')
|
|
20
|
+
expect(helper.format_path('foo/bar/baz/qux')).to eq('/bar/baz')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#extract_base_url' do
|
|
25
|
+
it 'keeps the scheme and host for a normal url' do
|
|
26
|
+
expect(helper.extract_base_url('http://example.com/some/page')).to eq('http://example.com')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'preserves the port for localhost urls' do
|
|
30
|
+
expect(helper.extract_base_url('http://localhost:3000/some/page')).to eq('http://localhost:3000')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe '#default_route?' do
|
|
35
|
+
it 'is true for a plain Page' do
|
|
36
|
+
expect(helper.default_route?(Page.new)).to be(true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'is false for a page subclass with no configured route' do
|
|
40
|
+
expect(helper.default_route?(FileNotFoundPage.new)).to be_falsey
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '#lookup_page_path' do
|
|
45
|
+
it 'returns nil when no custom routes are configured' do
|
|
46
|
+
expect(helper.lookup_page_path(FileNotFoundPage.new)).to be_nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#build_url' do
|
|
51
|
+
it 'appends the page path for default-route pages' do
|
|
52
|
+
page = Page.new
|
|
53
|
+
allow(page).to receive(:path).and_return('/about')
|
|
54
|
+
|
|
55
|
+
expect(helper.build_url('http://example.com', page)).to eq('http://example.com/about')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns nil for a non-default page with no configured path' do
|
|
59
|
+
expect(helper.build_url('http://example.com', FileNotFoundPage.new)).to be_nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#generate_page_url' do
|
|
64
|
+
it 'combines the base url and page path' do
|
|
65
|
+
page = Page.new
|
|
66
|
+
allow(page).to receive(:path).and_return('/about')
|
|
67
|
+
|
|
68
|
+
expect(helper.generate_page_url('http://example.com/current', page)).to eq('http://example.com/about')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe ApplicationHelper, type: :helper do
|
|
4
|
+
describe 'title and subtitle' do
|
|
5
|
+
it 'falls back to the default title and subtitle' do
|
|
6
|
+
allow(helper).to receive(:trusty_config).and_return({})
|
|
7
|
+
|
|
8
|
+
expect(helper.title).to eq('Trusty CMS')
|
|
9
|
+
expect(helper.subtitle).to eq('Publishing for Small Teams')
|
|
10
|
+
expect(helper.default_page_title).to eq('Trusty CMS - Publishing for Small Teams')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'uses the configured title and subtitle when present' do
|
|
14
|
+
allow(helper).to receive(:trusty_config)
|
|
15
|
+
.and_return('admin.title' => 'My Admin', 'admin.subtitle' => 'My Subtitle')
|
|
16
|
+
|
|
17
|
+
expect(helper.title).to eq('My Admin')
|
|
18
|
+
expect(helper.subtitle).to eq('My Subtitle')
|
|
19
|
+
expect(helper.default_page_title).to eq('My Admin - My Subtitle')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#logged_in?' do
|
|
24
|
+
it 'is true when there is a current user' do
|
|
25
|
+
allow(helper).to receive(:current_user).and_return(User.new)
|
|
26
|
+
expect(helper.logged_in?).to be(true)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'is false when there is no current user' do
|
|
30
|
+
allow(helper).to receive(:current_user).and_return(nil)
|
|
31
|
+
expect(helper.logged_in?).to be(false)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#admin? and #designer?' do
|
|
36
|
+
it 'reflects an admin user' do
|
|
37
|
+
user = instance_double(User, admin?: true, designer?: false)
|
|
38
|
+
allow(helper).to receive(:current_user).and_return(user)
|
|
39
|
+
|
|
40
|
+
expect(helper.admin?).to be(true)
|
|
41
|
+
expect(helper.designer?).to be(true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'treats a designer as a designer but not an admin' do
|
|
45
|
+
user = instance_double(User, admin?: false, designer?: true)
|
|
46
|
+
allow(helper).to receive(:current_user).and_return(user)
|
|
47
|
+
|
|
48
|
+
expect(helper.admin?).to be(false)
|
|
49
|
+
expect(helper.designer?).to be(true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'is false for both when there is no current user' do
|
|
53
|
+
allow(helper).to receive(:current_user).and_return(nil)
|
|
54
|
+
|
|
55
|
+
expect(helper.admin?).to be_falsey
|
|
56
|
+
expect(helper.designer?).to be_falsey
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#meta_errors? and #meta_label' do
|
|
61
|
+
it 'reports no meta errors and a "More" label' do
|
|
62
|
+
expect(helper.meta_errors?).to be(false)
|
|
63
|
+
expect(helper.meta_label).to eq('More')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#translate_with_default' do
|
|
68
|
+
it 'returns the given name when there is no translation' do
|
|
69
|
+
expect(helper.translate_with_default('Some Untranslated Name')).to eq('Some Untranslated Name')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#clean' do
|
|
74
|
+
it 'collapses duplicate slashes and strips a trailing slash from the path' do
|
|
75
|
+
expect(helper.clean('http://example.com/a//b/')).to eq('/a/b')
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe '#append_image_extension' do
|
|
80
|
+
it 'appends .png when no extension is present' do
|
|
81
|
+
expect(helper.send(:append_image_extension, 'admin/foo')).to eq('admin/foo.png')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'leaves an existing extension untouched' do
|
|
85
|
+
expect(helper.send(:append_image_extension, 'admin/foo.gif')).to eq('admin/foo.gif')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe '#available_locales_select' do
|
|
90
|
+
it 'prepends a default option with a blank value' do
|
|
91
|
+
expect(helper.available_locales_select.first.last).to eq('')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#body_classes' do
|
|
96
|
+
it 'starts as an empty, memoized array' do
|
|
97
|
+
expect(helper.body_classes).to eq([])
|
|
98
|
+
helper.body_classes << 'reversed'
|
|
99
|
+
expect(helper.body_classes).to eq(['reversed'])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe ScopedHelper do
|
|
4
|
+
# ScopedHelper defines #title/#subtitle on its includer (via the included
|
|
5
|
+
# hook) and reads from #current_site, so mix it into a small host class and
|
|
6
|
+
# stub current_site rather than fight ApplicationHelper's own title/subtitle.
|
|
7
|
+
let(:host_class) do
|
|
8
|
+
Class.new do
|
|
9
|
+
include ScopedHelper
|
|
10
|
+
attr_accessor :site
|
|
11
|
+
def current_site
|
|
12
|
+
site
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
subject(:helper) { host_class.new }
|
|
18
|
+
|
|
19
|
+
def site_double(name:, subtitle:)
|
|
20
|
+
double('site', name: name, subtitle: subtitle)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#title' do
|
|
24
|
+
it 'uses the current site name when present' do
|
|
25
|
+
helper.site = site_double(name: 'My Site', subtitle: '')
|
|
26
|
+
expect(helper.title).to eq('My Site')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'falls back to the configured admin title when the site name is blank' do
|
|
30
|
+
allow(TrustyCms::Config).to receive(:[]).with('admin.title').and_return('Configured Title')
|
|
31
|
+
helper.site = site_double(name: '', subtitle: '')
|
|
32
|
+
expect(helper.title).to eq('Configured Title')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'falls back to the default when both the site name and config are blank' do
|
|
36
|
+
allow(TrustyCms::Config).to receive(:[]).with('admin.title').and_return(nil)
|
|
37
|
+
helper.site = site_double(name: '', subtitle: '')
|
|
38
|
+
expect(helper.title).to eq('TrustyCMS')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#subtitle' do
|
|
43
|
+
it 'uses the current site subtitle when present' do
|
|
44
|
+
helper.site = site_double(name: 'My Site', subtitle: 'My Subtitle')
|
|
45
|
+
expect(helper.subtitle).to eq('My Subtitle')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'falls back to the configured admin subtitle when the site subtitle is blank' do
|
|
49
|
+
allow(TrustyCms::Config).to receive(:[]).with('admin.subtitle').and_return('Configured Subtitle')
|
|
50
|
+
helper.site = site_double(name: 'My Site', subtitle: '')
|
|
51
|
+
expect(helper.subtitle).to eq('Configured Subtitle')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'falls back to the default when both the site subtitle and config are blank' do
|
|
55
|
+
allow(TrustyCms::Config).to receive(:[]).with('admin.subtitle').and_return(nil)
|
|
56
|
+
helper.site = site_double(name: 'My Site', subtitle: '')
|
|
57
|
+
expect(helper.subtitle).to eq('publishing for small teams')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# ActiveRecord::Base monkeypatches. object_id_attr in particular relies on
|
|
4
|
+
# .descendants and generated accessors, so pin its behaviour ahead of any
|
|
5
|
+
# Rails upgrade. Exercised through PagePart, which uses it in production.
|
|
6
|
+
describe 'ActiveRecord extensions' do
|
|
7
|
+
describe '.object_id_attr' do
|
|
8
|
+
it 'instantiates the matching descendant for the stored id' do
|
|
9
|
+
HamlFilter # ensure the descendant is autoloaded
|
|
10
|
+
part = PagePart.new(filter_id: 'Haml')
|
|
11
|
+
expect(part.filter).to be_a(HamlFilter)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'falls back to the base class when no descendant matches' do
|
|
15
|
+
part = PagePart.new(filter_id: nil)
|
|
16
|
+
expect(part.filter.class).to eq(TextFilter)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'memoises the built object until the id changes' do
|
|
20
|
+
part = PagePart.new(filter_id: nil)
|
|
21
|
+
first = part.filter
|
|
22
|
+
expect(part.filter).to equal(first)
|
|
23
|
+
|
|
24
|
+
HamlFilter
|
|
25
|
+
part.filter_id = 'Haml'
|
|
26
|
+
expect(part.filter).to be_a(HamlFilter)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '.validates_path' do
|
|
31
|
+
let(:model_class) do
|
|
32
|
+
Class.new(PageField) do
|
|
33
|
+
validates_path :content
|
|
34
|
+
def self.name
|
|
35
|
+
'PathValidatedField'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'is valid when the value resolves to a real page' do
|
|
41
|
+
page = FactoryBot.create(:page, slug: '/', status_id: Status[:published].id)
|
|
42
|
+
allow(Page).to receive(:find_by_path).and_return(page)
|
|
43
|
+
|
|
44
|
+
record = model_class.new(name: 'ref', content: '/')
|
|
45
|
+
record.valid?
|
|
46
|
+
expect(record.errors[:content]).to be_empty
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'adds an error when the path is not found' do
|
|
50
|
+
allow(Page).to receive(:find_by_path).and_return(nil)
|
|
51
|
+
|
|
52
|
+
record = model_class.new(name: 'ref', content: '/missing')
|
|
53
|
+
record.valid?
|
|
54
|
+
expect(record.errors[:content]).to be_present
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'adds an error when the path resolves to a FileNotFoundPage' do
|
|
58
|
+
allow(Page).to receive(:find_by_path).and_return(FileNotFoundPage.new)
|
|
59
|
+
|
|
60
|
+
record = model_class.new(name: 'ref', content: '/404')
|
|
61
|
+
record.valid?
|
|
62
|
+
expect(record.errors[:content]).to be_present
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# LoginSystem is TrustyCms's custom role-based authorization layer, mixed into
|
|
4
|
+
# ApplicationController. The class-level access rules are pure logic, so pin
|
|
5
|
+
# them ahead of any Rails upgrade without needing a full request cycle.
|
|
6
|
+
describe LoginSystem do
|
|
7
|
+
describe 'role-based access (.only_allow_access_to / .user_has_access_to_action?)' do
|
|
8
|
+
let(:controller_class) do
|
|
9
|
+
Class.new(ActionController::Base) do
|
|
10
|
+
include LoginSystem
|
|
11
|
+
only_allow_access_to :index, :show,
|
|
12
|
+
when: %i[admin designer],
|
|
13
|
+
denied_message: 'You need editor privileges.'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'records the declared permissions per action' do
|
|
18
|
+
perms = controller_class.controller_permissions[:index]
|
|
19
|
+
expect(perms[:when]).to eq(%i[admin designer])
|
|
20
|
+
expect(perms[:denied_message]).to eq('You need editor privileges.')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'grants access to a user with an allowed role' do
|
|
24
|
+
expect(controller_class.user_has_access_to_action?(User.new(admin: true), :index)).to be(true)
|
|
25
|
+
expect(controller_class.user_has_access_to_action?(User.new(designer: true), :show)).to be(true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'denies a user without an allowed role' do
|
|
29
|
+
expect(controller_class.user_has_access_to_action?(User.new, :index)).to be(false)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'denies an anonymous (nil) user' do
|
|
33
|
+
expect(controller_class.user_has_access_to_action?(nil, :index)).to be(false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'allows any action that has no declared restriction' do
|
|
37
|
+
expect(controller_class.user_has_access_to_action?(User.new, :unlisted_action)).to be(true)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe 'conditional access (:if)' do
|
|
42
|
+
let(:controller_class) do
|
|
43
|
+
Class.new(ActionController::Base) do
|
|
44
|
+
include LoginSystem
|
|
45
|
+
only_allow_access_to :index, if: :allowed?
|
|
46
|
+
def allowed?
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'delegates to the named predicate on the controller' do
|
|
53
|
+
expect(controller_class.user_has_access_to_action?(User.new, :index)).to be(true)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# NOTE: .login_required? / .login_required reference `filter_chain`, a Rails
|
|
58
|
+
# API removed years ago, and would raise NoMethodError if called. They are
|
|
59
|
+
# dead code today (only commented-out call sites remain), so they are not
|
|
60
|
+
# exercised here. Flagging for removal or repair rather than pinning.
|
|
61
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'ostruct'
|
|
3
|
+
|
|
4
|
+
# lib/ostruct.rb reopens OpenStruct (originally patched for a Ruby upgrade) and
|
|
5
|
+
# shadows the stdlib version via the load path. This is exactly the kind of
|
|
6
|
+
# patch a Ruby/Rails upgrade can break, so pin the behaviour we rely on.
|
|
7
|
+
describe 'OpenStruct patch' do
|
|
8
|
+
it 'is the project patch, not the stdlib implementation' do
|
|
9
|
+
expect(OpenStruct.instance_method(:initialize).source_location.first)
|
|
10
|
+
.to eq(File.expand_path('../../lib/ostruct.rb', __dir__))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'builds accessors from a hash, symbolizing string keys' do
|
|
14
|
+
os = OpenStruct.new('a' => 1, :b => 2)
|
|
15
|
+
expect(os.a).to eq(1)
|
|
16
|
+
expect(os.b).to eq(2)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'returns nil for an unset member' do
|
|
20
|
+
expect(OpenStruct.new.anything).to be_nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'assigns and reads a member dynamically' do
|
|
24
|
+
os = OpenStruct.new
|
|
25
|
+
os.foo = 'bar'
|
|
26
|
+
expect(os.foo).to eq('bar')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'raises ArgumentError when a setter is given the wrong arity' do
|
|
30
|
+
os = OpenStruct.new
|
|
31
|
+
expect { os.send(:foo=, 1, 2) }.to raise_error(ArgumentError)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'string_extensions/string_extensions'
|
|
3
|
+
|
|
4
|
+
# Core-class monkeypatch: pin the behaviour so a Ruby/Rails upgrade that shifts
|
|
5
|
+
# String#underscore/#humanize or stringex surfaces as a failure here.
|
|
6
|
+
describe 'String extensions' do
|
|
7
|
+
describe '#symbolize' do
|
|
8
|
+
it 'collapses non-alphanumerics and underscores into a symbol' do
|
|
9
|
+
expect('Foo Bar!'.symbolize).to eq(:foo_bar)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'strips leading and trailing separators' do
|
|
13
|
+
expect(' Hello--World '.symbolize).to eq(:hello_world)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#titlecase' do
|
|
18
|
+
it 'upcases the first letter of each word' do
|
|
19
|
+
expect('hello world'.titlecase).to eq('Hello World')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'leaves already-capitalised words alone' do
|
|
23
|
+
expect('Hello World'.titlecase).to eq('Hello World')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#to_name' do
|
|
28
|
+
it 'humanises and title-cases a class-style name' do
|
|
29
|
+
expect('FooBar'.to_name).to eq('Foo Bar')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'strips a trailing suffix when given one' do
|
|
33
|
+
expect('FooFilter'.to_name('Filter')).to eq('Foo')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#parameterize and its aliases' do
|
|
38
|
+
it 'produces a url-style slug' do
|
|
39
|
+
expect('Hello World'.parameterize).to eq('hello-world')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'aliases to_slug, slugify and slugerize to parameterize' do
|
|
43
|
+
expect('Hello World'.to_slug).to eq('hello-world')
|
|
44
|
+
expect('Hello World'.slugify).to eq('hello-world')
|
|
45
|
+
expect('Hello World'.slugerize).to eq('hello-world')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
# NOTE: symbol_extensions is not required anywhere in the app itself, so we
|
|
3
|
+
# require it explicitly here. Kept as an upgrade tripwire since it depends on
|
|
4
|
+
# String#symbolize (see string_extensions).
|
|
5
|
+
require 'string_extensions/string_extensions'
|
|
6
|
+
require 'symbol_extensions/symbol_extensions'
|
|
7
|
+
|
|
8
|
+
describe 'Symbol extensions' do
|
|
9
|
+
describe '#symbolize' do
|
|
10
|
+
it 'normalises the symbol via String#symbolize' do
|
|
11
|
+
expect(:'Foo Bar'.symbolize).to eq(:foo_bar)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|