trek 2.1.0 → 2.3.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/.slim-lint.yml +0 -3
  3. data/AGENTS.md +8 -1
  4. data/CHANGELOG.md +75 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +20 -3
  7. data/Makefile +11 -9
  8. data/README.md +1 -1
  9. data/app/assets/icons/trek/audit.svg +1 -0
  10. data/app/assets/icons/trek/chevron-down.svg +1 -0
  11. data/app/assets/icons/trek/chevron-up.svg +1 -0
  12. data/app/assets/icons/trek/external.svg +1 -0
  13. data/app/assets/icons/trek/print.svg +1 -0
  14. data/app/assets/icons/trek/theme-auto.svg +1 -0
  15. data/app/assets/icons/trek/theme-dark.svg +1 -0
  16. data/app/assets/icons/trek/theme-light.svg +1 -0
  17. data/app/assets/stylesheets/trek/base.css +13 -0
  18. data/app/components/trek/button_component/button_component.css +9 -0
  19. data/app/components/trek/button_component.rb +13 -1
  20. data/app/components/trek/dashboard/analytics_component/analytics_component.html.slim +2 -0
  21. data/app/components/trek/dashboard/analytics_component.rb +1 -0
  22. data/app/components/trek/dashboard/chart_component/chart_component.css +192 -0
  23. data/app/components/trek/dashboard/chart_component/chart_component.html.slim +6 -0
  24. data/app/components/trek/dashboard/chart_component/chart_component.js +1 -0
  25. data/app/components/trek/dashboard/chart_component.rb +27 -0
  26. data/app/components/trek/dashboard/tile_component/tile_component.css +40 -0
  27. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +14 -4
  28. data/app/components/trek/dashboard/tile_component.rb +20 -2
  29. data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
  30. data/app/components/trek/dialog_component/dialog_component.js +62 -2
  31. data/app/components/trek/dropdown_component/dropdown_component.css +66 -0
  32. data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
  33. data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
  34. data/app/components/trek/dropdown_component.rb +29 -0
  35. data/app/components/trek/form/actions_component/actions_component.css +6 -7
  36. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +10 -12
  37. data/app/components/trek/form/content_editor_component/content_editor_component.js +4 -0
  38. data/app/components/trek/form/group_component/group_component.css +34 -0
  39. data/app/components/trek/header_component/header_component.css +10 -4
  40. data/app/components/trek/header_component/header_component.html.slim +17 -10
  41. data/app/components/trek/header_component/header_component.js +29 -5
  42. data/app/components/trek/header_component.rb +20 -3
  43. data/app/components/trek/layout_component/layout_component.css +20 -0
  44. data/app/components/trek/layout_component/layout_component.html.slim +16 -0
  45. data/app/components/trek/layout_component/layout_component.js +35 -6
  46. data/app/components/trek/menu_component/menu_component.css +6 -0
  47. data/app/components/trek/menu_component.rb +29 -4
  48. data/app/components/trek/toaster/toast_component.rb +1 -1
  49. data/app/controllers/concerns/trek/authorization.rb +12 -0
  50. data/app/formatters/prosemirror_to_html/global_id_to_links_formatter.rb +3 -1
  51. data/app/helpers/trek/page_path_helper.rb +4 -3
  52. data/app/helpers/trek/theme_helper.rb +16 -1
  53. data/app/models/concerns/trek/phonable.rb +15 -3
  54. data/app/models/concerns/trek/sectionable.rb +5 -0
  55. data/app/models/concerns/trek/users/validations.rb +1 -1
  56. data/app/models/prosemirror.rb +5 -2
  57. data/app/uploaders/audio_uploader.rb +11 -0
  58. data/app/uploaders/record_image_uploader.rb +3 -0
  59. data/app/views/trek/panels/images/_form.html.slim +1 -1
  60. data/app/views/trek/panels/images/create.turbo_stream.slim +3 -3
  61. data/app/views/trek/panels/images/new.html.slim +1 -1
  62. data/app/views/trek/panels/links/_form.html.slim +1 -1
  63. data/app/views/trek/panels/links/create.turbo_stream.slim +3 -3
  64. data/app/views/trek/panels/links/new.html.slim +1 -1
  65. data/app/views/trek/panels/prompts/_form.html.slim +1 -1
  66. data/app/views/trek/panels/prompts/create.turbo_stream.slim +3 -3
  67. data/app/views/trek/panels/prompts/new.html.slim +1 -1
  68. data/config/locales/trek.en.yml +3 -2
  69. data/config/locales/trek.fr.yml +1 -0
  70. data/docs/customize.md +18 -2
  71. data/docs/deploy.md +7 -2
  72. data/docs/getting-started.md +1 -1
  73. data/docs/index.md +1 -1
  74. data/docs/reference/components.md +15 -0
  75. data/docs/reference/concerns/phonable.md +27 -1
  76. data/docs/reference/environment-variables.md +2 -0
  77. data/docs/reference/forms.md +2 -0
  78. data/docs/reference/generators/install.md +4 -1
  79. data/docs/reference/icons.md +1 -1
  80. data/docs/reference/models/tag.md +1 -1
  81. data/docs/reference/models/user.md +4 -2
  82. data/docs/reference/uploaders.md +31 -6
  83. data/docs/what-is-trek.md +1 -1
  84. data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
  85. data/lib/generators/trek/install/appsignal_generator.rb +102 -0
  86. data/lib/generators/trek/install/model_specs_generator.rb +64 -0
  87. data/lib/generators/trek/install/postmark_generator.rb +3 -0
  88. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  89. data/lib/generators/trek/install/scalingo_generator.rb +1 -1
  90. data/lib/generators/trek/install/uploaders_generator.rb +2 -0
  91. data/lib/generators/trek/install_generator.rb +5 -0
  92. data/lib/generators/trek/taxonomies_generator.rb +7 -0
  93. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  94. data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
  95. data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
  96. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
  97. data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
  98. data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
  99. data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
  100. data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
  101. data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
  102. data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
  103. data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
  104. data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
  105. data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
  106. data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
  107. data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
  108. data/lib/generators/trek/templates/models/tag.rb +6 -1
  109. data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
  110. data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
  111. data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
  112. data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
  113. data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
  114. data/lib/generators/trek/templates/spec/models/user_spec.rb +76 -0
  115. data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
  116. data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
  117. data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
  118. data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
  119. data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
  120. data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
  121. data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
  122. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
  123. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +71 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +45 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
  128. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
  129. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
  130. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
  131. data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
  132. data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
  133. data/lib/tasks/trek/attachments.rake +18 -0
  134. data/lib/trek/cached_attachments.rb +53 -0
  135. data/lib/trek/saved_attachments_reload.rb +44 -0
  136. data/lib/trek/version.rb +1 -1
  137. data/lib/trek.rb +3 -0
  138. data/package.json +1 -1
  139. data/postcss.config.js +36 -0
  140. data/trek.gemspec +5 -0
  141. metadata +76 -3
  142. data/app/assets/icons/trek/theme.svg +0 -11
