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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -3
  3. data/Gemfile.lock +58 -46
  4. data/README.md +2 -0
  5. data/app/controllers/admin/resource_controller.rb +1 -1
  6. data/app/controllers/application_controller.rb +15 -0
  7. data/app/models/asset.rb +29 -5
  8. data/app/models/standard_tags.rb +13 -7
  9. data/app/models/user.rb +13 -0
  10. data/lib/trusty_cms/site_scope_auth_reporter.rb +108 -0
  11. data/lib/trusty_cms/version.rb +1 -1
  12. data/package.json +1 -1
  13. data/spec/controllers/admin/snippets_controller_spec.rb +95 -0
  14. data/spec/controllers/site_controller_spec.rb +63 -0
  15. data/spec/factories/site.rb +8 -0
  16. data/spec/helpers/admin/references_helper_spec.rb +54 -0
  17. data/spec/helpers/admin/url_helper_spec.rb +71 -0
  18. data/spec/helpers/application_helper_spec.rb +102 -0
  19. data/spec/helpers/scoped_helper_spec.rb +60 -0
  20. data/spec/lib/active_record_extensions_spec.rb +65 -0
  21. data/spec/lib/login_system_spec.rb +61 -0
  22. data/spec/lib/ostruct_spec.rb +33 -0
  23. data/spec/lib/string_extensions_spec.rb +48 -0
  24. data/spec/lib/symbol_extensions_spec.rb +14 -0
  25. data/spec/lib/trusty_cms/config_cache_spec.rb +73 -0
  26. data/spec/models/admins_site_spec.rb +19 -0
  27. data/spec/models/asset_spec.rb +224 -0
  28. data/spec/models/asset_type_spec.rb +137 -0
  29. data/spec/models/file_not_found_page_spec.rb +33 -0
  30. data/spec/models/haml_filter_spec.rb +30 -0
  31. data/spec/models/menu_renderer_spec.rb +63 -0
  32. data/spec/models/page_attachment_spec.rb +29 -0
  33. data/spec/models/page_context_spec.rb +57 -0
  34. data/spec/models/page_field_spec.rb +15 -0
  35. data/spec/models/page_part_spec.rb +33 -0
  36. data/spec/models/page_spec.rb +184 -0
  37. data/spec/models/rails_page_spec.rb +32 -0
  38. data/spec/models/site_spec.rb +93 -0
  39. data/spec/models/snippet_finder_spec.rb +27 -0
  40. data/spec/models/snippet_tags_spec.rb +45 -0
  41. data/spec/models/standard_tags/children_spec.rb +159 -0
  42. data/spec/models/standard_tags/content_spec.rb +168 -0
  43. data/spec/models/standard_tags/meta_spec.rb +86 -0
  44. data/spec/models/standard_tags_spec.rb +32 -0
  45. data/spec/models/status_spec.rb +63 -0
  46. data/spec/models/text_filter_spec.rb +47 -0
  47. data/spec/models/trusty_cms/config_spec.rb +69 -0
  48. data/spec/models/trusty_cms/page_response_cache_director_spec.rb +72 -0
  49. data/spec/models/user_action_observer_spec.rb +39 -0
  50. data/spec/models/user_serialize_telemetry_spec.rb +124 -0
  51. data/spec/rails_helper.rb +13 -6
  52. data/spec/requests/admin/snippets_spec.rb +81 -0
  53. data/spec/spec_helper.rb +17 -3
  54. data/trusty_cms.gemspec +3 -3
  55. data/yarn.lock +273 -259
  56. 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