admin_suite 0.4.0 → 0.6.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +203 -18
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/assets/vendor/chart.umd.min.js +14 -0
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +71 -17
  10. data/app/helpers/admin_suite/base_helper.rb +187 -10
  11. data/app/javascript/admin_suite_application.js +3 -0
  12. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/resources/index.html.erb +25 -5
  15. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  16. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  17. data/config/routes.rb +8 -0
  18. data/lib/admin/base/filter_builder.rb +48 -5
  19. data/lib/admin/base/resource.rb +46 -32
  20. data/lib/admin_suite/auth/host_user.rb +42 -0
  21. data/lib/admin_suite/auth/strategy.rb +1 -1
  22. data/lib/admin_suite/auth.rb +15 -0
  23. data/lib/admin_suite/authorization_context.rb +28 -0
  24. data/lib/admin_suite/configuration.rb +66 -8
  25. data/lib/admin_suite/engine.rb +1 -1
  26. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  27. data/lib/admin_suite/mcp/authorization.rb +68 -0
  28. data/lib/admin_suite/mcp/serializer.rb +138 -0
  29. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  30. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  31. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  32. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  33. data/lib/admin_suite/mcp.rb +53 -0
  34. data/lib/admin_suite/query.rb +71 -0
  35. data/lib/admin_suite/renderer_registry.rb +11 -0
  36. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  37. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +23 -7
  40. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  41. data/test/controllers/resources_controller_test.rb +19 -13
  42. data/test/integration/association_linking_test.rb +292 -0
  43. data/test/integration/authentication_test.rb +10 -0
  44. data/test/integration/authorization_test.rb +20 -3
  45. data/test/integration/chart_panel_test.rb +491 -0
  46. data/test/integration/dashboard_test.rb +9 -2
  47. data/test/integration/index_query_characterization_test.rb +229 -0
  48. data/test/integration/index_table_test.rb +289 -0
  49. data/test/integration/mcp_aggregate_test.rb +44 -0
  50. data/test/integration/mcp_authorization_test.rb +102 -0
  51. data/test/integration/mcp_endpoint_test.rb +89 -0
  52. data/test/integration/mcp_get_record_test.rb +62 -0
  53. data/test/integration/mcp_instrumentation_test.rb +107 -0
  54. data/test/integration/mcp_list_records_test.rb +75 -0
  55. data/test/integration/mcp_parity_test.rb +155 -0
  56. data/test/integration/mcp_release_test.rb +131 -0
  57. data/test/integration/navigation_sections_test.rb +21 -0
  58. data/test/integration/read_only_resource_test.rb +128 -2
  59. data/test/integration/searchable_select_search_test.rb +369 -0
  60. data/test/integration/show_hide_blank_test.rb +195 -0
  61. data/test/integration/toggle_test.rb +106 -0
  62. data/test/lib/auth_host_user_test.rb +52 -0
  63. data/test/lib/auth_http_basic_test.rb +10 -0
  64. data/test/lib/auth_test.rb +20 -3
  65. data/test/lib/authorization_context_test.rb +127 -0
  66. data/test/lib/definition_loader_test.rb +12 -0
  67. data/test/lib/engine_defaults_test.rb +1 -2
  68. data/test/lib/form_field_renderer_test.rb +83 -11
  69. data/test/lib/format_table_cell_test.rb +49 -0
  70. data/test/lib/index_includes_test.rb +223 -0
  71. data/test/lib/mcp_serializer_test.rb +107 -0
  72. data/test/lib/query_test.rb +91 -0
  73. data/test/lib/removed_deprecations_test.rb +16 -0
  74. data/test/lib/renderer_test.rb +23 -7
  75. data/test/publish_workflow_test.rb +63 -0
  76. data/test/test_helper.rb +73 -8
  77. metadata +76 -9
  78. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
  79. data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
  80. data/test/lib/resource_exportable_deprecation_test.rb +0 -39
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises `hide_blank:` on `panel`/`section` `fields:` rows. Mirrors the
6
+ # `ReadOnlyResourceFixtures::Widget` shape (test/integration/read_only_resource_test.rb)
7
+ # so the show route resolves through the ordinary numeric-id `klass.find` path,
8
+ # not the slug/uuid/token fallback.
9
+ module HideBlankFixtures
10
+ class Widget
11
+ extend ActiveModel::Naming
12
+
13
+ attr_reader :id
14
+
15
+ def initialize(id: 1)
16
+ @id = id
17
+ end
18
+
19
+ def self.all
20
+ [ new ]
21
+ end
22
+
23
+ def self.column_names
24
+ %w[id]
25
+ end
26
+
27
+ def self.primary_key
28
+ "id"
29
+ end
30
+
31
+ def self.columns_hash
32
+ { "id" => Struct.new(:type).new(:integer) }
33
+ end
34
+
35
+ def self.find(id)
36
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
37
+
38
+ new
39
+ end
40
+
41
+ def to_param
42
+ id.to_s
43
+ end
44
+
45
+ def attributes
46
+ { "id" => id }
47
+ end
48
+
49
+ # Non-blank control value -- always kept, hide_blank or not.
50
+ def title
51
+ "Widget One"
52
+ end
53
+
54
+ def blank_string
55
+ ""
56
+ end
57
+
58
+ def whitespace_string
59
+ " "
60
+ end
61
+
62
+ def nil_field
63
+ nil
64
+ end
65
+
66
+ def empty_array
67
+ []
68
+ end
69
+
70
+ def empty_hash
71
+ {}
72
+ end
73
+
74
+ def false_flag
75
+ false
76
+ end
77
+
78
+ def zero_count
79
+ 0
80
+ end
81
+
82
+ def zero_float
83
+ 0.0
84
+ end
85
+ end
86
+ end
87
+
88
+ module Admin
89
+ module Resources
90
+ class HideBlankWidgetResource < Admin::Base::Resource
91
+ model HideBlankFixtures::Widget
92
+ portal :ops
93
+ section :observability
94
+
95
+ FIELDS = %i[
96
+ title blank_string whitespace_string nil_field
97
+ empty_array empty_hash false_flag zero_count zero_float
98
+ ].freeze
99
+
100
+ show do
101
+ sidebar do
102
+ panel :sidebar_hidden, title: "Sidebar hidden", fields: FIELDS, hide_blank: true
103
+ panel :sidebar_default, title: "Sidebar default", fields: FIELDS
104
+ end
105
+
106
+ section :main_hidden, title: "Main hidden", fields: FIELDS, hide_blank: true
107
+ section :main_default, title: "Main default", fields: FIELDS
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ module AdminSuite
114
+ class ShowHideBlankTest < ActionDispatch::IntegrationTest
115
+ PATH = "/internal/admin_suite/ops/hide_blank_widgets/1"
116
+
117
+ test "hide_blank: true hides nil, empty string, empty array, and empty hash" do
118
+ get PATH
119
+ assert_response :success
120
+
121
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
122
+ refute_nil hidden_section, "expected to find the 'Main hidden' panel before 'Main default' in the body"
123
+
124
+ refute_includes hidden_section, "Nil field"
125
+ refute_includes hidden_section, "Blank string"
126
+ refute_includes hidden_section, "Empty array"
127
+ refute_includes hidden_section, "Empty hash"
128
+ end
129
+
130
+ test "hide_blank: true keeps false, 0, 0.0, and whitespace-only strings" do
131
+ get PATH
132
+ assert_response :success
133
+
134
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
135
+ refute_nil hidden_section
136
+
137
+ assert_includes hidden_section, "False flag"
138
+ assert_includes hidden_section, "Zero count"
139
+ assert_includes hidden_section, "Zero float"
140
+ assert_includes hidden_section, "Whitespace string"
141
+ # And the boolean must still render as the meaningful "No" state, not
142
+ # silently vanish.
143
+ assert_includes hidden_section, "No"
144
+ end
145
+
146
+ test "hide_blank: true keeps genuinely non-blank fields" do
147
+ get PATH
148
+ assert_response :success
149
+
150
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
151
+ assert_includes hidden_section, "Title"
152
+ assert_includes hidden_section, "Widget One"
153
+ end
154
+
155
+ test "default (no hide_blank option) hides nothing -- every label row still renders" do
156
+ get PATH
157
+ assert_response :success
158
+
159
+ default_section = response.body[/Main default.*?(?=<\/html>)/m]
160
+ refute_nil default_section
161
+
162
+ %w[Title Blank\ string Whitespace\ string Nil\ field Empty\ array Empty\ hash False\ flag Zero\ count Zero\ float].each do |label|
163
+ assert_includes default_section, label, "expected label '#{label}' to render in the default (no hide_blank) panel"
164
+ end
165
+ end
166
+
167
+ test "hide_blank: true on a sidebar panel hides the same blank fields as main" do
168
+ get PATH
169
+ assert_response :success
170
+
171
+ sidebar_hidden = response.body[/Sidebar hidden.*?(?=Sidebar default)/m]
172
+ refute_nil sidebar_hidden
173
+
174
+ refute_includes sidebar_hidden, "Nil field"
175
+ refute_includes sidebar_hidden, "Blank string"
176
+ refute_includes sidebar_hidden, "Empty array"
177
+ refute_includes sidebar_hidden, "Empty hash"
178
+ assert_includes sidebar_hidden, "False flag"
179
+ assert_includes sidebar_hidden, "Zero count"
180
+ assert_includes sidebar_hidden, "Zero float"
181
+ end
182
+
183
+ test "default sidebar panel (no hide_blank) hides nothing" do
184
+ get PATH
185
+ assert_response :success
186
+
187
+ sidebar_default = response.body[/Sidebar default.*?(?=<div class="bg-white)/m] || response.body[/Sidebar default.*\z/m]
188
+ refute_nil sidebar_default
189
+
190
+ %w[Nil\ field Blank\ string Empty\ array Empty\ hash].each do |label|
191
+ assert_includes sidebar_default, label
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Final review Finding 3: the gem hard-depends on turbo-rails (`turbo_stream`
6
+ # in `ResourcesController#toggle`, `turbo_frame_tag` throughout the resource
7
+ # views) but never declared it in the gemspec. Before that dependency is
8
+ # declared, this test exercises the exact path the review flagged as broken
9
+ # in a turbo-less host: `format.turbo_stream` inside `respond_to` raises the
10
+ # moment it's evaluated, because the `:turbo_stream` MIME type is unregistered
11
+ # without turbo-rails loaded. See `test/test_helper.rb`'s (now removed)
12
+ # `TurboFrameTestHelper`, which only ever patched `turbo_frame_tag` -- it
13
+ # never made `turbo_stream`/the MIME type real, so this branch was untestable
14
+ # until the real dependency was declared.
15
+ module ToggleFixtures
16
+ class Widget
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id
20
+ attr_accessor :active
21
+
22
+ def initialize(id: 1, active: false)
23
+ @id = id
24
+ @active = active
25
+ end
26
+
27
+ def self.all
28
+ ReadOnlyResourceFixtures::Relation.new([ new ])
29
+ end
30
+
31
+ def self.column_names
32
+ %w[id active]
33
+ end
34
+
35
+ def self.primary_key
36
+ "id"
37
+ end
38
+
39
+ def self.columns_hash
40
+ { "id" => Struct.new(:type).new(:integer) }
41
+ end
42
+
43
+ def self.find(id)
44
+ return new(id: 1, active: false) if id.to_s == "1"
45
+
46
+ raise ActiveRecord::RecordNotFound
47
+ end
48
+
49
+ def to_param
50
+ id.to_s
51
+ end
52
+
53
+ # `dom_id` (via `ActionView::RecordIdentifier#record_key_for_dom_id`)
54
+ # calls `to_key` on the record -- `extend ActiveModel::Naming` alone
55
+ # only supplies `model_name`, not `to_key`.
56
+ def to_key
57
+ [ id ]
58
+ end
59
+
60
+ def update!(attrs)
61
+ attrs.each { |key, value| public_send("#{key}=", value) }
62
+ true
63
+ end
64
+ end
65
+ end
66
+
67
+ module Admin
68
+ module Resources
69
+ class ToggleWidgetResource < Admin::Base::Resource
70
+ model ToggleFixtures::Widget
71
+ portal :ops
72
+ section :observability
73
+
74
+ index do
75
+ columns do
76
+ column :active, type: :toggle
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ module AdminSuite
84
+ class ToggleTest < ActionDispatch::IntegrationTest
85
+ include ActionView::RecordIdentifier
86
+
87
+ BASE_PATH = "/internal/admin_suite/ops/toggle_widgets"
88
+
89
+ test "toggle's turbo_stream branch replaces the toggle cell with the flipped state" do
90
+ post "#{BASE_PATH}/1/toggle",
91
+ params: { field: "active" },
92
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
93
+
94
+ assert_response :success
95
+ assert_equal "text/vnd.turbo-stream.html", response.media_type
96
+
97
+ target = dom_id(ToggleFixtures::Widget.new(id: 1), "active_toggle")
98
+ assert_includes response.body, "<turbo-stream"
99
+ assert_includes response.body, %(action="replace")
100
+ assert_includes response.body, %(target="#{target}")
101
+ # The record started with `active: false`; the toggle flips it to
102
+ # `true` before rendering, so the replaced cell must reflect "on".
103
+ assert_includes response.body, "is-on"
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class AuthNormalizeActorTest < ActiveSupport::TestCase
6
+ test "normalizes the authenticated-but-anonymous sentinel to nil" do
7
+ assert_nil AdminSuite::Auth.normalize_actor(true)
8
+ assert_nil AdminSuite::Auth.normalize_actor(false)
9
+ assert_nil AdminSuite::Auth.normalize_actor(nil)
10
+ end
11
+
12
+ test "passes a real actor through untouched" do
13
+ user = Struct.new(:email).new("ravi@techwright.io")
14
+ assert_same user, AdminSuite::Auth.normalize_actor(user)
15
+ end
16
+ end
17
+
18
+ class AuthHostUserTest < ActiveSupport::TestCase
19
+ Controller = Struct.new(:performed) do
20
+ def performed? = !!performed
21
+ def head(*) = nil
22
+ end
23
+
24
+ test "is registered under :host_user" do
25
+ assert_equal AdminSuite::Auth::HostUser, AdminSuite::Auth.lookup(:host_user)
26
+ end
27
+
28
+ test "returns the host user the resolver produced" do
29
+ user = Struct.new(:email).new("ravi@techwright.io")
30
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { user })
31
+
32
+ assert_same user, strategy.authenticate!(Controller.new(false))
33
+ end
34
+
35
+ test "denies when the resolver returns nothing" do
36
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { nil })
37
+
38
+ assert_nil strategy.authenticate!(Controller.new(false))
39
+ end
40
+
41
+ test "denies rather than raising when the resolver blows up" do
42
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { raise "boom" })
43
+
44
+ assert_nil strategy.authenticate!(Controller.new(false))
45
+ end
46
+
47
+ test "denies loudly when no resolver is configured" do
48
+ strategy = AdminSuite::Auth::HostUser.new({})
49
+
50
+ assert_nil strategy.authenticate!(Controller.new(false))
51
+ end
52
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  raise "unset ADMIN_SUITE_USERNAME in test env" if ENV["ADMIN_SUITE_USERNAME"]
4
+ raise "unset ADMIN_SUITE_PASSWORD in test env" if ENV["ADMIN_SUITE_PASSWORD"]
4
5
 
