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
@@ -35,6 +35,10 @@ module ReadOnlyResourceFixtures
35
35
  new
36
36
  end
37
37
 
38
+ def self.where(id:)
39
+ id.filter_map { |value| find(value) }
40
+ end
41
+
38
42
  def to_param
39
43
  id.to_s
40
44
  end
@@ -43,7 +47,16 @@ module ReadOnlyResourceFixtures
43
47
  { "id" => id, "name" => name }
44
48
  end
45
49
 
50
+ def self.ping_calls
51
+ @ping_calls || 0
52
+ end
53
+
54
+ def self.ping_calls=(value)
55
+ @ping_calls = value
56
+ end
57
+
46
58
  def ping
59
+ self.class.ping_calls += 1
47
60
  true
48
61
  end
49
62
  end
@@ -65,6 +78,88 @@ module Admin
65
78
 
66
79
  actions do
67
80
  action :ping
81
+ bulk_action :ping
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ module WritableResourceFixtures
88
+ class Widget
89
+ extend ActiveModel::Naming
90
+
91
+ attr_reader :id, :name
92
+
93
+ def initialize(id: 1, name: "Writable widget")
94
+ @id = id
95
+ @name = name
96
+ end
97
+
98
+ def self.all
99
+ ReadOnlyResourceFixtures::Relation.new([ new ])
100
+ end
101
+
102
+ def self.column_names
103
+ %w[id name]
104
+ end
105
+
106
+ def self.primary_key
107
+ "id"
108
+ end
109
+
110
+ def self.columns_hash
111
+ { "id" => Struct.new(:type).new(:integer) }
112
+ end
113
+
114
+ def self.find(id)
115
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
116
+
117
+ new
118
+ end
119
+
120
+ def self.where(id:)
121
+ id.filter_map { |value| find(value) }
122
+ end
123
+
124
+ def self.ping_calls
125
+ @ping_calls || 0
126
+ end
127
+
128
+ def self.ping_calls=(value)
129
+ @ping_calls = value
130
+ end
131
+
132
+ def to_param
133
+ id.to_s
134
+ end
135
+
136
+ def attributes
137
+ { "id" => id, "name" => name }
138
+ end
139
+
140
+ def ping
141
+ self.class.ping_calls += 1
142
+ true
143
+ end
144
+ end
145
+ end
146
+
147
+ module Admin
148
+ module Resources
149
+ class WritableWidgetResource < Admin::Base::Resource
150
+ model WritableResourceFixtures::Widget
151
+ portal :ops
152
+ section :observability
153
+
154
+ index do
155
+ columns do
156
+ column :name
157
+ end
158
+ end
159
+
160
+ actions do
161
+ action :ping
162
+ bulk_action :ping
68
163
  end
69
164
  end
70
165
  end
@@ -73,6 +168,12 @@ end
73
168
  module AdminSuite
74
169
  class ReadOnlyResourceTest < ActionDispatch::IntegrationTest
75
170
  BASE_PATH = "/internal/admin_suite/ops/read_only_widgets"
171
+ WRITABLE_PATH = "/internal/admin_suite/ops/writable_widgets"
172
+
173
+ setup do
174
+ ReadOnlyResourceFixtures::Widget.ping_calls = 0
175
+ WritableResourceFixtures::Widget.ping_calls = 0
176
+ end
76
177
 
77
178
  test "direct built in mutation endpoints are rejected" do
78
179
  get "#{BASE_PATH}/new"
@@ -122,9 +223,34 @@ module AdminSuite
122
223
  assert_response :not_found
123
224
  end
124
225
 
125
- test "declared member actions remain allowed on read_only resources" do
226
+ test "declared member actions are rejected on read_only resources" do
126
227
  post "#{BASE_PATH}/1/execute_action/ping"
