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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpEndpointFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+ end
9
+ end
10
+
11
+ module Admin
12
+ module Resources
13
+ class McpEndpointWidgetResource < Admin::Base::Resource
14
+ model McpEndpointFixtures::Widget
15
+ portal :ops
16
+ section :observability
17
+ index do
18
+ columns { column :name }
19
+ end
20
+ actions do
21
+ action :approve
22
+ bulk_action :archive
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class McpEndpointTest < McpIntegrationTest
29
+ def rpc(method, params = {}, id: 1)
30
+ post "/internal/admin_suite/mcp",
31
+ params: { jsonrpc: "2.0", id: id, method: method, params: params }.to_json,
32
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
33
+ JSON.parse(response.body)
34
+ end
35
+
36
+ test "tools/list advertises the read tools when an authorize hook permits" do
37
+ with_authorize(->(**) { true }) do
38
+ names = rpc("tools/list").dig("result", "tools").map { |tool| tool["name"] }
39
+
40
+ assert_includes names, "describe_resources"
41
+ end
42
+ end
43
+
44
+ test "describe_resources returns declared resources and their fields" do
45
+ with_authorize(->(**) { true }) do
46
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
47
+
48
+ refute result.dig("result", "isError"), result.inspect
49
+ assert_match(/mcp_endpoint_widget/, result.to_s)
50
+ assert_match(/name/, result.to_s)
51
+ end
52
+ end
53
+
54
+ test "describe_resources includes portal, section, and declared actions" do
55
+ with_authorize(->(**) { true }) do
56
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
57
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
58
+ widget = payload.find { |resource| resource["name"] == "mcp_endpoint_widget" }
59
+
60
+ assert_equal "ops", widget.fetch("portal")
61
+ assert_equal "observability", widget.fetch("section")
62
+ assert_includes widget.fetch("actions"), { "name" => "approve", "kind" => "member" }
63
+ assert_includes widget.fetch("actions"), { "name" => "archive", "kind" => "bulk" }
64
+ end
65
+ end
66
+
67
+ test "describe_resources returns a generic error without the raised message" do
68
+ with_authorize(->(**) { true }) do
69
+ AdminSuite::Mcp::Tools::DescribeResources.stub(:describe, proc { raise "SECRET-/db/password-detail" }) do
70
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
71
+
72
+ refute result["error"], result.inspect
73
+ assert result.dig("result", "isError")
74
+ body = result.to_s
75
+ assert_match(/describe_resources failed/, body)
76
+ refute_match(/SECRET-\/db\/password-detail/, body)
77
+ end
78
+ end
79
+ end
80
+
81
+ test "a nil authorize hook advertises no tools and denies direct calls" do
82
+ with_authorize(nil) do
83
+ assert_empty rpc("tools/list").dig("result", "tools")
84
+
85
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
86
+ assert result["error"], result.inspect
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpGetRecordFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ attr_reader :id, :name, :secret_token
10
+
11
+ def initialize(id: 1, name: "Widget", secret_token: "sh-hh")
12
+ @id = id
13
+ @name = name
14
+ @secret_token = secret_token
15
+ end
16
+
17
+ def self.find_by(id:)
18
+ new if id.to_s == "1"
19
+ end
20
+ end
21
+ end
22
+
23
+ module Admin
24
+ module Resources
25
+ class McpGetRecordWidgetResource < Admin::Base::Resource
26
+ model McpGetRecordFixtures::Widget
27
+ portal :ops
28
+ section :observability
29
+ show { panel :identity, fields: %i[id name] }
30
+ end
31
+ end
32
+ end
33
+
34
+ class McpGetRecordTest < McpIntegrationTest
35
+ def call_tool(arguments)
36
+ post "/internal/admin_suite/mcp",
37
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_record", arguments: arguments } }.to_json,
38
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
39
+ JSON.parse(response.body)
40
+ end
41
+
42
+ test "returns one record using only the show field set" do
43
+ result = with_authorize(->(**) { true }) do
44
+ call_tool(resource: "mcp_get_record_widget", id: "1")
45
+ end
46
+
47
+ refute result.dig("result", "isError"), result.inspect
48
+ assert_match(/Widget/, result.to_s)
49
+ refute_match(/sh-hh/, result.to_s)
50
+ end
51
+
52
+ test "a missing id is indistinguishable from a denied read" do
53
+ missing = with_authorize(->(**) { true }) do
54
+ call_tool(resource: "mcp_get_record_widget", id: "999999")
55
+ end
56
+ denied = with_authorize(->(**) { false }) do
57
+ call_tool(resource: "mcp_get_record_widget", id: "1")
58
+ end
59
+
60
+ assert_equal missing.dig("result", "content"), denied.dig("result", "content")
61
+ end
62
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpInstrumentationFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ def self.all = ReadOnlyResourceFixtures::Relation.new([])
10
+ end
11
+ end
12
+
13
+ module Admin
14
+ module Resources
15
+ class McpInstrumentationWidgetResource < Admin::Base::Resource
16
+ model McpInstrumentationFixtures::Widget
17
+ portal :ops
18
+ section :observability
19
+ index { columns { column :name } }
20
+ end
21
+ end
22
+ end
23
+
24
+ class McpInstrumentationTest < McpIntegrationTest
25
+ AuditActor = Struct.new(:id)
26
+ def call_tool(arguments = { resource: "mcp_instrumentation_widget" })
27
+ post "/internal/admin_suite/mcp",
28
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {
29
+ name: "list_records", arguments: arguments
30
+ } }.to_json,
31
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
32
+ end
33
+
34
+ test "every tool call emits one complete event whether allowed or denied" do
35
+ events = []
36
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
37
+ events << ActiveSupport::Notifications::Event.new(*args).payload
38
+ end
39
+
40
+ with_authorize(->(**) { true }) { call_tool }
41
+ with_authorize(nil) { AdminSuite::Mcp::Tools::ListRecords.call(
42
+ resource: "mcp_instrumentation_widget", server_context: { actor: nil }
43
+ ) }
44
+
45
+ assert_equal [true, false], events.map { |event| event[:allowed] }
46
+ assert events.all? { |event| event[:tool] == "list_records" }
47
+ assert events.all? { |event| event[:action] == :read }
48
+ assert events.all? { |event| event[:duration_ms].is_a?(Numeric) }
49
+ ensure
50
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
51
+ end
52
+
53
+ test "a raise inside authorize_resource! is not logged as an authorized read" do
54
+ events = []
55
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
56
+ events << ActiveSupport::Notifications::Event.new(*args).payload
57
+ end
58
+
59
+ AdminSuite::Mcp::Authorization.stub(:authorize_resource!, proc { raise "boom" }) do
60
+ AdminSuite::Mcp::Tools::ListRecords.call(
61
+ resource: "mcp_instrumentation_widget",
62
+ server_context: { actor: "x" }
63
+ )
64
+ end
65
+
66
+ assert_equal false, events.last[:allowed]
67
+ ensure
68
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
69
+ end
70
+
71
+ test "audit metadata identifies the operator and distinguishes tool failures" do
72
+ actor = AuditActor.new(42)
73
+ request = Struct.new(:request_id).new("audit-request-123")
74
+ events = []
75
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
76
+ events << ActiveSupport::Notifications::Event.new(*args).payload
77
+ end
78
+
79
+ AdminSuite::Mcp.instrument(tool: "list_records", actor: actor, request: request) do
80
+ [MCP::Tool::Response.new([{ type: "text", text: "failed" }], error: true), nil, true]
81
+ end
82
+
83
+ assert_equal "42", events.first.fetch(:actor_id)
84
+ assert_equal actor.class.name, events.first.fetch(:actor_type)
85
+ assert_equal "audit-request-123", events.first.fetch(:request_id)
86
+ assert_equal true, events.first.fetch(:error)
87
+ assert_equal true, events.first.fetch(:allowed)
88
+ ensure
89
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
90
+ end
91
+
92
+ test "the audit event carries the applied filter set" do
93
+ events = []
94
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
95
+ events << ActiveSupport::Notifications::Event.new(*args).payload
96
+ end
97
+
98
+ with_authorize(->(**) { true }) do
99
+ call_tool(resource: "mcp_instrumentation_widget", q: "alpha", filters: { state: "open" })
100
+ end
101
+
102
+ assert_equal "alpha", events.last.fetch(:q)
103
+ assert_equal({ state: "open" }, events.last.fetch(:filters))
104
+ ensure
105
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
106
+ end
107
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpListRecordsFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ attr_reader :id, :name, :secret_token
10
+
11
+ def initialize(id: 1, name: "Widget", secret_token: "sh-hh")
12
+ @id = id
13
+ @name = name
14
+ @secret_token = secret_token
15
+ end
16
+
17
+ def self.all = ReadOnlyResourceFixtures::Relation.new([new])
18
+ end
19
+ end
20
+
21
+ module Admin
22
+ module Resources
23
+ class McpListRecordsWidgetResource < Admin::Base::Resource
24
+ model McpListRecordsFixtures::Widget
25
+ portal :ops
26
+ section :observability
27
+ index do
28
+ columns do
29
+ column :id
30
+ column :name
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ class McpListRecordsTest < McpIntegrationTest
38
+ def call_tool(name, arguments)
39
+ post "/internal/admin_suite/mcp",
40
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: name, arguments: arguments } }.to_json,
41
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
42
+ JSON.parse(response.body)
43
+ end
44
+
45
+ test "returns rows limited to declared columns" do
46
+ result = with_authorize(->(**) { true }) do
47
+ call_tool("list_records", { resource: "mcp_list_records_widget" })
48
+ end
49
+
50
+ refute result.dig("result", "isError"), result.inspect
51
+ assert_match(/Widget/, result.to_s)
52
+ refute_match(/sh-hh/, result.to_s)
53
+ end
54
+
55
+ test "clamps per_page to the configured maximum and says so" do
56
+ result = with_authorize(->(**) { true }) do
57
+ call_tool("list_records", { resource: "mcp_list_records_widget", per_page: 5000 })
58
+ end
59
+
60
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
61
+ assert_equal 100, payload.fetch("applied_per_page")
62
+ end
63
+
64
+ test "an unknown resource and a denied resource return identical errors" do
65
+ unknown = with_authorize(->(**) { true }) do
66
+ call_tool("list_records", { resource: "no_such_thing" })
67
+ end
68
+ denied = with_authorize(->(**) { false }) do
69
+ call_tool("list_records", { resource: "mcp_list_records_widget" })
70
+ end
71
+
72
+ assert_equal unknown.dig("result", "content"), denied.dig("result", "content"),
73
+ "an existence oracle: an unknown resource must be indistinguishable from a denied one"
74
+ end
75
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpParityFixtures
6
+ class Relation
7
+ include Enumerable
8
+
9
+ def initialize(records, offset: 0)
10
+ @records = records
11
+ @offset = offset
12
+ end
13
+
14
+ def each(&block) = @records.each(&block)
15
+ def count(*) = @records.size
16
+ def offset(value) = self.class.new(@records, offset: value)
17
+ def limit(value) = @records[@offset, value] || []
18
+
19
+ def where(condition, search:)
20
+ fields = condition.scan(/(\w+) ILIKE/).flatten
21
+ term = search.delete("%").downcase
22
+ self.class.new(@records.select do |record|
23
+ fields.any? { |field| record.public_send(field).to_s.downcase.include?(term) }
24
+ end)
25
+ end
26
+
27
+ def order(ordering)
28
+ field, direction = ordering.first
29
+ rows = @records.sort_by { |record| record.public_send(field).to_s }
30
+ rows.reverse! if direction.to_sym == :desc
31
+ self.class.new(rows)
32
+ end
33
+ end
34
+
35
+ class Application < ActiveRecord::Base
36
+ attr_reader :id, :name
37
+
38
+ def initialize(id:, name:)
39
+ @id = id
40
+ @name = name
41
+ end
42
+
43
+ def to_param = id.to_s
44
+ end
45
+
46
+ class Widget
47
+ extend ActiveModel::Naming
48
+
49
+ attr_reader :id, :name, :created_at, :application
50
+
51
+ def initialize(id:, name:, created_at: Time.utc(2026, 9, 10, 12, 0, 0), application: Application.new(id: 7, name: "Acme"))
52
+ @id = id
53
+ @name = name
54
+ @created_at = created_at
55
+ @application = application
56
+ end
57
+
58
+ ROWS = [new(id: 1, name: "Zulu"), new(id: 2, name: "Alpha"), new(id: 3, name: "Bravo")].freeze
59
+
60
+ def self.all = Relation.new(ROWS)
61
+ def self.column_names = %w[id name]
62
+ def self.primary_key = "id"
63
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
64
+ def to_param = id.to_s
65
+ end
66
+ end
67
+
68
+ module Admin
69
+ module Resources
70
+ class McpParityWidgetResource < Admin::Base::Resource
71
+ model McpParityFixtures::Widget
72
+ portal :ops
73
+ section :observability
74
+ index do
75
+ searchable :name
76
+ sortable :name, default: :name, direction: :asc
77
+ columns do
78
+ column :id
79
+ column :name, sortable: true
80
+ column :listings_count, ->(_r) { 42 }
81
+ column :status, ->(_r) { "active" }, type: :label
82
+ column :created_at
83
+ column :application
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ class McpParityTest < McpIntegrationTest
91
+ PATH = "/internal/admin_suite/ops/mcp_parity_widgets"
92
+
93
+ def mcp_payload(tool, arguments)
94
+ post "/internal/admin_suite/mcp",
95
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {
96
+ name: tool, arguments: arguments
97
+ } }.to_json,
98
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
99
+ rpc = JSON.parse(response.body)
100
+ JSON.parse(rpc.dig("result", "content", 0, "text"))
101
+ end
102
+
103
+ test "list_records and the index return identical ids for one query" do
104
+ with_authorize(->(**) { true }) do
105
+ get PATH, params: { sort: "name", direction: "asc" }
106
+ assert_response :success
107
+ ui_ids = css_select("tbody tr").map { |row| row["data-record-id"] }
108
+
109
+ mcp_ids = mcp_payload("list_records", {
110
+ resource: "mcp_parity_widget", sort: "name", direction: "asc"
111
+ }).fetch("rows").map { |row| row.fetch("id").to_s }
112
+
113
+ assert_equal %w[2 3 1], ui_ids
114
+ assert_equal ui_ids, mcp_ids
115
+
116
+ get PATH, params: { search: "alpha" }
117
+ search_ui_ids = css_select("tbody tr").map { |row| row["data-record-id"] }
118
+ search_mcp_ids = mcp_payload("list_records", {
119
+ resource: "mcp_parity_widget", q: "alpha"
120
+ }).fetch("rows").map { |row| row.fetch("id").to_s }
121
+ assert_equal search_ui_ids, search_mcp_ids
122
+ end
123
+ end
124
+
125
+ test "lambda columns match the index cell and object fields serialize as primitives" do
126
+ with_authorize(->(**) { true }) do
127
+ get PATH
128
+ assert_response :success
129
+ cells = css_select("tbody tr[data-record-id='1'] td")
130
+ row = mcp_payload("list_records", { resource: "mcp_parity_widget" })
131
+ .fetch("rows").find { |item| item.fetch("id") == 1 }
132
+
133
+ assert_equal "42", cells[2].text.strip
134
+ assert_equal 42, row.fetch("listings_count")
135
+ assert_includes cells[3].text, "active"
136
+ assert_equal "active", row.fetch("status")
137
+ assert_equal Time.utc(2026, 9, 10, 12, 0, 0), Time.iso8601(row.fetch("created_at"))
138
+ assert_equal "Acme", row.fetch("application")
139
+ refute_match(/#<|0x[0-9a-f]+/i, row.fetch("application"))
140
+ end
141
+ end
142
+
143
+ test "aggregate count matches the shared query's index total" do
144
+ with_authorize(->(**) { true }) do
145
+ get PATH, params: { search: "alpha" }
146
+ ui_total = css_select("tbody tr").size
147
+
148
+ mcp_total = mcp_payload("aggregate", {
149
+ resource: "mcp_parity_widget", q: "alpha"
150
+ }).fetch("count")
151
+
152
+ assert_equal ui_total, mcp_total
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpReleaseFixtures
6
+ Record = Struct.new(:id, :state)
7
+
8
+ class Relation
9
+ include Enumerable
10
+ def initialize(rows) = @rows = rows
11
+ def each(&block) = @rows.each(&block)
12
+ def count = @rows.size
13
+ def offset(n) = self.class.new(@rows.drop(n))
14
+ def limit(n) = self.class.new(@rows.first(n))
15
+ def where(conditions)
16
+ self.class.new(@rows.select { |row| conditions.all? { |key, value| row.public_send(key) == value } })
17
+ end
18
+ end
19
+
20
+ class Widget
21
+ extend ActiveModel::Naming
22
+ def self.all = Relation.new([Record.new(1, "open"), Record.new(2, "closed")])
23
+ def self.find_by(id:) = all.find { |row| row.id.to_s == id.to_s }
24
+ end
25
+ end
26
+
27
+ module Admin
28
+ module Resources
29
+ class McpReleaseWidgetResource < Admin::Base::Resource
30
+ model McpReleaseFixtures::Widget
31
+ portal :ops
32
+ section :observability
33
+ index do
34
+ paginate 500
35
+ columns { column :id }
36
+ filters { filter :state, type: :select }
37
+ end
38
+ show { panel :identity, fields: %i[id state] }
39
+ end
40
+ end
41
+ end
42
+
43
+ class McpReleaseTest < ActionDispatch::IntegrationTest
44
+ setup do
45
+ @previous_actor = AdminSuite.config.current_actor
46
+ AdminSuite.config.current_actor = ->(_) { "release-test-operator" }
47
+ end
48
+
49
+ teardown { AdminSuite.config.current_actor = @previous_actor }
50
+
51
+ def call_tool(name, **arguments)
52
+ post "/internal/admin_suite/mcp", params: {
53
+ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: name, arguments: arguments }
54
+ }.to_json, headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
55
+ JSON.parse(response.body)
56
+ end
57
+
58
+ test "JSON filters affect both records and aggregates" do
59
+ with_authorize(->(**) { true }) do
60
+ result = call_tool("list_records", resource: "mcp_release_widget", filters: { state: "open" })
61
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
62
+ assert_equal [1], payload.fetch("rows").map { |row| row.fetch("id") }
63
+ assert_equal 100, payload.fetch("applied_per_page")
64
+ result = call_tool("aggregate", resource: "mcp_release_widget", filters: { state: "closed" })
65
+ assert_equal 1, JSON.parse(result.dig("result", "content", 0, "text")).fetch("count")
66
+ end
67
+ end
68
+
69
+ test "an anonymous MCP request is rejected even in the development escape hatch" do
70
+ AdminSuite.config.current_actor = nil
71
+ with_authorize(->(**) { true }) do
72
+ post "/internal/admin_suite/mcp", params: { jsonrpc: "2.0", id: 1, method: "tools/list" }.to_json,
73
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
74
+ assert_response :unauthorized
75
+ end
76
+ end
77
+
78
+ test "get_record checks record policy and passes the HTTP request to authorization" do
79
+ seen_requests = []
80
+ policy = ->(record:, context:, **) do
81
+ seen_requests << context.request
82
+ record.nil? || record.id != 1
83
+ end
84
+ with_authorize(policy) do
85
+ denied = call_tool("get_record", resource: "mcp_release_widget", id: "1")
86
+ missing = call_tool("get_record", resource: "mcp_release_widget", id: "999")
87
+ assert denied.dig("result", "isError")
88
+ assert_equal missing.dig("result", "content"), denied.dig("result", "content")
89
+ assert seen_requests.all? { |request| request.is_a?(ActionDispatch::Request) }
90
+ end
91
+ end
92
+
93
+ test "MCP rejects foreign origins while allowing same-origin and headless clients" do
94
+ with_authorize(->(**) { true }) do
95
+ ["https://attacker.example", "null", ""].each do |origin|
96
+ post "/internal/admin_suite/mcp", params: { jsonrpc: "2.0", id: 1, method: "tools/list" }.to_json,
97
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", "HTTP_ORIGIN" => origin }
98
+ assert_response :forbidden
99
+ end
100
+ post "/internal/admin_suite/mcp", params: { jsonrpc: "2.0", id: 1, method: "tools/list" }.to_json,
101
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", "HTTP_ORIGIN" => "http://www.example.com" }
102
+ assert_response :success
103
+ end
104
+ end
105
+
106
+ test "the stateless transport accepts notifications and declines GET streams" do
107
+ with_authorize(->(**) { true }) do
108
+ post "/internal/admin_suite/mcp", params: { jsonrpc: "2.0", method: "notifications/initialized" }.to_json,
109
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
110
+ assert_response :accepted
111
+ assert_empty response.body
112
+ get "/internal/admin_suite/mcp"
113
+ assert_response :method_not_allowed
114
+ delete "/internal/admin_suite/mcp"
115
+ assert_response :method_not_allowed
116
+ end
117
+ end
118
+
119
+ test "forgery protection allows the JSON transport but rejects tokenless forms" do
120
+ previous = AdminSuite::McpController.allow_forgery_protection
121
+ AdminSuite::McpController.allow_forgery_protection = true
122
+ with_authorize(->(**) { true }) do
123
+ call_tool("list_records", resource: "mcp_release_widget")
124
+ assert_response :success
125
+ post "/internal/admin_suite/mcp", params: { method: "tools/list" }
126
+ assert_response :unprocessable_entity
127
+ end
128
+ ensure
129
+ AdminSuite::McpController.allow_forgery_protection = previous
130
+ end
131
+ end
@@ -37,5 +37,26 @@ module AdminSuite
37
37
  assert_response :success
