admin_suite 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +69 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/controllers/admin_suite/application_controller.rb +12 -11
  7. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  8. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  9. data/app/views/admin_suite/resources/index.html.erb +1 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/admin/base/resource.rb +7 -29
  12. data/lib/admin_suite/auth/host_user.rb +42 -0
  13. data/lib/admin_suite/auth/strategy.rb +1 -1
  14. data/lib/admin_suite/auth.rb +15 -0
  15. data/lib/admin_suite/authorization_context.rb +28 -0
  16. data/lib/admin_suite/configuration.rb +56 -2
  17. data/lib/admin_suite/mcp/authorization.rb +68 -0
  18. data/lib/admin_suite/mcp/serializer.rb +138 -0
  19. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  20. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  21. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  22. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  23. data/lib/admin_suite/mcp.rb +53 -0
  24. data/lib/admin_suite/query.rb +71 -0
  25. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  26. data/lib/admin_suite/version.rb +1 -1
  27. data/lib/admin_suite.rb +14 -9
  28. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  29. data/test/controllers/resources_controller_test.rb +19 -13
  30. data/test/integration/authentication_test.rb +10 -0
  31. data/test/integration/authorization_test.rb +20 -3
  32. data/test/integration/index_query_characterization_test.rb +229 -0
  33. data/test/integration/mcp_aggregate_test.rb +44 -0
  34. data/test/integration/mcp_authorization_test.rb +102 -0
  35. data/test/integration/mcp_endpoint_test.rb +89 -0
  36. data/test/integration/mcp_get_record_test.rb +62 -0
  37. data/test/integration/mcp_instrumentation_test.rb +107 -0
  38. data/test/integration/mcp_list_records_test.rb +75 -0
  39. data/test/integration/mcp_parity_test.rb +155 -0
  40. data/test/integration/mcp_release_test.rb +131 -0
  41. data/test/integration/read_only_resource_test.rb +128 -2
  42. data/test/integration/searchable_select_search_test.rb +5 -4
  43. data/test/lib/auth_host_user_test.rb +52 -0
  44. data/test/lib/auth_http_basic_test.rb +10 -0
  45. data/test/lib/auth_test.rb +20 -3
  46. data/test/lib/authorization_context_test.rb +127 -0
  47. data/test/lib/engine_defaults_test.rb +1 -2
  48. data/test/lib/mcp_serializer_test.rb +107 -0
  49. data/test/lib/query_test.rb +91 -0
  50. data/test/lib/removed_deprecations_test.rb +16 -0
  51. data/test/publish_workflow_test.rb +63 -0
  52. data/test/test_helper.rb +26 -0
  53. metadata +42 -5
  54. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  55. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  56. data/test/lib/resource_exportable_deprecation_test.rb +0 -47
@@ -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
@@ -35,6 +35,10 @@ module ReadOnlyResourceFixtures
35
35
  new
36
36
  end
37
37
 
38
+ def self.where(id:)
39
+ id.filter_map { |value| find(value) }
40
+ end
41
+
38
42
  def to_param
39
43
  id.to_s
40
44
  end
@@ -43,7 +47,16 @@ module ReadOnlyResourceFixtures
43
47
  { "id" => id, "name" => name }
44
48
  end
45
49
 
50
+ def self.ping_calls
51
+ @ping_calls || 0
52
+ end
53
+
54
+ def self.ping_calls=(value)
55
+ @ping_calls = value
56
+ end
57
+
46
58
  def ping
59
+ self.class.ping_calls += 1
47
60
  true
48
61
  end
49
62
  end
@@ -65,6 +78,88 @@ module Admin
65
78
 
66
79
  actions do
67
80
  action :ping
