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
|
@@ -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
|
|
226
|
+
test "declared member actions are rejected on read_only resources" do
|
|
126
227
|
post "#{BASE_PATH}/1/execute_action/ping"
|
|
127
|
-
|
|
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
|
|
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:,
|
|
269
|
-
captured = { action: action, resource: resource, record: record,
|
|
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
|
|
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
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
data/test/lib/auth_test.rb
CHANGED
|
@@ -9,9 +9,11 @@ module AdminSuite
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
test "register and lookup a strategy by name" do
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|