admin_suite 0.4.0 → 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -7
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/controllers/admin_suite/resources_controller.rb +130 -5
  5. data/app/helpers/admin_suite/base_helper.rb +187 -10
  6. data/app/javascript/admin_suite_application.js +3 -0
  7. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  8. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  9. data/app/views/admin_suite/resources/index.html.erb +24 -5
  10. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  11. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  12. data/config/routes.rb +3 -0
  13. data/lib/admin/base/filter_builder.rb +48 -5
  14. data/lib/admin/base/resource.rb +43 -7
  15. data/lib/admin_suite/configuration.rb +10 -6
  16. data/lib/admin_suite/engine.rb +1 -1
  17. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  18. data/lib/admin_suite/renderer_registry.rb +11 -0
  19. data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
  20. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  21. data/lib/admin_suite/version.rb +1 -1
  22. data/lib/admin_suite.rb +13 -2
  23. data/test/integration/association_linking_test.rb +292 -0
  24. data/test/integration/chart_panel_test.rb +491 -0
  25. data/test/integration/dashboard_test.rb +9 -2
  26. data/test/integration/index_table_test.rb +289 -0
  27. data/test/integration/navigation_sections_test.rb +21 -0
  28. data/test/integration/searchable_select_search_test.rb +368 -0
  29. data/test/integration/show_hide_blank_test.rb +195 -0
  30. data/test/integration/toggle_test.rb +106 -0
  31. data/test/lib/definition_loader_test.rb +12 -0
  32. data/test/lib/form_field_renderer_test.rb +83 -11
  33. data/test/lib/format_table_cell_test.rb +49 -0
  34. data/test/lib/index_includes_test.rb +223 -0
  35. data/test/lib/legacy_renderer_deprecation_test.rb +8 -1
  36. data/test/lib/renderer_test.rb +23 -7
  37. data/test/lib/resource_exportable_deprecation_test.rb +10 -2
  38. data/test/test_helper.rb +47 -8
  39. metadata +35 -5
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Self-contained fixtures, following the pattern established in
6
+ # association_linking_test.rb/pagination_and_stats_test.rb: this file must
7
+ # work in isolation under `rake test TEST=test/integration/index_table_test.rb`,
8
+ # so it defines everything it needs rather than depending on fixtures that
9
+ # live only in a sibling test file. `LinkingFixtures::Company` is the one
10
+ # exception -- it lives in test_helper.rb (loaded for every run) precisely
11
+ # so tasks like this one can reuse it (see test_helper.rb's comment above
12
+ # it).
13
+ module IndexTableFixtures
14
+ # Two rows: one fully populated (including a present `belongs_to`-shaped
15
+ # association), one deliberately sparse (nil scalar, nil association) --
16
+ # covers item 5 (nil -> em dash) and Task 3's non-regression (a present
17
+ # association still renders as a link) in the same fixture.
18
+ class Widget
19
+ extend ActiveModel::Naming
20
+
21
+ attr_reader :id, :name, :count, :status, :company
22
+
23
+ def initialize(id:, name:, count:, status:, company:)
24
+ @id = id
25
+ @name = name
26
+ @count = count
27
+ @status = status
28
+ @company = company
29
+ end
30
+
31
+ ROWS = [
32
+ new(id: 1, name: "Alpha", count: 5, status: "ok", company: LinkingFixtures::Company.new),
33
+ new(id: 2, name: "Beta", count: nil, status: nil, company: nil)
34
+ ]
35
+
36
+ def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
37
+ def self.column_names = %w[id name count status]
38
+ def self.primary_key = "id"
39
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
40
+ def self.find(id) = ROWS.find { |w| w.to_param == id.to_s }
41
+ def to_param = id.to_s
42
+ def attributes = { "id" => id, "name" => name, "count" => count, "status" => status }
43
+ end
44
+
45
+ # A real slicing relation (unlike `ReadOnlyResourceFixtures::Relation`,
46
+ # whose `#offset`/`#limit` are no-ops returning self -- see
47
+ # test-harness fact #8). `Pagy::Backend#pagy_get_items` calls
48
+ # `collection.offset(pagy.offset).limit(pagy.limit)`, so this must
49
+ # actually slice for the per-page tests to prove anything.
50
+ class SlicingRelation
51
+ include Enumerable
52
+
53
+ def initialize(records, offset: 0)
54
+ @records = records
55
+ @offset = offset
56
+ end
57
+
58
+ def each(&block) = @records.each(&block)
59
+ def count(*) = @records.length
60
+ def offset(n) = SlicingRelation.new(@records, offset: n)
61
+ def limit(n) = @records[@offset, n] || []
62
+ end
63
+
64
+ class PagedWidget
65
+ extend ActiveModel::Naming
66
+
67
+ attr_reader :id
68
+
69
+ def initialize(id:) = @id = id
70
+
71
+ ALL_ROWS = (1..150).map { |n| new(id: n) }
72
+
73
+ def self.all = SlicingRelation.new(ALL_ROWS)
74
+ def self.column_names = %w[id]
75
+ def self.primary_key = "id"
76
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
77
+ def self.find(id) = ALL_ROWS.find { |w| w.to_param == id.to_s }
78
+ def to_param = id.to_s
79
+ def attributes = { "id" => id }
80
+ end
81
+ end
82
+
83
+ module Admin
84
+ module Resources
85
+ class IndexTableWidgetResource < Admin::Base::Resource
86
+ model IndexTableFixtures::Widget
87
+ portal :ops
88
+ section :observability
89
+
90
+ index do
91
+ # A filter is required for the sidebar (and thus the per-page
92
+ # selector, which lives inside that same form) to render at all --
93
+ # see `index.html.erb`'s `if index_config&.filters_list&.any? || ...`
94
+ # guard.
95
+ filters { filter :name, type: :text }
96
+ columns do
97
+ column :name, class: "font-mono"
98
+ column :count, align: :right
99
+ column :status, align: :diagonal
100
+ column :company
101
+ end
102
+ paginate 10
103
+ end
104
+ end
105
+
106
+ class IndexTablePagedWidgetResource < Admin::Base::Resource
107
+ model IndexTableFixtures::PagedWidget
108
+ portal :ops
109
+ section :observability
110
+
111
+ index do
112
+ columns { column :id }
113
+ # Deliberately distinct from the hardcoded 25 default and from any
114
+ # of the per_page values under test (10, 50, 100), so a test that
115
+ # asserts "10 rows" can only be passing because the DSL fallback
116
+ # kicked in, not by coincidence with some other default.
117
+ paginate 10
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ module AdminSuite
124
+ class IndexTableTest < ActionDispatch::IntegrationTest
125
+ # Item 1: sticky header.
126
+ test "the index thead carries the sticky header classes" do
127
+ get "/internal/admin_suite/ops/index_table_widgets"
128
+ assert_response :success
129
+ assert_match %r{<thead[^>]*\bsticky\b[^>]*\btop-0\b[^>]*\bz-10\b[^>]*>}, response.body
130
+ end
131
+
132
+ # `sticky top-0` only actually pins the header if its nearest scroll
133
+ # container ancestor genuinely scrolls internally. `overflow-x: auto`
134
+ # alone forces the browser to compute `overflow-y` as `auto` too (per
135
+ # the CSS Overflow spec's "visible becomes auto when the other axis
136
+ # isn't visible" rule), making the wrapper div a sticky containing
137
+ # block -- but with no bounded height, that div never scrolls, so the
138
+ # header has nothing to stick against and just scrolls away with the
139
+ # page. This asserts the wrapper is *also* given a bounded height and
140
+ # `overflow-y-auto`, so a future edit that drops the height silently
141
+ # re-breaks sticky (a class-presence-only test on `<thead>` would stay
142
+ # green even if the header stopped sticking) and fails here instead.
143
+ test "the table's scroll wrapper is bounded so the sticky header has something to stick against" do
144
+ get "/internal/admin_suite/ops/index_table_widgets"
145
+ assert_response :success
146
+ assert_match(
147
+ %r{<div class="[^"]*overflow-x-auto[^"]*overflow-y-auto[^"]*max-h-\[70vh\][^"]*"},
148
+ response.body
149
+ )
150
+ end
151
+
152
+ # Item 2: row click wiring, via the already-existing ClickActionsController.
153
+ test "each row wires the click-actions controller to its own show path" do
154
+ get "/internal/admin_suite/ops/index_table_widgets"
155
+ assert_response :success
156
+
157
+ assert_match(/<tr[^>]*data-controller="admin-suite--click-actions"[^>]*>/, response.body)
158
+ # ERB HTML-escapes the `>` in the Stimulus action descriptor, so the
159
+ # attribute renders as `click-&gt;...`, not a literal `->`.
160
+ assert_match(/<tr[^>]*data-action="click-&gt;admin-suite--click-actions#navigate"[^>]*>/, response.body)
161
+ assert_match(
162
+ %r{<tr[^>]*data-admin-suite--click-actions-url-value="[^"]*index_table_widgets/1"[^>]*>},
163
+ response.body
164
+ )
165
+ end
166
+
167
+ # Item 3: per-page selector + server-side clamp.
168
+ test "the filter form offers a 25/50/100 per_page selector" do
169
+ get "/internal/admin_suite/ops/index_table_widgets"
170
+ assert_response :success
171
+ assert_match(/<select[^>]*name="per_page"[^>]*>/, response.body)
172
+ assert_match(/<option[^>]*value="25"/, response.body)
173
+ assert_match(/<option[^>]*value="50"/, response.body)
174
+ assert_match(/<option[^>]*value="100"/, response.body)
175
+ end
176
+
177
+ # Counts `<tr>` inside `<tbody>` only, deliberately independent of item
178
+ # 2's row-click markup (which lands in the same `<tr>` tags) -- so a
179
+ # per_page test failure can never be secretly caused by row-click not
180
+ # being wired yet.
181
+ def row_count(body)
182
+ tbody = body[%r{<tbody.*?</tbody>}m]
183
+ tbody ? tbody.scan(/<tr[ >]/).size : 0
184
+ end
185
+
186
+ test "no per_page param falls back to the DSL's paginate(n) value" do
187
+ get "/internal/admin_suite/ops/index_table_paged_widgets"
188
+ assert_response :success
189
+ assert_equal 10, row_count(response.body)
190
+ end
191
+
192
+ test "a valid per_page renders that many rows" do
193
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 50 }
194
+ assert_response :success
195
+ assert_equal 50, row_count(response.body)
196
+ end
197
+
198
+ test "per_page above the max clamps to 100, not the requested amount" do
199
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 999_999 }
200
+ assert_response :success
201
+ assert_equal 100, row_count(response.body)
202
+ end
203
+
204
+ test "per_page=0 falls back to the DSL value instead of an empty/unbounded page" do
205
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 0 }
206
+ assert_response :success
207
+ assert_equal 10, row_count(response.body)
208
+ end
209
+
210
+ test "a negative per_page falls back to the DSL value" do
211
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: -1 }
212
+ assert_response :success
213
+ assert_equal 10, row_count(response.body)
214
+ end
215
+
216
+ test "a non-numeric per_page falls back to the DSL value" do
217
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: "abc" }
218
+ assert_response :success
219
+ assert_equal 10, row_count(response.body)
220
+ end
221
+
222
+ test "an array-shaped per_page (per_page[]=1) falls back to the DSL value" do
223
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: [ "1" ] }
224
+ assert_response :success
225
+ assert_equal 10, row_count(response.body)
226
+ end
227
+
228
+ # Item 4: column alignment + css_class.
229
+ test "align: :right emits text-right on the td" do
230
+ get "/internal/admin_suite/ops/index_table_widgets"
231
+ assert_response :success
232
+ assert_match(%r{<td class="[^"]*\btext-right\b[^"]*">\s*5\s*</td>}, response.body)
233
+ end
234
+
235
+ test "column.css_class (the class: option) reaches the td" do
236
+ get "/internal/admin_suite/ops/index_table_widgets"
237
+ assert_response :success
238
+ assert_match(%r{<td class="[^"]*\bfont-mono\b[^"]*">}, response.body)
239
+ end
240
+
241
+ test "an unknown align value renders successfully with no fabricated text-<value> class" do
242
+ get "/internal/admin_suite/ops/index_table_widgets"
243
+ assert_response :success
244
+ refute_includes response.body, "text-diagonal"
245
+ end
246
+
247
+ # Isolates a single fixture row's `<tr>...</tr>` markup by a distinctive
248
+ # cell value, so nil-cell assertions can target Beta's row specifically
249
+ # instead of matching the first empty-looking `<td>` anywhere on the
250
+ # page (there are several: Alpha's row has none, but a loose regex
251
+ # would not tell the two rows apart).
252
+ def widget_row(body, marker)
253
+ tbody = body[%r{<tbody.*?</tbody>}m]
254
+ tbody.scan(%r{<tr.*?</tr>}m).find { |r| r.include?(marker) }
255
+ end
256
+
257
+ # Item 5: nil cells render an em dash, and Task 3's association links
258
+ # are not regressed by that change.
259
+ test "a nil scalar column renders an em dash, not a blank cell" do
260
+ get "/internal/admin_suite/ops/index_table_widgets"
261
+ assert_response :success
262
+ beta_row = widget_row(response.body, "Beta")
263
+ refute_nil beta_row, "expected to find Beta's row"
264
+ # Beta's `count` is nil -- the fallback branch must turn that into an
265
+ # em dash instead of the empty string it renders today.
266
+ assert_match(%r{<td[^>]*>\s*—\s*</td>}, beta_row)
267
+ end
268
+
269
+ test "a nil association column renders an em dash, not a blank cell" do
270
+ get "/internal/admin_suite/ops/index_table_widgets"
271
+ assert_response :success
272
+ beta_row = widget_row(response.body, "Beta")
273
+ refute_nil beta_row, "expected to find Beta's row"
274
+ # Beta's `company` is nil (Task 3 deliberately left nil association
275
+ # handling to this task). Count how many dash-only cells the row has:
276
+ # count and status are also nil, so there must be at least 3 (not 2),
277
+ # proving company's cell got the dash too rather than staying blank.
278
+ dash_cells = beta_row.scan(%r{<td[^>]*>\s*—\s*</td>}).size
279
+ assert_equal 3, dash_cells,
280
+ "expected 3 dash cells (count, status, company all nil) in Beta's row, got #{dash_cells}"
281
+ end
282
+
283
+ test "a present association still renders as a link (Task 3 non-regression)" do
284
+ get "/internal/admin_suite/ops/index_table_widgets"
285
+ assert_response :success
286
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
287
+ end
288
+ end
289
+ end
@@ -37,5 +37,26 @@ module AdminSuite
37
37
  assert_response :success
