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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -7
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/resources_controller.rb +130 -5
- data/app/helpers/admin_suite/base_helper.rb +187 -10
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/resources/index.html.erb +24 -5
- data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
- data/app/views/layouts/admin_suite/application.html.erb +6 -0
- data/config/routes.rb +3 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +43 -7
- data/lib/admin_suite/configuration.rb +10 -6
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +13 -2
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/navigation_sections_test.rb +21 -0
- data/test/integration/searchable_select_search_test.rb +368 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/definition_loader_test.rb +12 -0
- data/test/lib/form_field_renderer_test.rb +83 -11
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +8 -1
- data/test/lib/renderer_test.rb +23 -7
- data/test/lib/resource_exportable_deprecation_test.rb +10 -2
- data/test/test_helper.rb +47 -8
- 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->...`, not a literal `->`.
|
|
160
|
+
assert_match(/<tr[^>]*data-action="click->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
|