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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +203 -18
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/assets/vendor/chart.umd.min.js +14 -0
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +71 -17
  10. data/app/helpers/admin_suite/base_helper.rb +187 -10
  11. data/app/javascript/admin_suite_application.js +3 -0
  12. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/resources/index.html.erb +25 -5
  15. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  16. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  17. data/config/routes.rb +8 -0
  18. data/lib/admin/base/filter_builder.rb +48 -5
  19. data/lib/admin/base/resource.rb +46 -32
  20. data/lib/admin_suite/auth/host_user.rb +42 -0
  21. data/lib/admin_suite/auth/strategy.rb +1 -1
  22. data/lib/admin_suite/auth.rb +15 -0
  23. data/lib/admin_suite/authorization_context.rb +28 -0
  24. data/lib/admin_suite/configuration.rb +66 -8
  25. data/lib/admin_suite/engine.rb +1 -1
  26. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  27. data/lib/admin_suite/mcp/authorization.rb +68 -0
  28. data/lib/admin_suite/mcp/serializer.rb +138 -0
  29. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  30. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  31. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  32. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  33. data/lib/admin_suite/mcp.rb +53 -0
  34. data/lib/admin_suite/query.rb +71 -0
  35. data/lib/admin_suite/renderer_registry.rb +11 -0
  36. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  37. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +23 -7
  40. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  41. data/test/controllers/resources_controller_test.rb +19 -13
  42. data/test/integration/association_linking_test.rb +292 -0
  43. data/test/integration/authentication_test.rb +10 -0
  44. data/test/integration/authorization_test.rb +20 -3
  45. data/test/integration/chart_panel_test.rb +491 -0
  46. data/test/integration/dashboard_test.rb +9 -2
  47. data/test/integration/index_query_characterization_test.rb +229 -0
  48. data/test/integration/index_table_test.rb +289 -0
  49. data/test/integration/mcp_aggregate_test.rb +44 -0
  50. data/test/integration/mcp_authorization_test.rb +102 -0
  51. data/test/integration/mcp_endpoint_test.rb +89 -0
  52. data/test/integration/mcp_get_record_test.rb +62 -0
  53. data/test/integration/mcp_instrumentation_test.rb +107 -0
  54. data/test/integration/mcp_list_records_test.rb +75 -0
  55. data/test/integration/mcp_parity_test.rb +155 -0
  56. data/test/integration/mcp_release_test.rb +131 -0
  57. data/test/integration/navigation_sections_test.rb +21 -0
  58. data/test/integration/read_only_resource_test.rb +128 -2
  59. data/test/integration/searchable_select_search_test.rb +369 -0
  60. data/test/integration/show_hide_blank_test.rb +195 -0
  61. data/test/integration/toggle_test.rb +106 -0
  62. data/test/lib/auth_host_user_test.rb +52 -0
  63. data/test/lib/auth_http_basic_test.rb +10 -0
  64. data/test/lib/auth_test.rb +20 -3
  65. data/test/lib/authorization_context_test.rb +127 -0
  66. data/test/lib/definition_loader_test.rb +12 -0
  67. data/test/lib/engine_defaults_test.rb +1 -2
  68. data/test/lib/form_field_renderer_test.rb +83 -11
  69. data/test/lib/format_table_cell_test.rb +49 -0
  70. data/test/lib/index_includes_test.rb +223 -0
  71. data/test/lib/mcp_serializer_test.rb +107 -0
  72. data/test/lib/query_test.rb +91 -0
  73. data/test/lib/removed_deprecations_test.rb +16 -0
  74. data/test/lib/renderer_test.rb +23 -7
  75. data/test/publish_workflow_test.rb +63 -0
  76. data/test/test_helper.rb +73 -8
  77. metadata +76 -9
  78. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
  79. data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
  80. 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-&gt;...`, not a literal `->`.
160
+ assert_match(/<tr[^>]*data-action="click-&gt;admin-suite--click-actions#navigate"[^>]*>/, response.body)
161
+ assert_match(
162
+ %r{<tr[^>]*data-admin-suite--click-actions-url-value="[^"]*index_table_widgets/1"[^>]*>},
163
+ response.body
164
+ )
165
+ end
166
+
167
+ # Item 3: per-page selector + server-side clamp.
168
+ test "the filter form offers a 25/50/100 per_page selector" do
169
+ get "/internal/admin_suite/ops/index_table_widgets"
170
+ assert_response :success
171
+ assert_match(/<select[^>]*name="per_page"[^>]*>/, response.body)
172
+ assert_match(/<option[^>]*value="25"/, response.body)
173
+ assert_match(/<option[^>]*value="50"/, response.body)
174
+ assert_match(/<option[^>]*value="100"/, response.body)
175
+ end
176
+
177
+ # Counts `<tr>` inside `<tbody>` only, deliberately independent of item
178
+ # 2's row-click markup (which lands in the same `<tr>` tags) -- so a
179
+ # per_page test failure can never be secretly caused by row-click not
180
+ # being wired yet.
181
+ def row_count(body)
182
+ tbody = body[%r{<tbody.*?</tbody>}m]
183
+ tbody ? tbody.scan(/<tr[ >]/).size : 0
184
+ end
185
+
186
+ test "no per_page param falls back to the DSL's paginate(n) value" do
187
+ get "/internal/admin_suite/ops/index_table_paged_widgets"
188
+ assert_response :success
189
+ assert_equal 10, row_count(response.body)
190
+ end
191
+
192
+ test "a valid per_page renders that many rows" do
193
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 50 }
194
+ assert_response :success
195
+ assert_equal 50, row_count(response.body)
196
+ end
197
+
198
+ test "per_page above the max clamps to 100, not the requested amount" do
199
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 999_999 }
200
+ assert_response :success
201
+ assert_equal 100, row_count(response.body)
202
+ end
203
+
204
+ test "per_page=0 falls back to the DSL value instead of an empty/unbounded page" do
205
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 0 }
206
+ assert_response :success
207
+ assert_equal 10, row_count(response.body)
208
+ end
209
+
210
+ test "a negative per_page falls back to the DSL value" do
211
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: -1 }
212
+ assert_response :success
213
+ assert_equal 10, row_count(response.body)
214
+ end
215
+
216
+ test "a non-numeric per_page falls back to the DSL value" do
217
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: "abc" }
218
+ assert_response :success
219
+ assert_equal 10, row_count(response.body)
220
+ end
221
+
222
+ test "an array-shaped per_page (per_page[]=1) falls back to the DSL value" do
223
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: [ "1" ] }
224
+ assert_response :success
225
+ assert_equal 10, row_count(response.body)
226
+ end
227
+
228
+ # Item 4: column alignment + css_class.
229
+ test "align: :right emits text-right on the td" do
230
+ get "/internal/admin_suite/ops/index_table_widgets"
231
+ assert_response :success
232
+ assert_match(%r{<td class="[^"]*\btext-right\b[^"]*">\s*5\s*</td>}, response.body)
233
+ end
234
+
235
+ test "column.css_class (the class: option) reaches the td" do
236
+ get "/internal/admin_suite/ops/index_table_widgets"
237
+ assert_response :success
238
+ assert_match(%r{<td class="[^"]*\bfont-mono\b[^"]*">}, response.body)
239
+ end
240
+
241
+ test "an unknown align value renders successfully with no fabricated text-<value> class" do
242
+ get "/internal/admin_suite/ops/index_table_widgets"
243
+ assert_response :success
244
+ refute_includes response.body, "text-diagonal"
245
+ end
246
+
247
+ # Isolates a single fixture row's `<tr>...</tr>` markup by a distinctive
248
+ # cell value, so nil-cell assertions can target Beta's row specifically
249
+ # instead of matching the first empty-looking `<td>` anywhere on the
250
+ # page (there are several: Alpha's row has none, but a loose regex
251
+ # would not tell the two rows apart).
252
+ def widget_row(body, marker)
253
+ tbody = body[%r{<tbody.*?</tbody>}m]
254
+ tbody.scan(%r{<tr.*?</tr>}m).find { |r| r.include?(marker) }
255
+ end
256
+
257
+ # Item 5: nil cells render an em dash, and Task 3's association links
258
+ # are not regressed by that change.
259
+ test "a nil scalar column renders an em dash, not a blank cell" do
260
+ get "/internal/admin_suite/ops/index_table_widgets"
261
+ assert_response :success
262
+ beta_row = widget_row(response.body, "Beta")
263
+ refute_nil beta_row, "expected to find Beta's row"
264
+ # Beta's `count` is nil -- the fallback branch must turn that into an
265
+ # em dash instead of the empty string it renders today.
266
+ assert_match(%r{<td[^>]*>\s*—\s*</td>}, beta_row)
267
+ end
268
+
269
+ test "a nil association column renders an em dash, not a blank cell" do
270
+ get "/internal/admin_suite/ops/index_table_widgets"
271
+ assert_response :success
272
+ beta_row = widget_row(response.body, "Beta")
273
+ refute_nil beta_row, "expected to find Beta's row"
274
+ # Beta's `company` is nil (Task 3 deliberately left nil association
275
+ # handling to this task). Count how many dash-only cells the row has:
276
+ # count and status are also nil, so there must be at least 3 (not 2),
277
+ # proving company's cell got the dash too rather than staying blank.
278
+ dash_cells = beta_row.scan(%r{<td[^>]*>\s*—\s*</td>}).size
279
+ assert_equal 3, dash_cells,
280
+ "expected 3 dash cells (count, status, company all nil) in Beta's row, got #{dash_cells}"
281
+ end
282
+
283
+ test "a present association still renders as a link (Task 3 non-regression)" do
284
+ get "/internal/admin_suite/ops/index_table_widgets"
285
+ assert_response :success
286
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
287
+ end
288
+ end
289
+ end
@@ -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