38
38
  assert_includes response.body, "Observability" # from ReadOnlyWidgetResource's `section :observability`
39
39
  end
40
+
41
+ # `:zzz_last` (declared in `setup` above) has no resources registered
42
+ # under it anywhere in the suite, unlike `:observability`. Before this
43
+ # fix, `_sidebar.html.erb` rendered a bare section label with nothing
44
+ # underneath for a section like this; `portals/show.html.erb` already
45
+ # showed "No resources in this section yet." for the same case, so the
46
+ # sidebar was the odd one out.
47
+ test "the sidebar shows the no-resources message for a declared but empty section" do
48
+ get "/internal/admin_suite/ops"
49
+ assert_response :success
50
+
51
+ # Scoped to the sidebar itself: `portals/show.html.erb`'s own fallback
52
+ # section list also renders "No resources in this section yet." for
53
+ # the same empty section, so an unscoped `assert_includes` on the full
54
+ # body wouldn't actually prove the *sidebar* (as opposed to the main
55
+ # content) says it.
56
+ sidebar = Nokogiri::HTML(response.body).at_css(".admin-suite-sidebar")
57
+ assert sidebar, "expected to find the sidebar"
58
+ assert_includes sidebar.text, "Aaa Displayed First"
59
+ assert_includes sidebar.text, "No resources in this section yet."
60
+ end
40
61
  end