127
- refute_equal 404, response.status
228
+
229
+ assert_response :not_found
230
+ assert_equal 0, ReadOnlyResourceFixtures::Widget.ping_calls
231
+ end
232
+
233
+ test "declared bulk actions are rejected on read_only resources" do
234
+ post "#{BASE_PATH}/bulk_action/ping", params: { ids: [ "1" ] }
235
+
236
+ assert_response :not_found
237
+ assert_equal 0, ReadOnlyResourceFixtures::Widget.ping_calls
238
+ end
239
+
240
+ test "declared member actions still execute on writable resources" do
241
+ post "#{WRITABLE_PATH}/1/execute_action/ping"
242
+
243
+ assert_redirected_to "#{WRITABLE_PATH}/1"
244
+ assert_equal 1, WritableResourceFixtures::Widget.ping_calls
245
+ end
246
+
247
+ test "declared bulk actions still execute on writable resources" do
248
+ post "#{WRITABLE_PATH}/bulk_action/ping", params: { ids: [ "1" ] }
249
+
250
+ assert_redirected_to WRITABLE_PATH
251
+ follow_redirect!
252
+ assert_includes response.body, "Successfully processed 1 records"
253
+ assert_equal 1, WritableResourceFixtures::Widget.ping_calls
128
254
  end
129
255
  end