@@ -58,6 +58,9 @@ module Trek
58
58
 
59
59
  def log_and_abort(error)
60
60
  Rails.logger.info(error)
61
+ # Reported as handled (the delivery is deliberately abandoned, not a failure) and
62
+ # through Rails.error, so the app code stays independent of the monitoring vendor.
63
+ Rails.error.report(error, handled: true)
61
64
  end
62
65
  RUBY
63
66
  end
@@ -5,7 +5,7 @@ module Trek
5
5
  module Generators
6
6
  module Install
7
7
  class RubyGenerator < Rails::Generators::Base
8
- RUBY_VERSION = "4.0.6"
8
+ RUBY_VERSION = "4.0.7"
9
9
 
10
10
  include Trek::Generators::Helpers
11
11
 
@@ -5,7 +5,7 @@ module Trek
5
5
  module Generators
6
6
  module Install
7
7
  class ScalingoGenerator < Rails::Generators::Base
8
- BUILDPACK_URL = "https://github.com/etaminstudio/trek-buildpack#v0.1.0".freeze
8
+ BUILDPACK_URL = "https://github.com/etaminstudio/trek-buildpack/archive/refs/tags/v0.1.0.tar.gz".freeze
9
9
 
10
10
  include Trek::Generators::Helpers
11
11
 
@@ -19,6 +19,8 @@ module Trek
19
19
  "app/uploaders/model_image_uploader.rb"
20
20
  template "record_image_uploader.rb",
