admin_suite 0.3.1 → 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 +210 -1
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +138 -6
- data/app/helpers/admin_suite/base_helper.rb +232 -391
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +31 -82
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
- data/app/views/layouts/admin_suite/application.html.erb +11 -3
- data/config/routes.rb +3 -0
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +92 -17
- data/lib/admin_suite/configuration.rb +32 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +81 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +48 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- 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/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +62 -0
- data/test/integration/pagination_and_stats_test.rb +152 -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/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +276 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +136 -0
- 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 +42 -0
- data/test/lib/renderer_test.rb +237 -0
- data/test/lib/resource_exportable_deprecation_test.rb +47 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +121 -5
- metadata +62 -5
|
@@ -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
|
|
@@ -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
|