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
@@ -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
@@ -263,10 +263,10 @@ module AdminSuite
263
263
  assert_response :forbidden
264
264
  end
265
265
 
266
- test "authorize hook receives action: :read, the resource, a nil record, and the controller" do
266
+ test "authorize hook receives action: :read, the resource, a nil record, and the context" do
267
267
  captured = nil
268
- hook = lambda do |actor:, action:, resource:, record:, controller:|
269
- captured = { action: action, resource: resource, record: record, controller: controller.class }
268
+ hook = lambda do |actor:, action:, resource:, record:, context:|
269
+ captured = { action: action, resource: resource, record: record, context: context }
270
270
  true
271
271
  end
272
272
 
@@ -275,7 +275,8 @@ module AdminSuite
275
275
  assert_equal :read, captured[:action]
276
276
  assert_equal Admin::Resources::SearchableSelectCompanyResource, captured[:resource]
277
277
  assert_nil captured[:record]
278
- assert_equal AdminSuite::ResourcesController, captured[:controller]
278
+ assert_equal :web, captured[:context].surface
279
+ assert_equal AdminSuite::ResourcesController, captured[:context].controller.class
279
280
  end
280
281
 
281
282
  test "a stray ?id= is never loaded and never reaches authorize's record:" do
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "test_helper"
4
4
  require "tmpdir"
5
+ require "open3"
6
+ require "rbconfig"
5
7
 
6
8
  module Admin
7
9
  module Base
@@ -96,16 +98,22 @@ module Admin
96
98
  end
97
99
 
98
100
  test "load_action_handlers_for_admin_suite returns early when AdminSuite is not defined" do
99
- # Temporarily undefine AdminSuite to test early return
100
- admin_suite_defined = defined?(AdminSuite)
101
-
102
- skip "Cannot test AdminSuite undefined condition when AdminSuite is required" if admin_suite_defined
103
-
104
- resource_class = Struct.new(:resource_name).new("test")
105
- executor = ActionExecutor.new(resource_class, :test, nil)
101
+ # Bundler evaluates the gemspec and defines AdminSuite::VERSION.
102
+ # Bypass its RUBYOPT preload in this dependency-free subprocess.
103
+ script = <<~RUBY
104
+ require_relative "lib/admin/base/action_executor"
105
+ abort "AdminSuite unexpectedly loaded" if defined?(AdminSuite)
106
+ executor = Admin::Base::ActionExecutor.new(nil, :test, nil)
107
+ result = executor.send(:load_action_handlers_for_admin_suite!)
108
+ abort "Expected early return" unless result.nil?
109
+ abort "Handlers marked loaded" if Admin::Base::ActionExecutor.handlers_loaded
110
+ RUBY
111
+ stdout, stderr, status = Open3.capture3(
112
+ { "RUBYOPT" => nil }, RbConfig.ruby, "--disable-gems", "-e", script,
113
+ chdir: File.expand_path("../..", __dir__)
114
+ )
106
115
 
107
- # This should return early without error
108
- assert_nil executor.send(:load_action_handlers_for_admin_suite!)
116
+ assert status.success?, "Isolated executor failed:\n#{stdout}#{stderr}"
109
117
  end
110
118
 
111
119
  test "load_action_handlers_for_admin_suite handles empty action_globs gracefully" do
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class AuthNormalizeActorTest < ActiveSupport::TestCase
6
+ test "normalizes the authenticated-but-anonymous sentinel to nil" do
7
+ assert_nil AdminSuite::Auth.normalize_actor(true)
8
+ assert_nil AdminSuite::Auth.normalize_actor(false)
9
+ assert_nil AdminSuite::Auth.normalize_actor(nil)
10
+ end
11
+
12
+ test "passes a real actor through untouched" do
13
+ user = Struct.new(:email).new("ravi@techwright.io")
14
+ assert_same user, AdminSuite::Auth.normalize_actor(user)
15
+ end
16
+ end
17
+
18
+ class AuthHostUserTest < ActiveSupport::TestCase
19
+ Controller = Struct.new(:performed) do
20
+ def performed? = !!performed
21
+ def head(*) = nil
22
+ end
23
+
24
+ test "is registered under :host_user" do
25
+ assert_equal AdminSuite::Auth::HostUser, AdminSuite::Auth.lookup(:host_user)
26
+ end
27
+
28
+ test "returns the host user the resolver produced" do
29
+ user = Struct.new(:email).new("ravi@techwright.io")
30
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { user })
31
+
32
+ assert_same user, strategy.authenticate!(Controller.new(false))
33
+ end
34
+
35
+ test "denies when the resolver returns nothing" do
36
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { nil })
37
+
38
+ assert_nil strategy.authenticate!(Controller.new(false))
39
+ end
40
+
41
+ test "denies rather than raising when the resolver blows up" do
42
+ strategy = AdminSuite::Auth::HostUser.new(resolve: ->(_c) { raise "boom" })
43
+
44
+ assert_nil strategy.authenticate!(Controller.new(false))
45
+ end
46
+
47
+ test "denies loudly when no resolver is configured" do
48
+ strategy = AdminSuite::Auth::HostUser.new({})
49
+
50
+ assert_nil strategy.authenticate!(Controller.new(false))
51
+ end
52
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  raise "unset ADMIN_SUITE_USERNAME in test env" if ENV["ADMIN_SUITE_USERNAME"]
4
+ raise "unset ADMIN_SUITE_PASSWORD in test env" if ENV["ADMIN_SUITE_PASSWORD"]
4
5
 