21
21
  "app/uploaders/record_image_uploader.rb"
22
+ template "audio_uploader.rb",
23
+ "app/uploaders/audio_uploader.rb"
22
24
  end
23
25
 
24
26
  def uncomment_route
@@ -76,6 +76,10 @@ module Trek
76
76
  generate "trek:install:mailer"
77
77
  end
78
78
 
79
+ def install_monitoring
80
+ generate "trek:install:appsignal"
81
+ end
82
+
79
83
  def install_user
80
84
  generate "trek:install:user"
81
85
  end
@@ -111,6 +115,7 @@ module Trek
111
115
  generate "trek:install:fragment"
112
116
  generate "trek:install:menu_node"
113
117
  generate "trek:install:external_link"
118
+ generate "trek:install:model_specs"
114
119
  end
115
120
 
116
121
  def install_ci
@@ -44,6 +44,13 @@ module Trek
44
44
  "app/policies/admin/tag_category_policy.rb"
45
45
  end
46
46
 
47
+ def copy_policy_specs
48
+ template "spec/policies/admin/tag_policy_spec.rb",
49
+ "spec/policies/admin/tag_policy_spec.rb"
50
+ template "spec/policies/admin/tag_category_policy_spec.rb",
51
+ "spec/policies/admin/tag_category_policy_spec.rb"
52
+ end
53
+
47
54
  def generate_crud
48
55
  generate("trek:scaffold", "TagCategory name:string key:string")
49
56
  generate("trek:scaffold", "Tag name:string key:string category:references")
@@ -20,7 +20,7 @@ stages:
20
20
 
21
21
  # Defaults
22
22
  default:
23
- image: registry.git.etamin.studio/devops/ci-ruby/node-24:4.0.6
23
+ image: registry.git.etamin.studio/devops/ci-ruby/node-24:4.0.7
24
24
 
25
25
  services:
26
26
  - postgres:16
@@ -0,0 +1,25 @@
1
+ # AppSignal's Ruby config file, loaded by the gem's Railtie (not a Rails initializer).
2
+ #
3
+ # The push API key lives in the Rails credentials (the installer injects it from
4
+ # APPSIGNAL_PUSH_API_KEY). APPSIGNAL_* environment variables are read before this
5
+ # block runs, so they keep precedence: the credentials only fill in what is unset,
6
+ # which leaves per-environment overrides (e.g. APPSIGNAL_APP_ENV) possible.
7
+ Appsignal.configure do |config|
8
+ config.name = "<%= application_name.dasherize %>"
9
+
10
+ config.push_api_key = Rails.application.credentials.dig(:appsignal, :push_api_key) if config.push_api_key.blank?
11
+
12
+ # The agent activates itself only when APPSIGNAL_PUSH_API_KEY is set in the
13
+ # environment; with the key in the credentials, activation has to be explicit.
14
+ # Production is monitored as soon as a key exists, the other environments never are.
15
+ config.active = config.push_api_key.present? if Rails.env.production?
16
+
17
+ # The slug is read-only on Scalingo, as on any host with a read-only filesystem, so
18
+ # the agent cannot write its own diagnostic log to log/: it falls back to /tmp on its
19
+ # own and announces it on every boot and every one-off container. Say it upfront.
20
+ config.log_path = "/tmp" if Rails.env.production?
21
+
22
+ # Trek mounts rails-healthcheck at /healthcheck — the platform polls it constantly
23
+ # and it would dominate the throughput graphs. (Rails' own /up is ignored by default.)
24
+ config.ignore_actions += ["Healthcheck::HealthchecksController#check"]
25
+ end
@@ -9,6 +9,10 @@ module Admin
9
9
  set_rack_response ImageUploader.upload_response(:cache, request.env)
10
10
  end
11
11
 
12
+ def audio
13
+ set_rack_response AudioUploader.upload_response(:cache, request.env)
14
+ end
15
+
12
16
  private
13
17
 
14
18
  def set_rack_response((status, headers, body))
@@ -15,7 +15,10 @@ module Admin
15
15
  def theme
16
16
  value = params[:theme].to_s
17
17
  Current.user.update!(theme: value) if User.themes.key?(value)
18
- head :no_content
18
+ respond_to do |format|
19
+ format.turbo_stream
20
+ format.html { head :no_content }
21
+ end
19
22
  end