81
+ bulk_action :ping
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ module WritableResourceFixtures
88
+ class Widget
89
+ extend ActiveModel::Naming
90
+
91
+ attr_reader :id, :name
92
+
93
+ def initialize(id: 1, name: "Writable widget")
94
+ @id = id
95
+ @name = name
96
+ end
97
+
98
+ def self.all
99
+ ReadOnlyResourceFixtures::Relation.new([ new ])
100
+ end
101
+
102
+ def self.column_names
103
+ %w[id name]
104
+ end
105
+
106
+ def self.primary_key
107
+ "id"
108
+ end
109
+
110
+ def self.columns_hash
111
+ { "id" => Struct.new(:type).new(:integer) }
112
+ end
113
+
114
+ def self.find(id)
115
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
116
+
117
+ new
118
+ end
119
+
120
+ def self.where(id:)
121
+ id.filter_map { |value| find(value) }
122
+ end
123
+
124
+ def self.ping_calls
125
+ @ping_calls || 0
126
+ end
127
+
128
+ def self.ping_calls=(value)
129
+ @ping_calls = value
130
+ end
131
+
132
+ def to_param
133
+ id.to_s
134
+ end
135
+
136
+ def attributes
137
+ { "id" => id, "name" => name }
138
+ end
139
+
140
+ def ping
141
+ self.class.ping_calls += 1
142
+ true
143
+ end
144
+ end
145
+ end
146
+
147
+ module Admin
148
+ module Resources
149
+ class WritableWidgetResource < Admin::Base::Resource
150
+ model WritableResourceFixtures::Widget
151
+ portal :ops
152
+ section :observability
153
+
154
+ index do
155
+ columns do
156
+ column :name
157
+ end
158
+ end
159
+
160
+ actions do
161
+ action :ping
162
+ bulk_action :ping
68
163
  end
69
164
  end
70
165
  end
@@ -73,6 +168,12 @@ end
73
168
  module AdminSuite
74
169
  class ReadOnlyResourceTest < ActionDispatch::IntegrationTest
75
170
  BASE_PATH = "/internal/admin_suite/ops/read_only_widgets"
171
+ WRITABLE_PATH = "/internal/admin_suite/ops/writable_widgets"
172
+
173
+ setup do
174
+ ReadOnlyResourceFixtures::Widget.ping_calls = 0
175
+ WritableResourceFixtures::Widget.ping_calls = 0
176
+ end
76
177
 
77
178
  test "direct built in mutation endpoints are rejected" do
78
179
  get "#{BASE_PATH}/new"
@@ -122,9 +223,34 @@ module AdminSuite
122
223
  assert_response :not_found
123
224
  end
124
225
 
125
- test "declared member actions remain allowed on read_only resources" do
226
+ test "declared member actions are rejected on read_only resources" do
126
227
  post "#{BASE_PATH}/1/execute_action/ping"
127
- refute_equal 404, response.status
228
+
229
+ assert_response :not_found
230
+ assert_equal 0, ReadOnlyResourceFixtures::Widget.ping_calls
231
+ end
232
+
233
+ test "declared bulk actions are rejected on read_only resources" do
234
+ post "#{BASE_PATH}/bulk_action/ping", params: { ids: [ "1" ] }
235
+
236
+ assert_response :not_found
237
+ assert_equal 0, ReadOnlyResourceFixtures::Widget.ping_calls
238
+ end
239
+
240
+ test "declared member actions still execute on writable resources" do
241
+ post "#{WRITABLE_PATH}/1/execute_action/ping"
242
+
243
+ assert_redirected_to "#{WRITABLE_PATH}/1"
244
+ assert_equal 1, WritableResourceFixtures::Widget.ping_calls
245
+ end
246
+
247
+ test "declared bulk actions still execute on writable resources" do
248
+ post "#{WRITABLE_PATH}/bulk_action/ping", params: { ids: [ "1" ] }
249
+
250
+ assert_redirected_to WRITABLE_PATH
251
+ follow_redirect!
252
+ assert_includes response.body, "Successfully processed 1 records"
253
+ assert_equal 1, WritableResourceFixtures::Widget.ping_calls
128
254
  end
129
255
  end
130
256
  end