5
6
  require "test_helper"
6
7
 
@@ -55,6 +56,15 @@ module AdminSuite
55
56
  assert_equal :forbidden, controller.rendered_status
56
57
  end
57
58
 
59
+ test "denies with 403 when password is blank" do
60
+ strategy = Auth::HttpBasic.new(username: "ravi", password: nil)
61
+ controller = ControllerDouble.new(given_username: "ravi", given_password: nil)
62
+
63
+ assert_nil strategy.authenticate!(controller)
64
+ assert_equal :forbidden, controller.rendered_status
65
+ refute controller.challenged
66
+ end
67
+
58
68
  test "is registered as :http_basic" do
59
69
  assert_equal Auth::HttpBasic, Auth.lookup(:http_basic)
60
70
  end
@@ -9,9 +9,11 @@ module AdminSuite
9
9
  end
10
10
 
11
11
  test "register and lookup a strategy by name" do
12
- AdminSuite::Auth.register(:fake, FakeStrategy)
13
- assert_equal FakeStrategy, AdminSuite::Auth.lookup(:fake)
14
- assert_equal FakeStrategy, AdminSuite::Auth.lookup("fake")
12
+ with_auth_registry_snapshot do
13
+ AdminSuite::Auth.register(:fake, FakeStrategy)
14
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup(:fake)
15
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup("fake")
16
+ end
15
17
  end
16
18
 
17
19
  test "lookup of unknown strategy raises UnknownStrategyError" do
@@ -20,10 +22,25 @@ module AdminSuite
20
22
  end
21
23
  end
22
24
 
25
+ test "registered lists registered strategy names" do
26
+ with_auth_registry_snapshot do
27
+ AdminSuite::Auth.register(:fake, FakeStrategy)
28
+
29
+ assert_includes AdminSuite::Auth.registered, :fake
30
+ assert_includes AdminSuite::Auth.registered, :http_basic
31
+ end
32
+ end
33
+
23
34
  test "base strategy exposes options and requires authenticate!" do
24
35
  strategy = AdminSuite::Auth::Strategy.new(username: "u")
25
36
  assert_equal({ username: "u" }, strategy.options)
26
37
  assert_raises(NotImplementedError) { strategy.authenticate!(nil) }
27
38
  end
39
+
40
+ test "base strategy symbolizes string-keyed options" do
41
+ strategy = AdminSuite::Auth::Strategy.new("username" => "u")
42
+
43
+ assert_equal({ username: "u" }, strategy.options)
44
+ end
28
45
  end
