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,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
|