5
6
  require "test_helper"
6
7
 
@@ -55,6 +56,15 @@ module AdminSuite
55
56
  assert_equal :forbidden, controller.rendered_status
56
57
  end
57
58
 
59
+ test "denies with 403 when password is blank" do
60
+ strategy = Auth::HttpBasic.new(username: "ravi", password: nil)
61
+ controller = ControllerDouble.new(given_username: "ravi", given_password: nil)
62
+
63
+ assert_nil strategy.authenticate!(controller)
64
+ assert_equal :forbidden, controller.rendered_status
65
+ refute controller.challenged
66
+ end
67
+
58
68
  test "is registered as :http_basic" do
59
69
  assert_equal Auth::HttpBasic, Auth.lookup(:http_basic)
60
70
  end
@@ -9,9 +9,11 @@ module AdminSuite
9
9
  end
10
10
 
11
11
  test "register and lookup a strategy by name" do
12
- AdminSuite::Auth.register(:fake, FakeStrategy)
13
- assert_equal FakeStrategy, AdminSuite::Auth.lookup(:fake)
14
- assert_equal FakeStrategy, AdminSuite::Auth.lookup("fake")
12
+ with_auth_registry_snapshot do
13
+ AdminSuite::Auth.register(:fake, FakeStrategy)
14
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup(:fake)
15
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup("fake")
16
+ end
15
17
  end
