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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +69 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/controllers/admin_suite/application_controller.rb +12 -11
  7. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  8. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  9. data/app/views/admin_suite/resources/index.html.erb +1 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/admin/base/resource.rb +7 -29
  12. data/lib/admin_suite/auth/host_user.rb +42 -0
  13. data/lib/admin_suite/auth/strategy.rb +1 -1
  14. data/lib/admin_suite/auth.rb +15 -0
  15. data/lib/admin_suite/authorization_context.rb +28 -0
  16. data/lib/admin_suite/configuration.rb +56 -2
  17. data/lib/admin_suite/mcp/authorization.rb +68 -0
  18. data/lib/admin_suite/mcp/serializer.rb +138 -0
  19. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  20. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  21. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  22. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  23. data/lib/admin_suite/mcp.rb +53 -0
  24. data/lib/admin_suite/query.rb +71 -0
  25. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  26. data/lib/admin_suite/version.rb +1 -1
  27. data/lib/admin_suite.rb +14 -9
  28. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  29. data/test/controllers/resources_controller_test.rb +19 -13
  30. data/test/integration/authentication_test.rb +10 -0
  31. data/test/integration/authorization_test.rb +20 -3
  32. data/test/integration/index_query_characterization_test.rb +229 -0
  33. data/test/integration/mcp_aggregate_test.rb +44 -0
  34. data/test/integration/mcp_authorization_test.rb +102 -0
  35. data/test/integration/mcp_endpoint_test.rb +89 -0
  36. data/test/integration/mcp_get_record_test.rb +62 -0
  37. data/test/integration/mcp_instrumentation_test.rb +107 -0
  38. data/test/integration/mcp_list_records_test.rb +75 -0
  39. data/test/integration/mcp_parity_test.rb +155 -0
  40. data/test/integration/mcp_release_test.rb +131 -0
  41. data/test/integration/read_only_resource_test.rb +128 -2
  42. data/test/integration/searchable_select_search_test.rb +5 -4
  43. data/test/lib/auth_host_user_test.rb +52 -0
  44. data/test/lib/auth_http_basic_test.rb +10 -0
  45. data/test/lib/auth_test.rb +20 -3
  46. data/test/lib/authorization_context_test.rb +127 -0
  47. data/test/lib/engine_defaults_test.rb +1 -2
  48. data/test/lib/mcp_serializer_test.rb +107 -0
  49. data/test/lib/query_test.rb +91 -0
  50. data/test/lib/removed_deprecations_test.rb +16 -0
  51. data/test/publish_workflow_test.rb +63 -0
  52. data/test/test_helper.rb +26 -0
  53. metadata +42 -5
  54. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  55. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  56. 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 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
@@ -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
@@ -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