38
38
  assert_includes response.body, "Observability" # from ReadOnlyWidgetResource's `section :observability`
39
39
  end
40
+
41
+ # `:zzz_last` (declared in `setup` above) has no resources registered
42
+ # under it anywhere in the suite, unlike `:observability`. Before this
43
+ # fix, `_sidebar.html.erb` rendered a bare section label with nothing
44
+ # underneath for a section like this; `portals/show.html.erb` already
45
+ # showed "No resources in this section yet." for the same case, so the
46
+ # sidebar was the odd one out.
47
+ test "the sidebar shows the no-resources message for a declared but empty section" do
48
+ get "/internal/admin_suite/ops"
49
+ assert_response :success
50
+
51
+ # Scoped to the sidebar itself: `portals/show.html.erb`'s own fallback
52
+ # section list also renders "No resources in this section yet." for
53
+ # the same empty section, so an unscoped `assert_includes` on the full
54
+ # body wouldn't actually prove the *sidebar* (as opposed to the main
55
+ # content) says it.
56
+ sidebar = Nokogiri::HTML(response.body).at_css(".admin-suite-sidebar")
57
+ assert sidebar, "expected to find the sidebar"
58
+ assert_includes sidebar.text, "Aaa Displayed First"
59
+ assert_includes sidebar.text, "No resources in this section yet."
60
+ end
40
61
  end
41
62
  end