20
23
 
21
24
  private
@@ -0,0 +1,37 @@
1
+ # Tags every AppSignal transaction with the area it belongs to and the acting user,
2
+ # so errors and slow requests can be filtered per namespace and per actor.
3
+ #
4
+ # Included in ApplicationController (front-office and Admin::BaseController through
5
+ # it) and, from config/initializers/appsignal_context.rb, in Trek::BaseController.
6
+ #
7
+ # Free of charge when AppSignal is inactive (no push API key, i.e. every environment
8
+ # but production): each Appsignal call below returns early.
9
+ module AppsignalContext
10
+ extend ActiveSupport::Concern
11
+
12
+ ADMIN_CONTROLLER_PATHS = ["admin/", "trek/"].freeze
13
+
14
+ included do
15
+ before_action :set_appsignal_context
16
+ end
17
+
18
+ private
19
+
20
+ def set_appsignal_context
21
+ Appsignal.set_namespace(appsignal_namespace)
22
+ Appsignal.add_tags(user_id: appsignal_user_id) if appsignal_user_id
23
+ end
24
+
25
+ def appsignal_namespace
26
+ controller_path.start_with?(*ADMIN_CONTROLLER_PATHS) ? "admin" : "web"
27
+ end
28
+
29
+ def appsignal_user_id
30
+ # Trek::Authentication resolves the session in its own before_action, which may
31
+ # run after this one, so resume it explicitly. Front-office controllers don't
32
+ # include the concern and have no session to resume.
33
+ resume_session if respond_to?(:resume_session, true) && Current.session.nil?
34
+
35
+ Current.user&.id
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ # Makes `fragments` available to seeds and specs: Oaken only defines a model's method once a seed
2
+ # file has used it.
3
+ register Fragment
@@ -0,0 +1,6 @@
1
+ # Defaults for `users.create` and `users.new` (see https://github.com/kaspth/oaken): a unique email and
2
+ # a password passing Trek's validations, so a seed or a spec only sets what it cares about. Specs
3
+ # sign in with `users.password`.
4
+ def users.password = "correct horse battery staple"
5
+
6
+ users.defaults email: -> { "user-#{SecureRandom.hex(4)}@example.com" }, password: users.password
@@ -0,0 +1,5 @@
1
+ # A small page tree for specs, loaded with `seed "cases/page_tree"`: the home page (the tree's root,
2
+ # which has no slug), a section, and a page inside it.
3
+ home = pages.create :home, key: "root", title: "Home"
4
+ company = pages.create :company, title: "Company", parent: home
5
+ pages.create :team, title: "Our team", parent: company
@@ -0,0 +1,5 @@
1
+ # Trek::BaseController is an engine class, so the concern is mixed in on every code
2
+ # reload rather than from a class body the application owns.
3
+ Rails.application.config.to_prepare do
4
+ Trek::BaseController.include(AppsignalContext)
5
+ end
@@ -8,5 +8,5 @@ en:
8
8
  attributes:
9
9
  <%= file_path %>:
10
10
  <%- attributes.each do |attr| -%>
11
- <%= attr.name %>: <%= attr.name.humanize -%>
11
+ <%= attr.name %>: <%= attr.name.humanize %>
12
12
  <%- end -%>
@@ -5,7 +5,7 @@ class TrekFragments < <%= migration_class_name %>
5
5
  t.string :namespace, index: true
6
6
  t.integer :position, null: false
7
7
  t.jsonb :title, default: {}
8
- t.jsonb :content
8
+ t.jsonb :content, default: {}
9
9
 
10
10
  t.timestamps
11
11
 
@@ -18,4 +18,3 @@ class TrekMenuNodes < <%= migration_class_name %>
18
18
  end
19
19
  end
20
20
  end
21
-
@@ -1,6 +1,6 @@
1
1
  class TrekTagCategories < <%= migration_class_name %>
2
2
  def change
3
- create_table "tag_categories" do |t|
3
+ create_table "tag_categories" do |t|
4
4
  t.string :key, index: { unique: true }
5
5
  t.string :name
6
6
  t.integer :position, null: false
@@ -1,6 +1,6 @@
1
1
  class TrekTaggings < <%= migration_class_name %>
2
2
  def change
