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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +78 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/Gemfile +1 -0
  6. data/README.md +25 -5
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  10. data/app/views/admin_suite/resources/index.html.erb +1 -0
  11. data/config/routes.rb +5 -0
  12. data/lib/admin/base/resource.rb +7 -29
  13. data/lib/admin_suite/auth/host_user.rb +42 -0
  14. data/lib/admin_suite/auth/strategy.rb +1 -1
  15. data/lib/admin_suite/auth.rb +15 -0
  16. data/lib/admin_suite/authorization_context.rb +28 -0
  17. data/lib/admin_suite/configuration.rb +56 -2
  18. data/lib/admin_suite/mcp/authorization.rb +68 -0
  19. data/lib/admin_suite/mcp/serializer.rb +138 -0
  20. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  21. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  22. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  23. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  24. data/lib/admin_suite/mcp.rb +53 -0
  25. data/lib/admin_suite/query.rb +71 -0
  26. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  27. data/lib/admin_suite/version.rb +1 -1
  28. data/lib/admin_suite.rb +14 -9
  29. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  30. data/test/archspec/enforcement_test.rb +115 -0
  31. data/test/controllers/resources_controller_test.rb +19 -13
  32. data/test/dummy/config/environments/development.rb +9 -6
  33. data/test/dummy_boot_test.rb +21 -0
  34. data/test/integration/authentication_test.rb +10 -0
  35. data/test/integration/authorization_test.rb +20 -3
  36. data/test/integration/index_query_characterization_test.rb +229 -0
  37. data/test/integration/mcp_aggregate_test.rb +44 -0
  38. data/test/integration/mcp_authorization_test.rb +102 -0
  39. data/test/integration/mcp_endpoint_test.rb +89 -0
  40. data/test/integration/mcp_get_record_test.rb +62 -0
  41. data/test/integration/mcp_instrumentation_test.rb +107 -0
  42. data/test/integration/mcp_list_records_test.rb +75 -0
  43. data/test/integration/mcp_parity_test.rb +155 -0
  44. data/test/integration/mcp_release_test.rb +131 -0
  45. data/test/integration/read_only_resource_test.rb +128 -2
  46. data/test/integration/searchable_select_search_test.rb +5 -4
  47. data/test/lib/action_executor_test.rb +17 -9
  48. data/test/lib/auth_host_user_test.rb +52 -0
  49. data/test/lib/auth_http_basic_test.rb +10 -0
  50. data/test/lib/auth_test.rb +20 -3
  51. data/test/lib/authorization_context_test.rb +127 -0
  52. data/test/lib/engine_defaults_test.rb +1 -2
  53. data/test/lib/mcp_serializer_test.rb +107 -0
  54. data/test/lib/query_test.rb +91 -0
  55. data/test/lib/removed_deprecations_test.rb +16 -0
  56. data/test/publish_workflow_test.rb +63 -0
  57. data/test/test_helper.rb +26 -0
  58. metadata +44 -5
  59. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  60. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  61. 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
- # config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
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 :filter_calls, :paginated_scope
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 filtered_collection
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, controller.filter_calls
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
- # Raise an error on page load if there are pending migrations.
35
- config.active_record.migration_error = :page_load
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
- # Highlight code that triggered database queries in logs.
38
- config.active_record.verbose_query_logs = true
39
+ # Highlight code that triggered database queries in logs.
40
+ config.active_record.verbose_query_logs = true
39
41
 
40
- # Append comments with runtime information tags to SQL queries in logs.
41
- config.active_record.query_log_tags_enabled = true
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 receives actor, mapped action, resource, record, controller" do
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:, controller:|
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