16
18
 
17
19
  test "lookup of unknown strategy raises UnknownStrategyError" do
@@ -20,10 +22,25 @@ module AdminSuite
20
22
  end
21
23
  end
22
24
 
25
+ test "registered lists registered strategy names" do
26
+ with_auth_registry_snapshot do
27
+ AdminSuite::Auth.register(:fake, FakeStrategy)
28
+
29
+ assert_includes AdminSuite::Auth.registered, :fake
30
+ assert_includes AdminSuite::Auth.registered, :http_basic
31
+ end
32
+ end
33
+
23
34
  test "base strategy exposes options and requires authenticate!" do
24
35
  strategy = AdminSuite::Auth::Strategy.new(username: "u")
25
36
  assert_equal({ username: "u" }, strategy.options)
26
37
  assert_raises(NotImplementedError) { strategy.authenticate!(nil) }
27
38
  end
39
+
40
+ test "base strategy symbolizes string-keyed options" do
41
+ strategy = AdminSuite::Auth::Strategy.new("username" => "u")
42
+
43
+ assert_equal({ username: "u" }, strategy.options)
44
+ end
28
45
  end
29
46
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AuthorizationContextFixtures
6
+ # A plain object implementing #call -- exactly the shape a host might write
7
+ # as an authorize adapter. Has no #parameters of its own; the writer must
8
+ # fall back to introspecting #call's method object.
9
+ class CorrectArityCallable
10
+ def call(actor:, action:, resource:, record:, context:)
11
+ true
12
+ end
13
+ end
14
+
15
+ class OldArityCallable
16
+ def call(actor:, action:, resource:, record:, controller:)
17
+ true
18
+ end
19
+ end
20
+
21
+ class SplatCallable
22
+ def call(**)
23
+ true
24
+ end
25
+ end
26
+ end
27
+
28
+ class AuthorizationContextTest < ActiveSupport::TestCase
29
+ test "reports its surface" do
30
+ web = AdminSuite::AuthorizationContext.new(surface: :web, controller: :ctrl)
31
+ mcp = AdminSuite::AuthorizationContext.new(surface: :mcp)
32
+
33
+ assert_predicate web, :web?
34
+ refute_predicate web, :mcp?
35
+ assert_equal :ctrl, web.controller
36
+ assert_predicate mcp, :mcp?
37
+ assert_nil mcp.controller
38
+ end
39
+
40
+ test "rejects an unknown surface" do
41
+ assert_raises(ArgumentError) { AdminSuite::AuthorizationContext.new(surface: :carrier_pigeon) }
42
+ end
43
+
44
+ test "assigning an old-arity authorize hook raises immediately" do
45
+ error = assert_raises(ArgumentError) do
46
+ AdminSuite.config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
47
+ end
48
+
49
+ # Assert on the COMPUTED diagnosis, not the message's fixed footer -- that
50
+ # footer explains the controller:/context: migration on every failure, so
51
+ # `assert_match(/controller:/)` alone would pass for any rejection at all.
52
+ assert_match(/Missing: \[:context\]/, error.message)
53
+ assert_match(/Unexpected: \[:controller\]/, error.message)
54
+ ensure
55
+ AdminSuite.config.authorize = nil
56
+ end
57
+
58
+ test "accepts a correct-arity hook and a nil hook" do
59
+ AdminSuite.config.authorize = ->(actor:, action:, resource:, record:, context:) { true }
60
+ assert_respond_to AdminSuite.config.authorize, :call
61
+ AdminSuite.config.authorize = nil
62
+ assert_nil AdminSuite.config.authorize
63
+ end
64
+
65
+ test "accepts a hook that takes a keyword splat" do
66
+ AdminSuite.config.authorize = ->(**) { true }
67
+ assert_respond_to AdminSuite.config.authorize, :call
68
+
69
+ AdminSuite.config.authorize = ->(action:, **) { action == :read }
70
+ assert_respond_to AdminSuite.config.authorize, :call
71
+ ensure
72
+ AdminSuite.config.authorize = nil
73
+ end
74
+
75
+ test "still rejects controller: even behind a splat" do
76
+ error = assert_raises(ArgumentError) do
77
+ AdminSuite.config.authorize = ->(controller:, **) { true }
78
+ end
79
+
80
+ # The splat means nothing is missing; `controller:` being named is the
81
+ # whole defect, so assert exactly that rather than the shared footer.
82
+ assert_match(/Missing: \[\]/, error.message)
83
+ assert_match(/Unexpected: \[:controller\]/, error.message)
84
+ ensure
85
+ AdminSuite.config.authorize = nil
86
+ end
87
+
88
+ test "accepts a correct-arity callable object without #parameters" do
89
+ AdminSuite.config.authorize = AuthorizationContextFixtures::CorrectArityCallable.new
90
+ assert_respond_to AdminSuite.config.authorize, :call
91
+ ensure
92
+ AdminSuite.config.authorize = nil
93
+ end
94
+
95
+ test "rejects an old-arity callable object, proving the fallback validates rather than bypasses" do
96
+ error = assert_raises(ArgumentError) do
97
+ AdminSuite.config.authorize = AuthorizationContextFixtures::OldArityCallable.new
98
+ end
99
+
100
+ # This test's name claims the fallback VALIDATES rather than bypasses, so
101
+ # it must assert the guard actually read this object's `#call` signature.
102
+ # Matching the footer would also pass for a fallback that rejected every
103
+ # callable outright -- the opposite of validation.
104
+ assert_match(/Missing: \[:context\]/, error.message)
105
+ assert_match(/Unexpected: \[:controller\]/, error.message)
106
+ ensure
107
+ AdminSuite.config.authorize = nil
108
+ end
109
+
110
+ test "accepts a callable object whose #call takes a keyword splat" do
111
+ AdminSuite.config.authorize = AuthorizationContextFixtures::SplatCallable.new
112
+ assert_respond_to AdminSuite.config.authorize, :call
113
+ ensure
114
+ AdminSuite.config.authorize = nil
115
+ end
116
+
117
+ test "rejects a non-callable with an ArgumentError naming the problem" do
118
+ error = assert_raises(ArgumentError) do
119
+ AdminSuite.config.authorize = "nope"
120
+ end
121
+
122
+ assert_match(/callable/, error.message)
123
+ assert_match(/String/, error.message)
124
+ ensure
125
+ AdminSuite.config.authorize = nil
126
+ end
127
+ end
@@ -41,6 +41,18 @@ module AdminSuite
41
41
  before.each_value { |definition| AdminSuite::PortalRegistry.register(definition) }
