admin_suite 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/CHANGELOG.md +69 -11
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +7 -78
- data/app/views/admin_suite/resources/index.html.erb +1 -0
- data/config/routes.rb +5 -0
- data/lib/admin/base/resource.rb +7 -29
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +56 -2
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +14 -9
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +5 -4
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +26 -0
- metadata +42 -5
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
- data/test/lib/resource_exportable_deprecation_test.rb +0 -47
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class McpSerializerTest < ActiveSupport::TestCase
|
|
6
|
+
Column = Data.define(:name)
|
|
7
|
+
Index = Data.define(:columns_list)
|
|
8
|
+
Config = Data.define(:index_config)
|
|
9
|
+
|
|
10
|
+
test "emits only the resource's declared columns" do
|
|
11
|
+
record = Struct.new(:id, :name, :secret_token).new(1, "Widget", "sh-hh")
|
|
12
|
+
config = Config.new(Index.new([Column.new(:id), Column.new(:name)]))
|
|
13
|
+
|
|
14
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
15
|
+
|
|
16
|
+
assert_equal %w[id name], row.keys.map(&:to_s).sort
|
|
17
|
+
refute_includes row.keys.map(&:to_s), "secret_token"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
test "a raising accessor degrades that cell, not the row" do
|
|
21
|
+
record = Object.new
|
|
22
|
+
def record.id = 1
|
|
23
|
+
def record.name = raise("boom")
|
|
24
|
+
config = Config.new(Index.new([Column.new(:id), Column.new(:name)]))
|
|
25
|
+
|
|
26
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
27
|
+
|
|
28
|
+
assert_equal 1, row[:id]
|
|
29
|
+
assert_nil row[:name]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "association panels return bounded rows containing only declared columns" do
|
|
33
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
34
|
+
show.panel :children, association: :children, columns: [:name], limit: 500
|
|
35
|
+
config = Struct.new(:show_config).new(show)
|
|
36
|
+
child = Struct.new(:name, :secret_token).new("Visible", "private")
|
|
37
|
+
record = Struct.new(:children).new(Array.new(150, child))
|
|
38
|
+
|
|
39
|
+
payload = AdminSuite::Mcp::Serializer.associations_payload(record, config, max_rows: 100)
|
|
40
|
+
assert_equal 100, payload.fetch(:children).fetch(:rows).size
|
|
41
|
+
assert_equal({ name: "Visible" }, payload.fetch(:children).fetch(:rows).first)
|
|
42
|
+
assert_equal 100, payload.fetch(:children).fetch(:applied_limit)
|
|
43
|
+
refute_includes JSON.generate(payload), "private"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "association panels without declared columns do not serialize model attributes" do
|
|
47
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
48
|
+
show.panel :children, association: :children
|
|
49
|
+
config = Struct.new(:show_config).new(show)
|
|
50
|
+
record = Object.new
|
|
51
|
+
def record.children = raise("must not load an undeclared field set")
|
|
52
|
+
|
|
53
|
+
assert_empty AdminSuite::Mcp::Serializer.associations_payload(record, config, max_rows: 100)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test "a content lambda serializes the proc result, not a missing attribute" do
|
|
57
|
+
record = Struct.new(:id).new(1)
|
|
58
|
+
column = Admin::Base::Resource::ColumnDefinition.new(name: :listings_count, content: ->(_r) { 42 })
|
|
59
|
+
config = Config.new(Index.new([column]))
|
|
60
|
+
|
|
61
|
+
assert_equal 42, AdminSuite::Mcp::Serializer.index_row(record, config)[:listings_count]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "a label lambda serializes the proc result" do
|
|
65
|
+
record = Struct.new(:id).new(1)
|
|
66
|
+
column = Admin::Base::Resource::ColumnDefinition.new(name: :status, content: ->(_r) { "active" }, type: :label)
|
|
67
|
+
config = Config.new(Index.new([column]))
|
|
68
|
+
|
|
69
|
+
assert_equal "active", AdminSuite::Mcp::Serializer.index_row(record, config)[:status]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test "declared times and associations serialize as parseable primitives, not heap dumps" do
|
|
73
|
+
created_at = Time.utc(2026, 9, 10, 12, 0, 0)
|
|
74
|
+
application = SerializerApplication.new(id: 7, name: "Acme")
|
|
75
|
+
record = Struct.new(:created_at, :application, :blob).new(created_at, application, SerializerOpaque.new)
|
|
76
|
+
config = Config.new(Index.new([Column.new(:created_at), Column.new(:application), Column.new(:blob)]))
|
|
77
|
+
|
|
78
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
79
|
+
text = AdminSuite::Mcp::Serializer.dump(row)
|
|
80
|
+
|
|
81
|
+
assert_equal "2026-09-10T12:00:00Z", row[:created_at]
|
|
82
|
+
assert_equal created_at, Time.iso8601(row[:created_at])
|
|
83
|
+
assert_equal "Acme", row[:application]
|
|
84
|
+
refute_match(/#<|0x[0-9a-f]+/i, text)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
test "a missing association panel degrades that panel instead of raising" do
|
|
88
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
89
|
+
show.panel :children, association: :missing_kids, columns: [:name]
|
|
90
|
+
config = Struct.new(:show_config).new(show)
|
|
91
|
+
|
|
92
|
+
payload = AdminSuite::Mcp::Serializer.associations_payload(Object.new, config, max_rows: 100)
|
|
93
|
+
|
|
94
|
+
assert_equal [], payload.fetch(:children).fetch(:rows)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class SerializerApplication < ActiveRecord::Base
|
|
98
|
+
attr_reader :id, :name
|
|
99
|
+
|
|
100
|
+
def initialize(id:, name:)
|
|
101
|
+
@id = id
|
|
102
|
+
@name = name
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class SerializerOpaque; end
|
|
107
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class QueryTest < ActiveSupport::TestCase
|
|
6
|
+
Config = Struct.new(:model_class, :index_config)
|
|
7
|
+
IndexConfig = Struct.new(
|
|
8
|
+
:per_page,
|
|
9
|
+
:includes_list,
|
|
10
|
+
:searchable_fields,
|
|
11
|
+
:filters_list,
|
|
12
|
+
:default_sort,
|
|
13
|
+
:sortable_fields,
|
|
14
|
+
:default_sort_direction
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class RaisingIncludesRelation < ReadOnlyResourceFixtures::Relation
|
|
18
|
+
def includes(*)
|
|
19
|
+
raise ArgumentError, "bad association"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Model
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :relation
|
|
26
|
+
|
|
27
|
+
def all = relation
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
test "per_page falls back to the DSL value when no param is given" do
|
|
32
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: {})
|
|
33
|
+
|
|
34
|
+
assert_equal 30, query.per_page
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
test "per_page honours the param" do
|
|
38
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: "50" })
|
|
39
|
+
|
|
40
|
+
assert_equal 50, query.per_page
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test "per_page clamps to MAX_PAGE_SIZE" do
|
|
44
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: "999999" })
|
|
45
|
+
|
|
46
|
+
assert_equal AdminSuite::Query::MAX_PAGE_SIZE, query.per_page
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "per_page falls back for junk and non-positive values" do
|
|
50
|
+
["abc", "0", "-5", nil, "", [], true].each do |value|
|
|
51
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: value })
|
|
52
|
+
|
|
53
|
+
assert_equal 30, query.per_page, "per_page: #{value.inspect} must fall back to the DSL value"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "the cap applies to DSL defaults as well as explicit page sizes" do
|
|
58
|
+
[nil, "", "bad", 0, -1, [], true, 5000].each do |value|
|
|
59
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 500),
|
|
60
|
+
params: { per_page: value }, max_page_size: 40)
|
|
61
|
+
assert_equal 40, query.per_page, "per_page=#{value.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "per_page remains a positive integer when max_page_size is below 1" do
|
|
66
|
+
[0, -5, nil].each do |cap|
|
|
67
|
+
query = AdminSuite::Query.new(
|
|
68
|
+
resource_config: config_with(per_page: 30),
|
|
69
|
+
params: { per_page: "10" },
|
|
70
|
+
max_page_size: cap
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert_kind_of Integer, query.per_page, "max_page_size=#{cap.inspect}"
|
|
74
|
+
assert_operator query.per_page, :>, 0, "max_page_size=#{cap.inspect}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
test "a raising includes degrades to the unoptimized scope instead of raising" do
|
|
79
|
+
relation = RaisingIncludesRelation.new([])
|
|
80
|
+
Model.relation = relation
|
|
81
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 25, includes: [:nope]), params: {})
|
|
82
|
+
|
|
83
|
+
assert_same relation, query.scope
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def config_with(per_page:, includes: [])
|
|
89
|
+
Config.new(Model, IndexConfig.new(per_page, includes, [], [], nil, [], :asc))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class RemovedDeprecationsTest < ActiveSupport::TestCase
|
|
6
|
+
test "the gem has no legacy Gleania renderer implementations or defaults" do
|
|
7
|
+
refute defined?(AdminSuite::Renderers::LegacyGleania)
|
|
8
|
+
%i[prompt_template_preview messages_preview tool_args_preview turn_messages_preview].each do |key|
|
|
9
|
+
assert_nil AdminSuite::RendererRegistry.lookup_default(key)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
test "exportable is no longer a resource DSL method" do
|
|
14
|
+
refute Admin::Base::Resource.respond_to?(:exportable)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
class PublishWorkflowTest < Minitest::Test
|
|
7
|
+
WORKFLOW_PATH = File.expand_path("../.github/workflows/publish.yml", __dir__)
|
|
8
|
+
|
|
9
|
+
def setup
|
|
10
|
+
@source = File.read(WORKFLOW_PATH)
|
|
11
|
+
@workflow = YAML.load_file(WORKFLOW_PATH)
|
|
12
|
+
@job = @workflow.fetch("jobs").fetch("publish")
|
|
13
|
+
@steps = @job.fetch("steps")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_publishing_uses_the_release_environment_and_job_scoped_oidc
|
|
17
|
+
assert_equal "release", @job["environment"]
|
|
18
|
+
assert_equal "write", @job.fetch("permissions")["id-token"]
|
|
19
|
+
assert_equal "write", @job.fetch("permissions")["contents"]
|
|
20
|
+
refute @workflow.fetch("permissions", {}).key?("id-token")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_automatic_publishing_requires_successful_main_push_ci
|
|
24
|
+
condition = @job.fetch("if")
|
|
25
|
+
assert_includes condition, "github.repository == 'techwright-lab/admin_suite'"
|
|
26
|
+
assert_includes condition, "github.event_name == 'workflow_run'"
|
|
27
|
+
assert_includes condition, "github.event.workflow_run.conclusion == 'success'"
|
|
28
|
+
assert_includes condition, "github.event.workflow_run.event == 'push'"
|
|
29
|
+
assert_includes condition, "github.event.workflow_run.head_branch == 'main'"
|
|
30
|
+
assert_includes condition, "github.event.workflow_run.head_repository.full_name == github.repository"
|
|
31
|
+
assert_includes condition, "github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_credentials_are_pinned_conditional_and_before_the_push
|
|
35
|
+
credentials_index = @steps.index { |step| step["name"] == "Configure RubyGems trusted publishing" }
|
|
36
|
+
refute_nil credentials_index
|
|
37
|
+
credentials = @steps.fetch(credentials_index)
|
|
38
|
+
push_index = @steps.index { |step| step["name"] == "Publish to RubyGems" }
|
|
39
|
+
push = @steps.fetch(push_index)
|
|
40
|
+
|
|
41
|
+
assert_equal "rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a", credentials["uses"]
|
|
42
|
+
assert_equal "steps.version_check.outputs.should_publish == 'true'", credentials["if"]
|
|
43
|
+
assert_equal credentials["if"], push["if"]
|
|
44
|
+
assert_operator credentials_index, :<, push_index
|
|
45
|
+
refute_includes @source, "secrets.RUBYGEMS_API_KEY"
|
|
46
|
+
refute_includes @source, "GEM_HOST_API_KEY:"
|
|
47
|
+
refute_includes @source, "~/.gem/credentials"
|
|
48
|
+
assert_includes push.fetch("run"), "gem push"
|
|
49
|
+
assert_includes push.fetch("run"), "--host https://rubygems.org"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_releases_preserve_tested_sha_and_serialization
|
|
53
|
+
checkout = @steps.find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
|
|
54
|
+
assert_equal "${{ github.event.workflow_run.head_sha || github.sha }}", checkout.fetch("with")["ref"]
|
|
55
|
+
assert_equal false, @workflow.fetch("concurrency")["cancel-in-progress"]
|
|
56
|
+
assert_includes @source, "different contents. Bump the gem version before publishing."
|
|
57
|
+
push_index = @steps.index { |step| step["name"] == "Publish to RubyGems" }
|
|
58
|
+
tag_index = @steps.index { |step| step["name"] == "Create Git tag" }
|
|
59
|
+
release_index = @steps.index { |step| step["name"] == "Create GitHub Release" }
|
|
60
|
+
assert_operator push_index, :<, tag_index
|
|
61
|
+
assert_operator tag_index, :<, release_index
|
|
62
|
+
end
|
|
63
|
+
end
|
data/test/test_helper.rb
CHANGED
|
@@ -16,6 +16,15 @@ if ENV["COVERAGE"]
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
require_relative "dummy/config/environment"
|
|
19
|
+
|
|
20
|
+
# Auth strategies are process-global. Tests that register temporary strategies
|
|
21
|
+
# can restore a snapshot with this helper to avoid cross-file registry leakage.
|
|
22
|
+
def with_auth_registry_snapshot
|
|
23
|
+
registry = AdminSuite::Auth.instance_variable_get(:@registry).dup
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
AdminSuite::Auth.instance_variable_set(:@registry, registry)
|
|
27
|
+
end
|
|
19
28
|
require "minitest/autorun"
|
|
20
29
|
require "active_support/test_case"
|
|
21
30
|
require "action_dispatch/testing/integration"
|
|
@@ -23,6 +32,14 @@ require "action_dispatch/testing/integration"
|
|
|
23
32
|
# Ensure the engine is loaded (and its initializers run).
|
|
24
33
|
require "admin_suite"
|
|
25
34
|
|
|
35
|
+
def with_authorize(hook)
|
|
36
|
+
previous = AdminSuite.config.authorize
|
|
37
|
+
AdminSuite.config.authorize = hook
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
AdminSuite.config.authorize = previous
|
|
41
|
+
end
|
|
42
|
+
|
|
26
43
|
# The dummy app is intentionally database-free, while the generic controller
|
|
27
44
|
# supports Active Record hosts. Supply only the exception type its lookup path
|
|
28
45
|
# rescues so show-page behavior can be exercised with an in-memory fixture.
|
|
@@ -181,3 +198,12 @@ module Admin
|
|
|
181
198
|
end
|
|
182
199
|
end
|
|
183
200
|
end
|
|
201
|
+
|
|
202
|
+
# MCP never permits anonymous callers, including in the development escape hatch.
|
|
203
|
+
class McpIntegrationTest < ActionDispatch::IntegrationTest
|
|
204
|
+
setup do
|
|
205
|
+
@previous_mcp_actor_resolver = AdminSuite.config.current_actor
|
|
206
|
+
AdminSuite.config.current_actor = ->(_) { "test-operator" }
|
|
207
|
+
end
|
|
208
|
+
teardown { AdminSuite.config.current_actor = @previous_mcp_actor_resolver }
|
|
209
|
+
end
|