130
256
  end
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Fixtures for the gem-provided searchable_select search endpoint
6
+ # (ResourcesController#search). Self-contained per the pattern established in
7
+ # authorization_test.rb/pagination_and_stats_test.rb: `rake test
8
+ # TEST=test/integration/searchable_select_search_test.rb` must work with no
9
+ # other test file's fixtures loaded.
10
+ module SearchableSelectFixtures
11
+ class Company
12
+ extend ActiveModel::Naming
13
+
14
+ attr_reader :id, :name, :secret
15
+
16
+ def initialize(id:, name:, secret: nil)
17
+ @id = id
18
+ @name = name
19
+ @secret = secret
20
+ end
21
+
22
+ def self.records
23
+ @records ||= (1..30).map { |n| new(id: n, name: "Widget #{n}") } +
24
+ [ new(id: 1000, name: "Acme Corp", secret: "unicorn-marker") ]
25
+ end
26
+
27
+ def self.all = ReadOnlyResourceFixtures::Relation.new(records)
28
+ def self.column_names = %w[id name secret]
29
+ def self.primary_key = "id"
30
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
31
+
32
+ def self.find(id)
33
+ records.find { |r| r.id.to_s == id.to_s } || raise(ActiveRecord::RecordNotFound)
34
+ end
35
+
36
+ # Fakes AR's `where(sql, binds)` well enough to exercise the real
37
+ # production predicate (`Admin::Base::FilterBuilder.search_predicate`)
38
+ # end to end in this database-free dummy app: parses the field name(s)
39
+ # out of the "<field> ILIKE :search" text the predicate builds -- which
40
+ # only ever contains names from the resource's declared `searchable`
41
+ # whitelist -- and substring-matches only *those* fields. A term that
42
+ # only appears in a non-searchable field (`secret`, below) can therefore
43
+ # never match, mirroring real ILIKE restricted to real whitelisted
44
+ # columns.
45
+ def self.where(conditions, binds = {})
46
+ term = binds[:search].to_s.delete_prefix("%").delete_suffix("%").downcase
47
+ fields = conditions.scan(/(\w+) ILIKE :search/).flatten
48
+ matches = records.select { |r| fields.any? { |f| r.public_send(f).to_s.downcase.include?(term) } }
49
+ ReadOnlyResourceFixtures::Relation.new(matches)
50
+ end
51
+
52
+ def to_param = id.to_s
53
+ def attributes = { "id" => id, "name" => name, "secret" => secret }
54
+ end
55
+
56
+ # Declares an index (so it's a real, otherwise-normal resource) but never
57
+ # calls `searchable`, leaving `searchable_fields` empty -- one of the
58
+ # hostile-input shapes the task brief calls out explicitly. `where` raises
59
+ # so any test that reaches it proves the guard in
60
+ # `ResourcesController#search_results` failed to short-circuit before
61
+ # querying.
62
+ class NoSearchWidget
63
+ extend ActiveModel::Naming
64
+
65
+ attr_reader :id, :name
66
+
67
+ def initialize(id:, name:)
68
+ @id = id
69
+ @name = name
70
+ end
71
+
72
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Solo widget") ])
73
+ def self.column_names = %w[id name]
74
+ def self.primary_key = "id"
75
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
76
+ def self.find(_id) = new(id: 1, name: "Solo widget")
77
+
78
+ def self.where(*)
79
+ raise "must not query a resource with no searchable fields declared"
80
+ end
81
+
82
+ def to_param = id.to_s
83
+ def attributes = { "id" => id, "name" => name }
84
+ end
85
+
86
+ # No `index` block at all -- `index_config` itself is nil, a stricter
87
+ # version of the "no searchable fields" case above.
88
+ class NoIndexWidget
89
+ extend ActiveModel::Naming
90
+
91
+ attr_reader :id, :name
92
+
93
+ def initialize(id:, name:)
94
+ @id = id
95
+ @name = name
96
+ end
97
+
98
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Bare widget") ])
99
+ def self.column_names = %w[id name]
100
+ def self.primary_key = "id"
101
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
102
+ def self.find(_id) = new(id: 1, name: "Bare widget")
103
+
104
+ def self.where(*)
105
+ raise "must not query a resource with no index config at all"
106
+ end
107
+
108
+ def to_param = id.to_s
109
+ def attributes = { "id" => id, "name" => name }
110
+ end
111
+
112
+ # Exercises `render_searchable_select`'s URL resolution: one field left to
113
+ # resolve its search URL automatically via `resource:`, one overridden
114
+ # with an explicit String `collection:` (must still win -- unchanged host
115
+ # behavior).
116
+ class Deal
117
+ extend ActiveModel::Naming
118
+
119
+ attr_accessor :id, :company_id, :vendor_id
120
+
121
+ def initialize(id: nil, company_id: nil, vendor_id: nil)
122
+ @id = id
123
+ @company_id = company_id
124
+ @vendor_id = vendor_id
125
+ end
126
+
127
+ def self.column_names = %w[id company_id vendor_id]
128
+ def persisted? = !id.nil?
129
+ def new_record? = !persisted?
130
+
131
+ # render_form_field unconditionally calls `resource.errors[field.name]`
132
+ # for every field; no test here exercises a validation error, so a Hash
133
+ # defaulting to `[]` is sufficient (same rationale as
134
+ # layout_assets_test.rb's MarkdownWidget).
135
+ def errors
136
+ Hash.new([])
137
+ end
138
+ end
139
+ end
140
+
141
+ module Admin
142
+ module Resources
143
+ class SearchableSelectCompanyResource < Admin::Base::Resource
144
+ model SearchableSelectFixtures::Company
145
+ portal :ops
146
+ section :observability
147
+
148
+ index do
149
+ searchable :name
150
+ columns { column :name }
151
+ end
152
+ end
153
+
154
+ class SearchableSelectNoSearchWidgetResource < Admin::Base::Resource
155
+ model SearchableSelectFixtures::NoSearchWidget
156
+ portal :ops
157
+ section :observability
158
+
159
+ index do
160
+ columns { column :name }
161
+ end
162
+ end
163
+
164
+ class SearchableSelectNoIndexWidgetResource < Admin::Base::Resource
165
+ model SearchableSelectFixtures::NoIndexWidget
166
+ portal :ops
167
+ section :observability
168
+ end
169
+
170
+ class SearchableSelectDealResource < Admin::Base::Resource
171
+ model SearchableSelectFixtures::Deal
172
+ portal :ops
173
+ section :observability
174
+
175
+ form do
176
+ field :company_id, type: :searchable_select, resource: :searchable_select_companies
177
+ field :vendor_id, type: :searchable_select, collection: "/custom/vendor/search"
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ module AdminSuite
184
+ class SearchableSelectSearchTest < ActionDispatch::IntegrationTest
185
+ COMPANIES_SEARCH = "/internal/admin_suite/ops/searchable_select_companies/search"
186
+ NO_SEARCH = "/internal/admin_suite/ops/searchable_select_no_search_widgets/search"
187
+ NO_INDEX = "/internal/admin_suite/ops/searchable_select_no_index_widgets/search"
188
+
189
+ def with_authorize(hook)
190
+ saved = AdminSuite.config.authorize
191
+ AdminSuite.config.authorize = hook
192
+ yield
193
+ ensure
194
+ AdminSuite.config.authorize = saved
195
+ end
196
+
197
+ test "returns matching records as a bare JSON array with id and name" do
198
+ get COMPANIES_SEARCH, params: { q: "Acme" }
199
+ assert_response :success
200
+
201
+ body = JSON.parse(response.body)
202
+ assert_instance_of Array, body
203
+ assert_equal [ { "id" => 1000, "name" => "Acme Corp" } ], body
204
+ end
205
+
206
+ test "the response is a bare array, never {results: [...]}" do
207
+ get COMPANIES_SEARCH, params: { q: "Widget" }
208
+ assert_response :success
209
+ assert_kind_of Array, JSON.parse(response.body)
210
+ end
211
+
212
+ test "caps results at 25 even when more records match" do
213
+ get COMPANIES_SEARCH, params: { q: "Widget" }
214
+ assert_response :success
215
+ assert_equal 25, JSON.parse(response.body).size
216
+ end
217
+
218
+ test "empty q returns an empty array, not the whole table" do
219
+ get COMPANIES_SEARCH, params: { q: "" }
220
+ assert_response :success
221
+ assert_equal [], JSON.parse(response.body)
222
+ end
223
+
224
+ test "a missing q param returns an empty array" do
225
+ get COMPANIES_SEARCH
226
+ assert_response :success
227
+ assert_equal [], JSON.parse(response.body)
228
+ end
229
+
230
+ test "only searches the resource's declared searchable fields, never an arbitrary column" do
231
+ get COMPANIES_SEARCH, params: { q: "unicorn-marker" }
232
+ assert_response :success
233
+ assert_equal [], JSON.parse(response.body),
234
+ "a term that only exists in a non-searchable column must never match"
235
+ end
236
+
237
+ test "a resource with no searchable fields declared returns empty results without querying the model" do
238
+ get NO_SEARCH, params: { q: "widget" }
239
+ assert_response :success
240
+ assert_equal [], JSON.parse(response.body)
241
+ end
242
+
243
+ test "a resource with no index config at all returns empty results without querying the model" do
244
+ get NO_INDEX, params: { q: "widget" }
245
+ assert_response :success
246
+ assert_equal [], JSON.parse(response.body)
247
+ end
248
+
249
+ test "unknown resource name 404s" do
250
+ get "/internal/admin_suite/ops/totally_unregistered_things/search", params: { q: "x" }
251
+ assert_response :not_found
252
+ end
253
+
254
+ test "a resource name with path traversal characters 404s rather than erroring" do
255
+ get "/internal/admin_suite/ops/#{ERB::Util.url_encode('../../etc/passwd')}/search", params: { q: "x" }
256
+ assert_response :not_found
257
+ end
258
+
259
+ test "denies with 403 when config.authorize denies" do
260
+ with_authorize(->(**) { false }) do
261
+ get COMPANIES_SEARCH, params: { q: "Acme" }
262
+ end
263
+ assert_response :forbidden
264
+ end
265
+
266
+ test "authorize hook receives action: :read, the resource, a nil record, and the context" do
267
+ captured = nil
268
+ hook = lambda do |actor:, action:, resource:, record:, context:|
269
+ captured = { action: action, resource: resource, record: record, context: context }
270
+ true
271
+ end
272
+
273
+ with_authorize(hook) { get COMPANIES_SEARCH, params: { q: "Acme" } }
274
+
275
+ assert_equal :read, captured[:action]
276
+ assert_equal Admin::Resources::SearchableSelectCompanyResource, captured[:resource]
277
+ assert_nil captured[:record]
278
+ assert_equal :web, captured[:context].surface
279
+ assert_equal AdminSuite::ResourcesController, captured[:context].controller.class
280
+ end
281
+
282
+ test "a stray ?id= is never loaded and never reaches authorize's record:" do
283
+ captured_record = :not_set
284
+ hook = lambda do |record:, **|
285
+ captured_record = record
286
+ true
287
+ end
288
+
289
+ # Company id 1000 ("Acme Corp") genuinely exists in this fixture --
290
+ # this must not load it, unlike show/edit/update/destroy, where an
291
+ # `:id` is expected and meaningful.
292
+ with_authorize(hook) { get COMPANIES_SEARCH, params: { q: "Acme", id: "1000" } }
293
+
294
+ assert_response :success
295
+ assert_nil captured_record,
296
+ "search must never load a record from a stray ?id= -- doing so hands an " \
297
+ "attacker-chosen record to config.authorize and creates a 404-vs-403 " \
298
+ "existence oracle over find_friendly_resource!'s slug/uuid/token lookups"
299
+ end
300
+
301
+ test "a stray ?id= for a nonexistent record does not 404 before authorize runs" do
302
+ # Before excluding `search` from `set_resource`, an unmatched id here
303
+ # raised ActiveRecord::RecordNotFound (404) *before* authorize_admin_suite!
304
+ # ever ran -- letting a denied actor distinguish "no such id" from
305
+ # "denied" by id, on a resource they have no read access to.
306
+ hook_called = false
307
+ with_authorize(->(**) { hook_called = true; false }) do
308
+ get COMPANIES_SEARCH, params: { q: "Acme", id: "999999" }
309
+ end
310
+
311
+ assert_response :forbidden
312
+ assert hook_called, "authorize must run even with a nonexistent stray ?id="
313
+ end
314
+
315
+ test "unregistered resource name 404s before the authorize hook ever runs" do
316
+ hook_called = false
317
+ with_authorize(->(**) { hook_called = true; false }) do
318
+ get "/internal/admin_suite/ops/totally_unregistered_things/search", params: { q: "x" }
319
+ end
320
+ refute hook_called, "authorize hook must not run for an unregistered resource name"
321
+ end
322
+
323
+ test "a very long q does not crash and does not leak the table" do
324
+ get COMPANIES_SEARCH, params: { q: "a" * 10_000 }
325
+ assert_response :success
326
+ assert_equal [], JSON.parse(response.body)
327
+ end
328
+
329
+ test "SQL metacharacters in q do not crash and are treated as a literal search term" do
330
+ get COMPANIES_SEARCH, params: { q: "'; DROP TABLE companies; --" }
331
+ assert_response :success
332
+ assert_equal [], JSON.parse(response.body)
333
+ end
334
+
335
+ test "render_searchable_select resolves field resource: to the gem's search endpoint" do
336
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
337
+ assert_response :success
338
+ assert_includes response.body, "/internal/admin_suite/ops/searchable_select_companies/search"
339
+ end
340
+
341
+ test "a String collection: still overrides the default resource: URL, unchanged" do
342
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
343
+ assert_response :success
344
+ assert_includes response.body, "/custom/vendor/search"
345
+ end
346
+
347
+ # Pins requirement #1 (authentication) with an actual test, not trace
348
+ # only -- this controller relies on ApplicationController's
349
+ # `admin_suite_authenticate!` before_action, and nothing here re-proves
350
+ # that for this specific action. Mirrors
351
+ # authentication_test.rb's "unconfigured auth fails closed with 403".
352
+ test "unconfigured auth fails closed with 403 on /search" do
353
+ saved_allow = AdminSuite.config.allow_unauthenticated
354
+ saved_strategy = AdminSuite.config.auth_strategy
355
+ saved_authenticate = AdminSuite.config.authenticate
356
+
357
+ AdminSuite.config.allow_unauthenticated = false
358
+ AdminSuite.config.auth_strategy = nil
359
+ AdminSuite.config.authenticate = nil
360
+
361
+ get COMPANIES_SEARCH, params: { q: "Acme" }
362
+ assert_response :forbidden
363
+ ensure
364
+ AdminSuite.config.allow_unauthenticated = saved_allow
365
+ AdminSuite.config.auth_strategy = saved_strategy
366
+ AdminSuite.config.authenticate = saved_authenticate
367
+ end
368
+ end
369
+ end