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.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/CHANGELOG.md +69 -11
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- 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 +7 -78
- data/app/views/admin_suite/resources/index.html.erb +1 -0
- data/config/routes.rb +5 -0
- data/lib/admin/base/resource.rb +7 -29
- 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 +56 -2
- 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/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +14 -9
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/index_query_characterization_test.rb +229 -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/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +5 -4
- 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/engine_defaults_test.rb +1 -2
- 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/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +26 -0
- metadata +42 -5
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
- data/test/lib/resource_exportable_deprecation_test.rb +0 -47
data/lib/admin_suite.rb
CHANGED
|
@@ -8,6 +8,11 @@ end
|
|
|
8
8
|
|
|
9
9
|
require "pagy"
|
|
10
10
|
|
|
11
|
+
# Hard dependency, declared in the gemspec but not a direct Gemfile entry in
|
|
12
|
+
# any host -- Bundler only auto-requires direct Gemfile dependencies, never a
|
|
13
|
+
# gem's transitive runtime dependencies.
|
|
14
|
+
require "mcp"
|
|
15
|
+
|
|
11
16
|
# Hard dependency, declared in the gemspec but not a direct Gemfile entry in
|
|
12
17
|
# any host -- Bundler's `Bundler.require` only auto-requires gems declared
|
|
13
18
|
# directly in the Gemfile (or via `gemspec`'s own path entry), not a gem's
|
|
@@ -20,6 +25,15 @@ require "turbo-rails"
|
|
|
20
25
|
require "admin_suite/version"
|
|
21
26
|
require "admin_suite/deprecation"
|
|
22
27
|
require "admin_suite/configuration"
|
|
28
|
+
require "admin_suite/authorization_context"
|
|
29
|
+
require "admin_suite/query"
|
|
30
|
+
require "admin_suite/mcp/authorization"
|
|
31
|
+
require "admin_suite/mcp/serializer"
|
|
32
|
+
require "admin_suite/mcp/tools/describe_resources"
|
|
33
|
+
require "admin_suite/mcp/tools/list_records"
|
|
34
|
+
require "admin_suite/mcp/tools/get_record"
|
|
35
|
+
require "admin_suite/mcp/tools/aggregate"
|
|
36
|
+
require "admin_suite/mcp"
|
|
23
37
|
require "admin_suite/markdown_renderer"
|
|
24
38
|
require "admin_suite/theme_palette"
|
|
25
39
|
require "admin_suite/portal_registry"
|
|
@@ -34,7 +48,6 @@ require "admin_suite/renderers/json_renderer"
|
|
|
34
48
|
require "admin_suite/renderers/key_value_renderer"
|
|
35
49
|
require "admin_suite/renderers/table_from_renderer"
|
|
36
50
|
require "admin_suite/renderers/code_renderer"
|
|
37
|
-
require "admin_suite/renderers/legacy_gleania"
|
|
38
51
|
require "admin_suite/legacy_custom_renderer_procs"
|
|
39
52
|
require "admin_suite/definition_loader"
|
|
40
53
|
require "admin_suite/host_autoload_policy"
|
|
@@ -58,14 +71,6 @@ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::Code
|
|
|
58
71
|
AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
|
|
59
72
|
AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
|
|
60
73
|
|
|
61
|
-
# Deprecated (removal targeted at 0.6.0, moved from the originally-planned
|
|
62
|
-
# 0.5.0 -- both host migrations, gleania and trust_growth, are in flight):
|
|
63
|
-
# the four Gleania-specific LLM chat-transcript renderers. See
|
|
64
|
-
# `AdminSuite::Renderers::LegacyGleania` for details.
|
|
65
|
-
AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
|
|
66
|
-
AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
|
|
67
|
-
AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
|
|
68
|
-
AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
|
|
69
74
|
|
|
70
75
|
module AdminSuite
|
|
71
76
|
class << self
|
|
@@ -26,9 +26,12 @@ AdminSuite.configure do |config|
|
|
|
26
26
|
# provide one (legacy fallback):
|
|
27
27
|
config.current_actor = ->(controller) { controller.respond_to?(:current_user) ? controller.current_user : nil }
|
|
28
28
|
|
|
29
|
-
# Authorization hook — called for every resource request
|
|
29
|
+
# Authorization hook — called for every resource request.
|
|
30
|
+
# Signature: ->(actor:, action:, resource:, record:, context:) { true }
|
|
30
31
|
# action is one of :read, :create, :update, :destroy, :execute.
|
|
31
|
-
#
|
|
32
|
+
# nil hook: authenticated web requests are allowed; MCP serves no tools/data.
|
|
33
|
+
# Hook return of false or nil denies the request (no disclose/mutate).
|
|
34
|
+
# config.authorize = ->(actor:, action:, resource:, record:, context:) { actor&.admin? }
|
|
32
35
|
config.authorize = nil
|
|
33
36
|
|
|
34
37
|
# Optional sign-out action in the topbar.
|
|
@@ -6,12 +6,7 @@ module AdminSuite
|
|
|
6
6
|
class ResourcesControllerTest < ActiveSupport::TestCase
|
|
7
7
|
class TestController < ResourcesController
|
|
8
8
|
attr_writer :test_resource_config, :test_params
|
|
9
|
-
attr_reader :
|
|
10
|
-
|
|
11
|
-
def initialize
|
|
12
|
-
super
|
|
13
|
-
@filter_calls = 0
|
|
14
|
-
end
|
|
9
|
+
attr_reader :paginated_scope, :pagy_limit
|
|
15
10
|
|
|
16
11
|
def params
|
|
17
12
|
@test_params ||= {}
|
|
@@ -23,18 +18,27 @@ module AdminSuite
|
|
|
23
18
|
@test_resource_config
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
def
|
|
27
|
-
@filter_calls += 1
|
|
28
|
-
{ total: 37 }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def paginate_collection(scope)
|
|
21
|
+
def pagy(scope, limit:)
|
|
32
22
|
@paginated_scope = scope
|
|
23
|
+
@pagy_limit = limit
|
|
33
24
|
[ Object.new, :paginated ]
|
|
34
25
|
end
|
|
35
26
|
end
|
|
36
27
|
|
|
28
|
+
class StatsModel
|
|
29
|
+
class << self
|
|
30
|
+
attr_accessor :all_calls
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
self.all_calls = all_calls.to_i + 1
|
|
34
|
+
{ total: 37 }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
37
39
|
class StatsResource < Admin::Base::Resource
|
|
40
|
+
model StatsModel
|
|
41
|
+
|
|
38
42
|
index do
|
|
39
43
|
stats do
|
|
40
44
|
stat :legacy, -> { 11 }
|
|
@@ -72,11 +76,13 @@ module AdminSuite
|
|
|
72
76
|
test "index reuses one filtered unpaginated scope for stats and pagination" do
|
|
73
77
|
controller = TestController.new
|
|
74
78
|
controller.test_resource_config = StatsResource
|
|
79
|
+
StatsModel.all_calls = 0
|
|
75
80
|
|
|
76
81
|
controller.index
|
|
77
82
|
|
|
78
|
-
assert_equal 1,
|
|
83
|
+
assert_equal 1, StatsModel.all_calls
|
|
79
84
|
assert_equal({ total: 37 }, controller.paginated_scope)
|
|
85
|
+
assert_equal 25, controller.pagy_limit
|
|
80
86
|
assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
|
|
81
87
|
assert_equal :paginated, controller.instance_variable_get(:@collection)
|
|
82
88
|
end
|
|
@@ -43,6 +43,16 @@ module AdminSuite
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
test "unknown auth strategy fails closed and lists registered strategies" do
|
|
47
|
+
with_config(allow_unauthenticated: false, auth_strategy: :typo) do
|
|
48
|
+
get ROOT
|
|
49
|
+
|
|
50
|
+
assert_response :forbidden
|
|
51
|
+
assert_includes response.body, "Unknown AdminSuite auth strategy :typo"
|
|
52
|
+
assert_includes response.body, "http_basic"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
46
56
|
test "legacy authenticate lambda still works" do
|
|
47
57
|
denials = ->(controller) { controller.head :forbidden }
|
|
48
58
|
with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: denials) do
|
|
@@ -66,13 +66,29 @@ module AdminSuite
|
|
|
66
66
|
with_authorize(->(**) { false }) do
|
|
67
67
|
get BASE
|
|
68
68
|
assert_response :forbidden
|
|
69
|
+
refute_includes response.body, "Gadget one"
|
|
69
70
|
end
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
test "authorize
|
|
73
|
+
test "nil authorize result denies with 403 and does not disclose the record" do
|
|
74
|
+
with_authorize(->(**) { nil }) do
|
|
75
|
+
get "#{BASE}/1"
|
|
76
|
+
assert_response :forbidden
|
|
77
|
+
refute_includes response.body, "Gadget one"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
test "nil authorize result cannot mutate" do
|
|
82
|
+
with_authorize(->(**) { nil }) do
|
|
83
|
+
delete "#{BASE}/1"
|
|
84
|
+
assert_response :forbidden
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "authorize receives actor, mapped action, resource, record, context" do
|
|
73
89
|
captured = nil
|
|
74
|
-
hook = lambda do |actor:, action:, resource:, record:,
|
|
75
|
-
captured = { action: action, resource: resource, record: record }
|
|
90
|
+
hook = lambda do |actor:, action:, resource:, record:, context:|
|
|
91
|
+
captured = { action: action, resource: resource, record: record, context: context }
|
|
76
92
|
true
|
|
77
93
|
end
|
|
78
94
|
|
|
@@ -83,6 +99,7 @@ module AdminSuite
|
|
|
83
99
|
assert_equal :read, captured[:action]
|
|
84
100
|
assert_equal Admin::Resources::AuthzGadgetResource, captured[:resource]
|
|
85
101
|
assert_instance_of AuthzFixtures::Gadget, captured[:record]
|
|
102
|
+
assert_equal :web, captured[:context].surface
|
|
86
103
|
end
|
|
87
104
|
|
|
88
105
|
test "destroy maps to :destroy" do
|
|
@@ -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,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
|
|
@@ -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
|