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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Pins the index query path as it behaves at v0.5.0, before
|
|
6
|
+
# AdminSuite::Query is extracted. These assertions intentionally exercise
|
|
7
|
+
# routed requests and rendered rows rather than controller internals.
|
|
8
|
+
module IndexQueryCharacterizationFixtures
|
|
9
|
+
class Relation
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
attr_reader :includes_calls
|
|
13
|
+
|
|
14
|
+
def initialize(records, offset: 0, includes_calls: [])
|
|
15
|
+
@records = records
|
|
16
|
+
@offset = offset
|
|
17
|
+
@includes_calls = includes_calls
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each(&block) = @records.each(&block)
|
|
21
|
+
def count(*) = @records.length
|
|
22
|
+
def offset(value) = self.class.new(@records, offset: value, includes_calls: includes_calls)
|
|
23
|
+
def limit(value) = @records[@offset, value] || []
|
|
24
|
+
|
|
25
|
+
def where(condition = nil, *values, **bindings)
|
|
26
|
+
condition = bindings if condition.nil?
|
|
27
|
+
selected =
|
|
28
|
+
if condition.is_a?(Hash)
|
|
29
|
+
@records.select { |record| condition.all? { |field, value| record.public_send(field).to_s == value.to_s } }
|
|
30
|
+
else
|
|
31
|
+
term = (bindings[:search] || values.first).to_s.delete("%").downcase
|
|
32
|
+
fields = condition.scan(/(\w+) ILIKE/).flatten
|
|
33
|
+
@records.select { |record| fields.any? { |field| record.public_send(field).to_s.downcase.include?(term) } }
|
|
34
|
+
end
|
|
35
|
+
self.class.new(selected, includes_calls: includes_calls)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def order(ordering)
|
|
39
|
+
field, direction = ordering.first
|
|
40
|
+
sorted = @records.sort_by { |record| record.public_send(field).to_s }
|
|
41
|
+
sorted.reverse! if direction.to_sym == :desc
|
|
42
|
+
self.class.new(sorted, includes_calls: includes_calls)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def includes(*associations)
|
|
46
|
+
includes_calls << associations
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class BadIncludesRelation < Relation
|
|
52
|
+
def includes(*) = raise(ArgumentError, "unknown association")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Widget
|
|
56
|
+
extend ActiveModel::Naming
|
|
57
|
+
|
|
58
|
+
attr_reader :id, :name, :secret, :status
|
|
59
|
+
|
|
60
|
+
def initialize(id:, name:, secret:, status:)
|
|
61
|
+
@id = id
|
|
62
|
+
@name = name
|
|
63
|
+
@secret = secret
|
|
64
|
+
@status = status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ROWS = [
|
|
68
|
+
new(id: 1, name: "Zulu", secret: "hidden alpha", status: "open"),
|
|
69
|
+
new(id: 2, name: "Alpha", secret: "hidden zulu", status: "closed"),
|
|
70
|
+
new(id: 3, name: "Bravo", secret: "private", status: "open")
|
|
71
|
+
].freeze
|
|
72
|
+
INCLUDES_CALLS = []
|
|
73
|
+
|
|
74
|
+
def self.all = Relation.new(ROWS, includes_calls: INCLUDES_CALLS)
|
|
75
|
+
def self.column_names = %w[id name secret status]
|
|
76
|
+
def self.primary_key = "id"
|
|
77
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
78
|
+
def self.find(id) = ROWS.find { |row| row.to_param == id.to_s }
|
|
79
|
+
def to_param = id.to_s
|
|
80
|
+
def attributes = { "id" => id, "name" => name, "secret" => secret, "status" => status }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class BadIncludesWidget < Widget
|
|
84
|
+
def self.all = BadIncludesRelation.new(ROWS)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class PagedWidget
|
|
88
|
+
extend ActiveModel::Naming
|
|
89
|
+
|
|
90
|
+
attr_reader :id
|
|
91
|
+
|
|
92
|
+
def initialize(id:) = @id = id
|
|
93
|
+
|
|
94
|
+
ROWS = (1..150).map { |id| new(id: id) }.freeze
|
|
95
|
+
|
|
96
|
+
def self.all = Relation.new(ROWS)
|
|
97
|
+
def self.column_names = %w[id]
|
|
98
|
+
def self.primary_key = "id"
|
|
99
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
100
|
+
def self.find(id) = ROWS.find { |row| row.to_param == id.to_s }
|
|
101
|
+
def to_param = id.to_s
|
|
102
|
+
def attributes = { "id" => id }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
module Admin
|
|
107
|
+
module Resources
|
|
108
|
+
class IndexQueryCharacterizationWidgetResource < Admin::Base::Resource
|
|
109
|
+
model IndexQueryCharacterizationFixtures::Widget
|
|
110
|
+
portal :ops
|
|
111
|
+
section :observability
|
|
112
|
+
|
|
113
|
+
index do
|
|
114
|
+
searchable :name
|
|
115
|
+
sortable :name, default: :name, direction: :desc
|
|
116
|
+
filters { filter :status, type: :select, options: %w[open closed] }
|
|
117
|
+
columns do
|
|
118
|
+
column :name, sortable: true
|
|
119
|
+
column :status
|
|
120
|
+
end
|
|
121
|
+
includes :owner
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class IndexQueryCharacterizationBadIncludesWidgetResource < Admin::Base::Resource
|
|
126
|
+
model IndexQueryCharacterizationFixtures::BadIncludesWidget
|
|
127
|
+
portal :ops
|
|
128
|
+
section :observability
|
|
129
|
+
index do
|
|
130
|
+
columns { column :name }
|
|
131
|
+
includes :missing
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class IndexQueryCharacterizationPagedWidgetResource < Admin::Base::Resource
|
|
136
|
+
model IndexQueryCharacterizationFixtures::PagedWidget
|
|
137
|
+
portal :ops
|
|
138
|
+
section :observability
|
|
139
|
+
index do
|
|
140
|
+
columns { column :id }
|
|
141
|
+
paginate 30
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class IndexQueryCharacterizationTest < ActionDispatch::IntegrationTest
|
|
148
|
+
WIDGETS_PATH = "/internal/admin_suite/ops/index_query_characterization_widgets"
|
|
149
|
+
PAGED_PATH = "/internal/admin_suite/ops/index_query_characterization_paged_widgets"
|
|
150
|
+
|
|
151
|
+
def rendered_names
|
|
152
|
+
css_select("tbody tr td:first-child").map { |cell| cell.text.strip }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def rendered_row_count = css_select("tbody tr").size
|
|
156
|
+
|
|
157
|
+
test "default sort applies when no sort param is given" do
|
|
158
|
+
get WIDGETS_PATH
|
|
159
|
+
assert_equal %w[Zulu Bravo Alpha], rendered_names
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
test "explicit sort and direction override the default" do
|
|
163
|
+
get WIDGETS_PATH, params: { sort: "name", direction: "asc" }
|
|
164
|
+
assert_equal %w[Alpha Bravo Zulu], rendered_names
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
test "a sort param naming an undeclared field is ignored" do
|
|
168
|
+
get WIDGETS_PATH, params: { sort: "secret", direction: "asc" }
|
|
169
|
+
assert_equal %w[Alpha Bravo Zulu], rendered_names
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
test "search examines only declared searchable fields" do
|
|
173
|
+
get WIDGETS_PATH, params: { search: "alpha" }
|
|
174
|
+
assert_equal [ "Alpha" ], rendered_names
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
test "a blank search returns the unfiltered scope" do
|
|
178
|
+
get WIDGETS_PATH, params: { search: " " }
|
|
179
|
+
assert_equal %w[Zulu Bravo Alpha], rendered_names
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
test "a declared filter narrows while an undeclared filter is ignored" do
|
|
183
|
+
get WIDGETS_PATH, params: { status: "open", secret: "hidden zulu" }
|
|
184
|
+
assert_equal %w[Zulu Bravo], rendered_names
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
test "declared includes are applied" do
|
|
188
|
+
IndexQueryCharacterizationFixtures::Widget::INCLUDES_CALLS.clear
|
|
189
|
+
get WIDGETS_PATH
|
|
190
|
+
assert_equal [ [ :owner ] ], IndexQueryCharacterizationFixtures::Widget::INCLUDES_CALLS
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
test "a bad includes association logs and still renders" do
|
|
194
|
+
logged = []
|
|
195
|
+
Rails.logger.stub(:warn, ->(message) { logged << message }) do
|
|
196
|
+
get "/internal/admin_suite/ops/index_query_characterization_bad_includes_widgets"
|
|
197
|
+
end
|
|
198
|
+
assert_response :success
|
|
199
|
+
assert_includes response.body, "Zulu"
|
|
200
|
+
assert_equal 1, logged.size
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
test "paginate 30 yields 30 rows per page" do
|
|
204
|
+
get PAGED_PATH
|
|
205
|
+
assert_equal 30, rendered_row_count
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
test "per_page 50 overrides the DSL value" do
|
|
209
|
+
get PAGED_PATH, params: { per_page: "50" }
|
|
210
|
+
assert_equal 50, rendered_row_count
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
test "an excessive per_page clamps to 100" do
|
|
214
|
+
get PAGED_PATH, params: { per_page: "999999" }
|
|
215
|
+
assert_equal 100, rendered_row_count
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
test "invalid and zero per_page values fall back to the DSL value" do
|
|
219
|
+
[ "abc", "0" ].each do |value|
|
|
220
|
+
get PAGED_PATH, params: { per_page: value }
|
|
221
|
+
assert_equal 30, rendered_row_count
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
test "a leading-zero per_page parses with Integer octal semantics" do
|
|
226
|
+
get PAGED_PATH, params: { per_page: "010" }
|
|
227
|
+
assert_equal 8, rendered_row_count
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module McpAggregateFixtures
|
|
6
|
+
class Widget
|
|
7
|
+
extend ActiveModel::Naming
|
|
8
|
+
|
|
9
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new([new, new])
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Admin
|
|
14
|
+
module Resources
|
|
15
|
+
class McpAggregateWidgetResource < Admin::Base::Resource
|
|
16
|
+
model McpAggregateFixtures::Widget
|
|
17
|
+
portal :ops
|
|
18
|
+
section :observability
|
|
19
|
+
index do
|
|
20
|
+
stats { stat :total, ->(scope) { scope.count } }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class McpAggregateTest < McpIntegrationTest
|
|
27
|
+
def call_tool(arguments)
|
|
28
|
+
post "/internal/admin_suite/mcp",
|
|
29
|
+
params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "aggregate", arguments: arguments } }.to_json,
|
|
30
|
+
headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
|
|
31
|
+
JSON.parse(response.body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
test "returns a count and declared stats without records" do
|
|
35
|
+
result = with_authorize(->(**) { true }) do
|
|
36
|
+
call_tool(resource: "mcp_aggregate_widget")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
payload = JSON.parse(result.dig("result", "content", 0, "text"))
|
|
40
|
+
assert_equal 2, payload.fetch("count")
|
|
41
|
+
assert_equal 2, payload.dig("stats", "total")
|
|
42
|
+
refute payload.key?("rows"), "aggregate must never return records"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module McpAuthorizationFixtures
|
|
6
|
+
class Widget
|
|
7
|
+
extend ActiveModel::Naming
|
|
8
|
+
|
|
9
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new([])
|
|
10
|
+
def self.find_by(id:) = new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Admin
|
|
15
|
+
module Resources
|
|
16
|
+
class McpAuthorizationWidgetResource < Admin::Base::Resource
|
|
17
|
+
model McpAuthorizationFixtures::Widget
|
|
18
|
+
portal :ops
|
|
19
|
+
section :observability
|
|
20
|
+
index { columns { column :name } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class McpAuthorizationDisabledResource < Admin::Base::Resource
|
|
24
|
+
model McpAuthorizationFixtures::Widget
|
|
25
|
+
portal :ops
|
|
26
|
+
section :observability
|
|
27
|
+
mcp false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class McpAuthorizationTest < McpIntegrationTest
|
|
33
|
+
def rpc(method, params = {})
|
|
34
|
+
post "/internal/admin_suite/mcp",
|
|
35
|
+
params: { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json,
|
|
36
|
+
headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
|
|
37
|
+
JSON.parse(response.body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call_tool(name, arguments = {})
|
|
41
|
+
rpc("tools/call", { name: name, arguments: arguments })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
test "a nil authorize hook advertises no tools" do
|
|
45
|
+
with_authorize(nil) do
|
|
46
|
+
assert_empty rpc("tools/list").dig("result", "tools")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test "a nil authorize hook denies a direct call to every tool" do
|
|
51
|
+
ctx = { actor: "x" }
|
|
52
|
+
|
|
53
|
+
with_authorize(nil) do
|
|
54
|
+
[
|
|
55
|
+
AdminSuite::Mcp::Tools::DescribeResources.call(server_context: ctx),
|
|
56
|
+
AdminSuite::Mcp::Tools::ListRecords.call(resource: "mcp_authorization_widget", server_context: ctx),
|
|
57
|
+
AdminSuite::Mcp::Tools::GetRecord.call(resource: "mcp_authorization_widget", id: "1", server_context: ctx),
|
|
58
|
+
AdminSuite::Mcp::Tools::Aggregate.call(resource: "mcp_authorization_widget", server_context: ctx)
|
|
59
|
+
].each do |response|
|
|
60
|
+
assert response.error?, response.inspect
|
|
61
|
+
assert_equal AdminSuite::Mcp::Authorization::DENIED_MESSAGE, response.content.first[:text]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
test "the authorize hook receives an mcp surface and a read action" do
|
|
67
|
+
seen = []
|
|
68
|
+
hook = lambda do |action:, record:, context:, **|
|
|
69
|
+
seen << [action, context.surface, record]
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
with_authorize(hook) do
|
|
74
|
+
call_tool("list_records", { resource: "mcp_authorization_widget" })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
assert_includes seen, [:read, :mcp, nil]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "describe_resources omits resources the hook denies" do
|
|
81
|
+
hook = ->(resource:, **) { resource.resource_name != "mcp_authorization_widget" }
|
|
82
|
+
|
|
83
|
+
with_authorize(hook) do
|
|
84
|
+
refute_match(/mcp_authorization_widget/, call_tool("describe_resources").to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "an mcp-disabled resource is invisible and unreachable" do
|
|
89
|
+
with_authorize(->(**) { true }) do
|
|
90
|
+
refute_match(/mcp_authorization_disabled/, call_tool("describe_resources").to_s)
|
|
91
|
+
assert call_tool("list_records", { resource: "mcp_authorization_disabled" }).dig("result", "isError")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test "a raising authorize hook denies instead of returning a server error" do
|
|
96
|
+
with_authorize(->(**) { raise "boom" }) do
|
|
97
|
+
result = call_tool("list_records", { resource: "mcp_authorization_widget" })
|
|
98
|
+
|
|
99
|
+
assert result.dig("result", "isError"), result.inspect
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|