admin_suite 0.5.0 → 0.6.2
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 +78 -11
- data/CONTRIBUTING.md +9 -4
- data/Gemfile +1 -0
- data/README.md +25 -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/archspec/enforcement_test.rb +115 -0
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/dummy/config/environments/development.rb +9 -6
- data/test/dummy_boot_test.rb +21 -0
- 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/action_executor_test.rb +17 -9
- 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 +44 -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.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun" unless defined?(Rails)
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
class ArchspecEnforcementTest < Minitest::Test
|
|
10
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
11
|
+
|
|
12
|
+
def test_pull_request_architecture_job_is_fail_closed_and_matches_bin_ci
|
|
13
|
+
workflow = YAML.load_file(File.join(ROOT, ".github/workflows/ci.yml"))
|
|
14
|
+
job = workflow.fetch("jobs").fetch("architecture")
|
|
15
|
+
assert_equal "ubuntu-latest", job["runs-on"]
|
|
16
|
+
assert_nil job["continue-on-error"]
|
|
17
|
+
assert_nil job["if"]
|
|
18
|
+
|
|
19
|
+
commands = job.fetch("steps").filter_map { |step| step["run"] }
|
|
20
|
+
assert_includes commands, "bin/archspec check"
|
|
21
|
+
assert_includes commands, "bin/archspec-baseline"
|
|
22
|
+
assert commands.none? { |command| command.include?("continue-on-error") || command.include?("[ -f") }
|
|
23
|
+
|
|
24
|
+
ci = File.read(File.join(ROOT, "bin/ci"))
|
|
25
|
+
assert_includes ci, 'step "Architecture: ArchSpec" bin/archspec check'
|
|
26
|
+
assert_includes ci, 'step "Architecture: baseline cap" bin/archspec-baseline'
|
|
27
|
+
refute_match(/if \[ -f bin\/archspec \]/, ci)
|
|
28
|
+
refute_match(/if \[ -f Archspec\.rb \]/, ci)
|
|
29
|
+
assert_equal "read", job.dig("permissions", "contents")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_mcp_cannot_call_rejects_a_write
|
|
33
|
+
spec = File.read(File.join(ROOT, "Archspec.rb"))
|
|
34
|
+
rule = spec[/mcp\.cannot_call\b.*?receiver: :any/m]
|
|
35
|
+
assert rule, "mcp.cannot_call must forbid writes on any receiver"
|
|
36
|
+
refute_match(/ui\.cannot_call/, rule)
|
|
37
|
+
assert_match(/queries\.cannot_use\b[^\n]*:mcp\b/, spec)
|
|
38
|
+
|
|
39
|
+
Dir.mktmpdir do |dir|
|
|
40
|
+
FileUtils.mkdir_p(File.join(dir, "lib/admin_suite/mcp"))
|
|
41
|
+
File.write(File.join(dir, "Archspec.rb"), spec)
|
|
42
|
+
File.write(File.join(dir, "archspec_todo.yml"), File.read(File.join(ROOT, "archspec_todo.yml")))
|
|
43
|
+
File.write(File.join(dir, "lib/admin_suite/mcp/probe.rb"), <<~RUBY)
|
|
44
|
+
module AdminSuite
|
|
45
|
+
module Mcp
|
|
46
|
+
class Probe
|
|
47
|
+
def call(record)
|
|
48
|
+
record.save
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
RUBY
|
|
54
|
+
|
|
55
|
+
output, status = run_archspec(dir, "check")
|
|
56
|
+
refute status.success?, output
|
|
57
|
+
assert_match(/must not call #save/, output)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_missing_and_invalid_configuration_fail
|
|
62
|
+
Dir.mktmpdir do |dir|
|
|
63
|
+
_output, status = run_archspec(dir, "check", "--config", File.join(dir, "missing.rb"))
|
|
64
|
+
refute status.success?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Dir.mktmpdir do |dir|
|
|
68
|
+
File.write(File.join(dir, "Archspec.rb"), "this is not ruby (\n")
|
|
69
|
+
_output, status = run_archspec(dir, "check")
|
|
70
|
+
refute status.success?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_stale_baseline_id_fails_and_current_set_passes
|
|
75
|
+
Dir.mktmpdir do |dir|
|
|
76
|
+
FileUtils.mkdir_p(File.join(dir, "app/models"))
|
|
77
|
+
File.write(File.join(dir, "app/models/widget.rb"), "class Widget\nend\n")
|
|
78
|
+
File.write(File.join(dir, "Archspec.rb"), <<~CONFIG)
|
|
79
|
+
todo "archspec_todo.yml"
|
|
80
|
+
component :models, in: "app/models/**/*.rb"
|
|
81
|
+
models.cannot_reference_constants "Nope"
|
|
82
|
+
CONFIG
|
|
83
|
+
File.write(File.join(dir, "archspec_todo.yml"), <<~YAML)
|
|
84
|
+
---
|
|
85
|
+
violations: []
|
|
86
|
+
YAML
|
|
87
|
+
|
|
88
|
+
_output, status = run_baseline(dir)
|
|
89
|
+
assert status.success?, "empty matching baseline must pass"
|
|
90
|
+
|
|
91
|
+
File.write(File.join(dir, "archspec_todo.yml"), <<~YAML)
|
|
92
|
+
---
|
|
93
|
+
violations:
|
|
94
|
+
- id: deadbeefdeadbeefdeadbeef
|
|
95
|
+
rule: constants.forbid
|
|
96
|
+
path: app/models/widget.rb
|
|
97
|
+
line: 1
|
|
98
|
+
message: gone
|
|
99
|
+
YAML
|
|
100
|
+
output, status = run_baseline(dir)
|
|
101
|
+
refute status.success?
|
|
102
|
+
assert_match(/stale deadbeefdeadbeefdeadbeef/, output)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def run_archspec(dir, *args)
|
|
109
|
+
Open3.capture2e({ "BUNDLE_GEMFILE" => File.join(ROOT, "Gemfile") }, File.join(ROOT, "bin/archspec"), *args, chdir: dir)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_baseline(dir)
|
|
113
|
+
Open3.capture2e({ "BUNDLE_GEMFILE" => File.join(ROOT, "Gemfile") }, File.join(ROOT, "bin/archspec-baseline"), chdir: dir)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -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
|
|
@@ -31,14 +31,17 @@ Rails.application.configure do
|
|
|
31
31
|
# Print deprecation notices to the Rails logger.
|
|
32
32
|
config.active_support.deprecation = :log
|
|
33
33
|
|
|
34
|
-
#
|
|
35
|
-
config.active_record
|
|
34
|
+
# The dummy app can run without the Active Record railtie.
|
|
35
|
+
if config.respond_to?(:active_record)
|
|
36
|
+
# Raise an error on page load if there are pending migrations.
|
|
37
|
+
config.active_record.migration_error = :page_load
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
# Highlight code that triggered database queries in logs.
|
|
40
|
+
config.active_record.verbose_query_logs = true
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
# Append comments with runtime information tags to SQL queries in logs.
|
|
43
|
+
config.active_record.query_log_tags_enabled = true
|
|
44
|
+
end
|
|
42
45
|
|
|
43
46
|
# Highlight code that triggered redirect in logs.
|
|
44
47
|
config.action_dispatch.verbose_redirect_logs = true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
class DummyBootTest < ActiveSupport::TestCase
|
|
8
|
+
test "database-free dummy boots and eager loads in development" do
|
|
9
|
+
script = <<~RUBY
|
|
10
|
+
require_relative "test/dummy/config/environment"
|
|
11
|
+
abort "Unexpected ActiveRecord railtie" if Rails.application.config.respond_to?(:active_record)
|
|
12
|
+
Rails.application.eager_load!
|
|
13
|
+
RUBY
|
|
14
|
+
stdout, stderr, status = Open3.capture3(
|
|
15
|
+
{ "RAILS_ENV" => "development" }, RbConfig.ruby, "-e", script,
|
|
16
|
+
chdir: File.expand_path("..", __dir__)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
assert status.success?, "Development boot failed:\n#{stdout}#{stderr}"
|
|
20
|
+
end
|
|
21
|
+
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
|