29
46
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AuthorizationContextFixtures
6
+ # A plain object implementing #call -- exactly the shape a host might write
7
+ # as an authorize adapter. Has no #parameters of its own; the writer must
8
+ # fall back to introspecting #call's method object.
9
+ class CorrectArityCallable
10
+ def call(actor:, action:, resource:, record:, context:)
11
+ true
12
+ end
13
+ end
14
+
15
+ class OldArityCallable
16
+ def call(actor:, action:, resource:, record:, controller:)
17
+ true
18
+ end
19
+ end
20
+
21
+ class SplatCallable
22
+ def call(**)
23
+ true
24
+ end
25
+ end
26
+ end
27
+
28
+ class AuthorizationContextTest < ActiveSupport::TestCase
29
+ test "reports its surface" do
30
+ web = AdminSuite::AuthorizationContext.new(surface: :web, controller: :ctrl)
31
+ mcp = AdminSuite::AuthorizationContext.new(surface: :mcp)
32
+
33
+ assert_predicate web, :web?
34
+ refute_predicate web, :mcp?
35
+ assert_equal :ctrl, web.controller
36
+ assert_predicate mcp, :mcp?
37
+ assert_nil mcp.controller
38
+ end
39
+
40
+ test "rejects an unknown surface" do
41
+ assert_raises(ArgumentError) { AdminSuite::AuthorizationContext.new(surface: :carrier_pigeon) }
42
+ end
43
+
44
+ test "assigning an old-arity authorize hook raises immediately" do
45
+ error = assert_raises(ArgumentError) do
46
+ AdminSuite.config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
47
+ end
48
+
49
+ # Assert on the COMPUTED diagnosis, not the message's fixed footer -- that
50
+ # footer explains the controller:/context: migration on every failure, so
51
+ # `assert_match(/controller:/)` alone would pass for any rejection at all.
52
+ assert_match(/Missing: \[:context\]/, error.message)
53
+ assert_match(/Unexpected: \[:controller\]/, error.message)
54
+ ensure
55
+ AdminSuite.config.authorize = nil
56
+ end
57
+
58
+ test "accepts a correct-arity hook and a nil hook" do
59
+ AdminSuite.config.authorize = ->(actor:, action:, resource:, record:, context:) { true }
60
+ assert_respond_to AdminSuite.config.authorize, :call
61
+ AdminSuite.config.authorize = nil
62
+ assert_nil AdminSuite.config.authorize
63
+ end
64
+
65
+ test "accepts a hook that takes a keyword splat" do
66
+ AdminSuite.config.authorize = ->(**) { true }
67
+ assert_respond_to AdminSuite.config.authorize, :call
68
+
69
+ AdminSuite.config.authorize = ->(action:, **) { action == :read }
70
+ assert_respond_to AdminSuite.config.authorize, :call
71
+ ensure
72
+ AdminSuite.config.authorize = nil
73
+ end
74
+
75
+ test "still rejects controller: even behind a splat" do
76
+ error = assert_raises(ArgumentError) do
77
+ AdminSuite.config.authorize = ->(controller:, **) { true }
78
+ end
79
+
80
+ # The splat means nothing is missing; `controller:` being named is the
81
+ # whole defect, so assert exactly that rather than the shared footer.
82
+ assert_match(/Missing: \[\]/, error.message)
83
+ assert_match(/Unexpected: \[:controller\]/, error.message)
84
+ ensure
85
+ AdminSuite.config.authorize = nil
86
+ end
87
+
88
+ test "accepts a correct-arity callable object without #parameters" do
89
+ AdminSuite.config.authorize = AuthorizationContextFixtures::CorrectArityCallable.new
90
+ assert_respond_to AdminSuite.config.authorize, :call
91
+ ensure
92
+ AdminSuite.config.authorize = nil
93
+ end
94
+
95
+ test "rejects an old-arity callable object, proving the fallback validates rather than bypasses" do
96
+ error = assert_raises(ArgumentError) do
97
+ AdminSuite.config.authorize = AuthorizationContextFixtures::OldArityCallable.new
98
+ end
99
+
100
+ # This test's name claims the fallback VALIDATES rather than bypasses, so
101
+ # it must assert the guard actually read this object's `#call` signature.
102
+ # Matching the footer would also pass for a fallback that rejected every
103
+ # callable outright -- the opposite of validation.
104
+ assert_match(/Missing: \[:context\]/, error.message)
105
+ assert_match(/Unexpected: \[:controller\]/, error.message)
106
+ ensure
107
+ AdminSuite.config.authorize = nil
108
+ end
109
+
110
+ test "accepts a callable object whose #call takes a keyword splat" do
111
+ AdminSuite.config.authorize = AuthorizationContextFixtures::SplatCallable.new
112
+ assert_respond_to AdminSuite.config.authorize, :call
113
+ ensure
114
+ AdminSuite.config.authorize = nil
115
+ end
116
+
117
+ test "rejects a non-callable with an ArgumentError naming the problem" do
118
+ error = assert_raises(ArgumentError) do
119
+ AdminSuite.config.authorize = "nope"
120
+ end
121
+
122
+ assert_match(/callable/, error.message)
123
+ assert_match(/String/, error.message)
124
+ ensure
125
+ AdminSuite.config.authorize = nil
126
+ end
127
+ end
@@ -31,8 +31,7 @@ module AdminSuite
31
31
 
32
32
  test "dead configuration and DSL members are gone" do
33
33
  refute AdminSuite::Configuration.new.respond_to?(:tailwind_cdn)
34
- assert Admin::Base::Resource.respond_to?(:exportable),
35
- "exportable is a deprecated no-op, not removed -- see ResourceExportableDeprecationTest"
34
+ refute Admin::Base::Resource.respond_to?(:exportable)
36
35
  refute Admin::Base::Resource::ColumnDefinition.members.include?(:render)
37
36
  end
38
37
  end