42
42
  end
43
43
  else
44
+ # :dashboards/:actions have no accumulating registry to snapshot --
45
+ # unlike :resources/:portals (which pick up real fixtures registered
46
+ # by *other* test files at load time), reset! for these two kinds
47
+ # only clears a single flag/definition object
48
+ # (`root_dashboard_loaded`/`root_dashboard_definition`,
49
+ # `handlers_loaded`) that is entirely local to whichever test set it,
50
+ # via its own `with_globs`/`with_dashboard`-style ensure block. So
51
+ # there is nothing cross-test left to preserve, and "restore" here
52
+ # deliberately degrades to a second `reset!` call rather than a real
53
+ # snapshot+restore -- do not read this branch as returning a
54
+ # value-preserving restore the way the :resources/:portals branches
55
+ # above do.
44
56
  -> { AdminSuite::DefinitionLoader.reset!(kind) }
45
57
  end
46
58
  end
@@ -31,8 +31,7 @@ module AdminSuite
31
31
 
32
32
  test "dead configuration and DSL members are gone" do
33
33
  refute AdminSuite::Configuration.new.respond_to?(:tailwind_cdn)
34
- assert Admin::Base::Resource.respond_to?(:exportable),
35
- "exportable is a deprecated no-op, not removed -- see ResourceExportableDeprecationTest"
34
+ refute Admin::Base::Resource.respond_to?(:exportable)
36
35
  refute Admin::Base::Resource::ColumnDefinition.members.include?(:render)
37
36
  end
38
37
  end