3
- create_table :taggings do |t|
3
+ create_table :taggings do |t|
4
4
  t.references :taggable, null: false, polymorphic: true
5
5
  t.references :tag, null: false, foreign_key: true
6
6
 
@@ -4,7 +4,8 @@ class TrekUsers < <%= migration_class_name %>
4
4
  add_column :<%= tableized_model_class %>, :theme, :integer, default: 0, null: false
5
5
  add_column :<%= tableized_model_class %>, :locale, :string
6
6
  add_column :<%= tableized_model_class %>, :role, :integer, default: 1, null: false
7
- add_column :<%= tableized_model_class %>, :password_digest, :string, null: false
7
+ # The table is created empty by the trek_create_users migration, so no default is needed.
8
+ add_column :<%= tableized_model_class %>, :password_digest, :string, null: false # rubocop:disable Rails/NotNullColumn
8
9
  add_column :<%= tableized_model_class %>, :last_activity_at, :datetime
9
10
  end
10
11
  end
@@ -6,7 +6,6 @@ class Tag < ApplicationRecord
6
6
  optional: true
7
7
 
8
8
  has_many :taggings, dependent: :destroy
9
- has_many :taggables, through: :taggings
10
9
 
11
10
  validates :name, :key, presence: true
12
11
  validates :key, uniqueness: { scope: :category }
@@ -19,6 +18,12 @@ class Tag < ApplicationRecord
19
18
  scope :in_categories, ->(keys) { TagCategory.where(key: keys).ordered.map(&:tags).reduce(:+) }
20
19
  scope :in_category, ->(key) { TagCategory.find_by(key:)&.tags }
21
20
 
21
+ # The tagged records, of any model. Not a `has_many :through`: Rails can't go through the
22
+ # polymorphic `Tagging#taggable`.
23
+ def taggables
24
+ taggings.includes(:taggable).map(&:taggable)
25
+ end
26
+
22
27
  def to_s
23
28
  name
24
29
  end
@@ -13,7 +13,7 @@ module Admin
13
13
  end
14
14
 
15
15
  def destroy?
16
- user.privileged? && record.taggables.empty?
16
+ user.privileged? && record.taggings.empty?
17
17
  end
18
18
 
19
19
  def reorder?
@@ -16,7 +16,7 @@ module Admin
16
16
  relation_scope do |relation|
17
17
  next relation if user.privileged?
18
18
 
19
- relation.where(user:)
19
+ relation.none
20
20
  end
21
21
  end
22
22
  end
