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