41
62
  end
@@ -0,0 +1,368 @@
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 controller" do
267
+ captured = nil
268
+ hook = lambda do |actor:, action:, resource:, record:, controller:|
269
+ captured = { action: action, resource: resource, record: record, controller: controller.class }
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 AdminSuite::ResourcesController, captured[:controller]
279
+ end
280
+
281
+ test "a stray ?id= is never loaded and never reaches authorize's record:" do
282
+ captured_record = :not_set
283
+ hook = lambda do |record:, **|
284
+ captured_record = record
285
+ true
286
+ end
287
+
288
+ # Company id 1000 ("Acme Corp") genuinely exists in this fixture --
289
+ # this must not load it, unlike show/edit/update/destroy, where an
290
+ # `:id` is expected and meaningful.
291
+ with_authorize(hook) { get COMPANIES_SEARCH, params: { q: "Acme", id: "1000" } }
292
+
293
+ assert_response :success
294
+ assert_nil captured_record,
295
+ "search must never load a record from a stray ?id= -- doing so hands an " \
296
+ "attacker-chosen record to config.authorize and creates a 404-vs-403 " \
297
+ "existence oracle over find_friendly_resource!'s slug/uuid/token lookups"
298
+ end
299
+
300
+ test "a stray ?id= for a nonexistent record does not 404 before authorize runs" do
301
+ # Before excluding `search` from `set_resource`, an unmatched id here
302
+ # raised ActiveRecord::RecordNotFound (404) *before* authorize_admin_suite!
303
+ # ever ran -- letting a denied actor distinguish "no such id" from
304
+ # "denied" by id, on a resource they have no read access to.
305
+ hook_called = false
306
+ with_authorize(->(**) { hook_called = true; false }) do
307
+ get COMPANIES_SEARCH, params: { q: "Acme", id: "999999" }
308
+ end
309
+
310
+ assert_response :forbidden
311
+ assert hook_called, "authorize must run even with a nonexistent stray ?id="
312
+ end
313
+
314
+ test "unregistered resource name 404s before the authorize hook ever runs" do
315
+ hook_called = false
316
+ with_authorize(->(**) { hook_called = true; false }) do
317
+ get "/internal/admin_suite/ops/totally_unregistered_things/search", params: { q: "x" }
318
+ end
319
+ refute hook_called, "authorize hook must not run for an unregistered resource name"
320
+ end
321
+
322
+ test "a very long q does not crash and does not leak the table" do
323
+ get COMPANIES_SEARCH, params: { q: "a" * 10_000 }
324
+ assert_response :success
325
+ assert_equal [], JSON.parse(response.body)
326
+ end
327
+
328
+ test "SQL metacharacters in q do not crash and are treated as a literal search term" do
329
+ get COMPANIES_SEARCH, params: { q: "'; DROP TABLE companies; --" }
330
+ assert_response :success
331
+ assert_equal [], JSON.parse(response.body)
332
+ end
333
+
334
+ test "render_searchable_select resolves field resource: to the gem's search endpoint" do
335
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
336
+ assert_response :success
337
+ assert_includes response.body, "/internal/admin_suite/ops/searchable_select_companies/search"
338
+ end
339
+
340
+ test "a String collection: still overrides the default resource: URL, unchanged" do
341
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
342
+ assert_response :success
343
+ assert_includes response.body, "/custom/vendor/search"
344
+ end
345
+
346
+ # Pins requirement #1 (authentication) with an actual test, not trace
347
+ # only -- this controller relies on ApplicationController's
348
+ # `admin_suite_authenticate!` before_action, and nothing here re-proves
349
+ # that for this specific action. Mirrors
350
+ # authentication_test.rb's "unconfigured auth fails closed with 403".
351
+ test "unconfigured auth fails closed with 403 on /search" do
352
+ saved_allow = AdminSuite.config.allow_unauthenticated
353
+ saved_strategy = AdminSuite.config.auth_strategy
354
+ saved_authenticate = AdminSuite.config.authenticate
355
+
356
+ AdminSuite.config.allow_unauthenticated = false
357
+ AdminSuite.config.auth_strategy = nil
358
+ AdminSuite.config.authenticate = nil
359
+
360
+ get COMPANIES_SEARCH, params: { q: "Acme" }
361
+ assert_response :forbidden
362
+ ensure
363
+ AdminSuite.config.allow_unauthenticated = saved_allow
364
+ AdminSuite.config.auth_strategy = saved_strategy
365
+ AdminSuite.config.authenticate = saved_authenticate
366
+ end
367
+ end
368
+ end