@@ -0,0 +1,24 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Page do
4
+ before { seed "cases/page_tree" }
5
+
6
+ it "slugs the title" do
7
+ expect(pages.team.slug).to eq("our-team")
8
+ end
9
+
10
+ it "builds the full path from the slugs of its ancestors, root excluded" do
11
+ expect(pages.team.full_path).to eq("company/our-team")
12
+ end
13
+
14
+ it "is found at its full path" do
15
+ expect(described_class.find_at_full_path("company/our-team")).to eq(pages.team)
16
+ end
17
+
18
+ it "keeps its former path when moved, so old URLs can be redirected" do
19
+ team = pages.team
20
+ team.update!(parent: pages.home)
21
+
22
+ expect(team.reload.paths.map(&:path)).to eq(["our-team", "company/our-team"])
23
+ end
24
+ end
@@ -0,0 +1,76 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe User do
4
+ subject(:user) { users.new }
5
+
6
+ it "is valid with an email and a password" do
7
+ expect(user).to be_valid
8
+ end
9
+
10
+ describe "validations" do
11
+ it "requires an email" do
12
+ user.email = nil
13
+
14
+ expect(user).to be_invalid
15
+ end
16
+
17
+ it "requires a well-formed email" do
18
+ user.email = "not an email"
19
+
20
+ expect(user).to be_invalid
21
+ end
22
+
23
+ it "requires a unique email" do
24
+ users.create(email: user.email)
25
+
26
+ expect(user).to be_invalid
27
+ end
28
+
29
+ it "requires a password of at least 8 characters on creation" do
30
+ user.password = "short"
31
+
32
+ expect(user).to be_invalid
33
+ end
34
+
35
+ it "requires a password of at least 8 characters when changing it" do
36
+ user = users.create
37
+ user.password = "short"
38
+
39
+ expect(user).to be_invalid
40
+ end
41
+
42
+ it "does not require the password when changing something else" do
43
+ user = described_class.find(users.create.id)
44
+ user.name = "Ada"
45
+
46
+ expect(user).to be_valid
47
+ end
48
+ end
49
+
50
+ describe "roles" do
51
+ it "defaults to guest" do
52
+ expect(user.role).to eq("guest")
53
+ end
54
+
55
+ it "treats admins and editors as privileged" do
56
+ expect(%w[admin editor guest].map { |role| users.new(role:).privileged? }).to eq([true, true, false])
57
+ end
58
+
59
+ it "scopes privileged users" do
60
+ admin, editor = users.create(role: :admin), users.create(role: :editor)
61
+ users.create(role: :reader)
62
+
63
+ expect(described_class.privileged).to contain_exactly(admin, editor)
64
+ end
65
+ end
66
+
67
+ describe "#to_s" do
68
+ it "reads the name" do
69
+ expect(users.new(name: "Ada").to_s).to eq("Ada")
70
+ end
71
+
72
+ it "falls back to the email" do
73
+ expect(users.new(email: "ada@example.com").to_s).to eq("ada@example.com")
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,17 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::ExternalLinkPolicy do
4
+ before { ExternalLink.create!(url: "https://example.com") }
5
+
6
+ it "shows privileged users every external link" do
7
+ policy = described_class.new(user: users.new(role: :editor))
8
+
9
+ expect(policy.apply_scope(ExternalLink.all, type: :active_record_relation)).to match_array(ExternalLink.all)
10
+ end
11
+
12
+ it "shows other roles no external link" do
13
+ policy = described_class.new(user: users.new(role: :reader))
14
+
15
+ expect(policy.apply_scope(ExternalLink.all, type: :active_record_relation)).to be_empty
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::FragmentPolicy do
4
+ def policy(role, record = Fragment)
5
+ described_class.new(record, user: users.new(role:))
6
+ end
7
+
8
+ %w[admin editor].each do |role|
9
+ it "lets #{role}s list, create, edit and delete fragments" do
10
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(true))
11
+ end
12
+ end
13
+
14
+ %w[user reader guest].each do |role|
15
+ it "keeps #{role}s away from fragments" do
16
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(false))
17
+ end
18
+ end
19
+
20
+ it "lets privileged users edit a fragment" do
21
+ expect(policy("editor").permitted_attributes).to contain_exactly(:content, :key, :namespace, :title)
22
+ end
23
+
24
+ it "lets other roles edit nothing" do
25
+ expect(policy("reader").permitted_attributes).to be_empty
26
+ end
27
+
28
+ it "shows other roles no fragment" do
29
+ Fragment.create!(key: "footer", namespace: "layout", title: "Footer")
30
+
31
+ expect(policy("reader").apply_scope(Fragment.all, type: :active_record_relation)).to be_empty
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::PagePolicy do
4
+ def policy(role, record = Page)
5
+ described_class.new(record, user: users.new(role:))
6
+ end
7
+
8
+ %w[admin editor].each do |role|
9
+ it "lets #{role}s list, create, edit and delete pages" do
10
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(true))
11
+ end
12
+ end
13
+
14
+ %w[user reader guest].each do |role|
15
+ it "keeps #{role}s away from pages" do
16
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(false))
17
+ end
18
+ end
19
+
20
+ it "lets privileged users edit a page's content" do
21
+ expect(policy("editor").permitted_attributes).to include(:title, :slug, :description, :content)
22
+ end
23
+
24
+ it "lets other roles edit nothing" do
25
+ expect(policy("reader").permitted_attributes).to be_empty
26
+ end
27
+
28
+ it "shows privileged users every page" do
29
+ seed "cases/page_tree"
30
+
31
+ expect(policy("editor").apply_scope(Page.all, type: :active_record_relation)).to match_array(Page.all)
32
+ end
33
+
34
+ it "shows other roles no page" do
35
+ seed "cases/page_tree"
36
+
37
+ expect(policy("reader").apply_scope(Page.all, type: :active_record_relation)).to be_empty
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::PreviewPolicy do
4
+ %w[admin editor user reader guest].each do |role|
5
+ it "lets #{role}s preview the home page" do
6
+ expect(described_class.new(user: users.new(role:)).apply(:home?)).to be(true)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::TagCategoryPolicy do
4
+ let(:category) { TagCategory.create!(name: "Topics", key: "topics") }
5
+
6
+ def policy(role, record = TagCategory)
7
+ described_class.new(record, user: users.new(role:))
8
+ end
9
+
10
+ %w[admin editor].each do |role|
11
+ it "lets #{role}s list, create and edit tag categories" do
12
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(true))
13
+ end
14
+ end
15
+
16
+ %w[user reader guest].each do |role|
17
+ it "keeps #{role}s away from tag categories" do
18
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(false))
19
+ end
20
+ end
21
+
22
+ it "lets privileged users delete an empty category" do
23
+ expect(policy("editor", category).apply(:destroy?)).to be(true)
24
+ end
25
+
26
+ it "keeps a category with tags from being deleted" do
27
+ Tag.create!(name: "News", key: "news", category:)
28
+
29
+ expect(policy("admin", category.reload).apply(:destroy?)).to be(false)
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::TagPolicy do
4
+ let(:tag) { Tag.create!(name: "News", key: "news") }
5
+
6
+ def policy(role, record = Tag)
7
+ described_class.new(record, user: users.new(role:))
8
+ end
9
+
10
+ %w[admin editor].each do |role|
11
+ it "lets #{role}s list, create and edit tags" do
12
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(true))
13
+ end
14
+ end
15
+
16
+ %w[user reader guest].each do |role|
17
+ it "keeps #{role}s away from tags" do
18
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(false))
19
+ end
20
+ end
21
+
22
+ it "lets privileged users delete an unused tag" do
23
+ expect(policy("editor", tag).apply(:destroy?)).to be(true)
24
+ end
25
+
26
+ it "keeps a tag in use from being deleted" do
27
+ allow(tag).to receive(:taggings).and_return([Tagging.new])
28
+
29
+ expect(policy("admin", tag).apply(:destroy?)).to be(false)
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::UserPolicy do
4
+ let(:admin) { users.create(role: :admin) }
5
+ let(:editor) { users.create(role: :editor) }
6
+
7
+ def policy(user, record = User)
8
+ described_class.new(record, user:)
9
+ end
10
+
11
+ it "lets admins list, create and delete users" do
12
+ expect(%i[index? create? destroy?].map { |rule| policy(admin).apply(rule) }).to all(be(true))
13
+ end
14
+
15
+ it "lets admins edit any user" do
16
+ expect(policy(admin, editor).apply(:update?)).to be(true)
17
+ end
18
+
19
+ it "keeps editors away from the users list" do
20
+ expect(%i[index? create? destroy?].map { |rule| policy(editor).apply(rule) }).to all(be(false))
21
+ end
22
+
23
+ it "lets editors see and edit themselves" do
24
+ expect(%i[show? update?].map { |rule| policy(editor, editor).apply(rule) }).to all(be(true))
25
+ end
26
+
27
+ it "keeps editors away from other users" do
28
+ expect(%i[show? update?].map { |rule| policy(editor, admin).apply(rule) }).to all(be(false))
29
+ end
30
+
31
+ it "lets admins change a user's role" do
32
+ expect(policy(admin, editor).permitted_attributes).to include(:role)
33
+ end
34
+
35
+ it "lets admins invite the users they create" do
36
+ expect(policy(admin, User).permitted_attributes).to include(:send_invite)
37
+ end
38
+
39
+ it "lets editors change their own details but not their role" do
40
+ expect(policy(editor, editor).permitted_attributes).to contain_exactly(:name, :email, :locale, :theme)
41
+ end
42
+
43
+ it "lets editors change nothing on another user" do
44
+ expect(policy(editor, admin).permitted_attributes).to be_empty
45
+ end
46
+
47
+ it "shows admins the privileged users only" do
48
+ reader = users.create(role: :reader)
49
+
50
+ expect(described_class.new(user: admin).apply_scope(User.all, type: :active_record_relation)).not_to include(reader)
51
+ end
52
+
53
+ it "shows editors no user" do
54
+ admin
55
+
56
+ expect(described_class.new(user: editor).apply_scope(User.all, type: :active_record_relation)).to be_empty
57
+ end
58
+ end