admin_suite 0.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +203 -18
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/assets/vendor/chart.umd.min.js +14 -0
  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 +71 -17
  10. data/app/helpers/admin_suite/base_helper.rb +187 -10
  11. data/app/javascript/admin_suite_application.js +3 -0
  12. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/resources/index.html.erb +25 -5
  15. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  16. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  17. data/config/routes.rb +8 -0
  18. data/lib/admin/base/filter_builder.rb +48 -5
  19. data/lib/admin/base/resource.rb +46 -32
  20. data/lib/admin_suite/auth/host_user.rb +42 -0
  21. data/lib/admin_suite/auth/strategy.rb +1 -1
  22. data/lib/admin_suite/auth.rb +15 -0
  23. data/lib/admin_suite/authorization_context.rb +28 -0
  24. data/lib/admin_suite/configuration.rb +66 -8
  25. data/lib/admin_suite/engine.rb +1 -1
  26. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  27. data/lib/admin_suite/mcp/authorization.rb +68 -0
  28. data/lib/admin_suite/mcp/serializer.rb +138 -0
  29. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  30. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  31. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  32. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  33. data/lib/admin_suite/mcp.rb +53 -0
  34. data/lib/admin_suite/query.rb +71 -0
  35. data/lib/admin_suite/renderer_registry.rb +11 -0
  36. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  37. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +23 -7
  40. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  41. data/test/controllers/resources_controller_test.rb +19 -13
  42. data/test/integration/association_linking_test.rb +292 -0
  43. data/test/integration/authentication_test.rb +10 -0
  44. data/test/integration/authorization_test.rb +20 -3
  45. data/test/integration/chart_panel_test.rb +491 -0
  46. data/test/integration/dashboard_test.rb +9 -2
  47. data/test/integration/index_query_characterization_test.rb +229 -0
  48. data/test/integration/index_table_test.rb +289 -0
  49. data/test/integration/mcp_aggregate_test.rb +44 -0
  50. data/test/integration/mcp_authorization_test.rb +102 -0
  51. data/test/integration/mcp_endpoint_test.rb +89 -0
  52. data/test/integration/mcp_get_record_test.rb +62 -0
  53. data/test/integration/mcp_instrumentation_test.rb +107 -0
  54. data/test/integration/mcp_list_records_test.rb +75 -0
  55. data/test/integration/mcp_parity_test.rb +155 -0
  56. data/test/integration/mcp_release_test.rb +131 -0
  57. data/test/integration/navigation_sections_test.rb +21 -0
  58. data/test/integration/read_only_resource_test.rb +128 -2
  59. data/test/integration/searchable_select_search_test.rb +369 -0
  60. data/test/integration/show_hide_blank_test.rb +195 -0
  61. data/test/integration/toggle_test.rb +106 -0
  62. data/test/lib/auth_host_user_test.rb +52 -0
  63. data/test/lib/auth_http_basic_test.rb +10 -0
  64. data/test/lib/auth_test.rb +20 -3
  65. data/test/lib/authorization_context_test.rb +127 -0
  66. data/test/lib/definition_loader_test.rb +12 -0
  67. data/test/lib/engine_defaults_test.rb +1 -2
  68. data/test/lib/form_field_renderer_test.rb +83 -11
  69. data/test/lib/format_table_cell_test.rb +49 -0
  70. data/test/lib/index_includes_test.rb +223 -0
  71. data/test/lib/mcp_serializer_test.rb +107 -0
  72. data/test/lib/query_test.rb +91 -0
  73. data/test/lib/removed_deprecations_test.rb +16 -0
  74. data/test/lib/renderer_test.rb +23 -7
  75. data/test/publish_workflow_test.rb +63 -0
  76. data/test/test_helper.rb +73 -8
  77. metadata +76 -9
  78. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
  79. data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
  80. data/test/lib/resource_exportable_deprecation_test.rb +0 -39
@@ -65,6 +65,17 @@ module AdminSuite
65
65
  def unregister(key)
66
66
  @registry.delete(key.to_sym)
67
67
  end
68
+
69
+ # Test-support only. Removes a single *default* registration.
70
+ #
71
+ # Exists solely so a spec that seeds a scratch key into the default
72
+ # store (e.g. to test host-class-vs-default precedence) can clean up
73
+ # after itself. AdminSuite's own built-in defaults are registered once
74
+ # at require time and are never expected to be removed in a running
75
+ # process -- do not call this outside a test teardown.
76
+ def unregister_default(key)
77
+ @defaults.delete(key.to_sym)
78
+ end
68
79
  end
69
80
  end
70
81
  end
@@ -48,6 +48,12 @@ module AdminSuite
48
48
  panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
49
49
  end
50
50
 
51
+ # `type:` selects the Chart.js chart type: `:bar` (default), `:line`,
52
+ # `:area` (a line chart rendered with fill), or `:doughnut`. It flows
53
+ # through `**options` untouched by this method -- the `_chart.html.erb`
54
+ # partial is what validates it, falling back to `:bar` with a logged
55
+ # warning for anything else, so a host typo degrades rather than
56
+ # breaking the dashboard.
51
57
  def chart_panel(title, data: nil, span: nil, **options, &block)
52
58
  data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
53
59
  panel(:chart, title, span: span, **options.merge(data: data_proc || data))
@@ -53,9 +53,9 @@ module AdminSuite
53
53
  end
54
54
  end
55
55
 
56
- if defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)
56
+ if defined?(::ActiveStorage::Attached::One) && value.is_a?(::ActiveStorage::Attached::One)
57
57
  return render_attachment_preview(value)
58
- elsif defined?(ActiveStorage::Attached::Many) && value.is_a?(ActiveStorage::Attached::Many)
58
+ elsif defined?(::ActiveStorage::Attached::Many) && value.is_a?(::ActiveStorage::Attached::Many)
59
59
  return render_attachments_preview(value)
60
60
  end
61
61
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
data/lib/admin_suite.rb CHANGED
@@ -8,9 +8,32 @@ end
8
8
 
9
9
  require "pagy"
10
10
 
11
+ # Hard dependency, declared in the gemspec but not a direct Gemfile entry in
12
+ # any host -- Bundler only auto-requires direct Gemfile dependencies, never a
13
+ # gem's transitive runtime dependencies.
14
+ require "mcp"
15
+
16
+ # Hard dependency, declared in the gemspec but not a direct Gemfile entry in
17
+ # any host -- Bundler's `Bundler.require` only auto-requires gems declared
18
+ # directly in the Gemfile (or via `gemspec`'s own path entry), not a gem's
19
+ # *transitive* runtime dependencies. Same reasoning as `pagy` above and
20
+ # `redcarpet`/`rouge` in markdown_renderer.rb: required explicitly here so
21
+ # `turbo_frame_tag`/`turbo_stream`/the `:turbo_stream` MIME type are real
22
+ # regardless of what else the host's own Gemfile happens to require.
23
+ require "turbo-rails"
24
+
11
25
  require "admin_suite/version"
12
26
  require "admin_suite/deprecation"
13
27
  require "admin_suite/configuration"
28
+ require "admin_suite/authorization_context"
29
+ require "admin_suite/query"
30
+ require "admin_suite/mcp/authorization"
31
+ require "admin_suite/mcp/serializer"
32
+ require "admin_suite/mcp/tools/describe_resources"
33
+ require "admin_suite/mcp/tools/list_records"
34
+ require "admin_suite/mcp/tools/get_record"
35
+ require "admin_suite/mcp/tools/aggregate"
36
+ require "admin_suite/mcp"
14
37
  require "admin_suite/markdown_renderer"
15
38
  require "admin_suite/theme_palette"
16
39
  require "admin_suite/portal_registry"
@@ -25,7 +48,6 @@ require "admin_suite/renderers/json_renderer"
25
48
  require "admin_suite/renderers/key_value_renderer"
26
49
  require "admin_suite/renderers/table_from_renderer"
27
50
  require "admin_suite/renderers/code_renderer"
28
- require "admin_suite/renderers/legacy_gleania"
29
51
  require "admin_suite/legacy_custom_renderer_procs"
30
52
  require "admin_suite/definition_loader"
31
53
  require "admin_suite/host_autoload_policy"
@@ -49,12 +71,6 @@ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::Code
49
71
  AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
50
72
  AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
51
73
 
52
- # Deprecated (removed in 0.5.0): the four Gleania-specific LLM chat-transcript
53
- # renderers. See `AdminSuite::Renderers::LegacyGleania` for details.
54
- AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
55
- AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
56
- AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
57
- AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
58
74
 
59
75
  module AdminSuite
60
76
  class << self
@@ -26,9 +26,12 @@ AdminSuite.configure do |config|
26
26
  # provide one (legacy fallback):
27
27
  config.current_actor = ->(controller) { controller.respond_to?(:current_user) ? controller.current_user : nil }
28
28
 
29
- # Authorization hook — called for every resource request:
29
+ # Authorization hook — called for every resource request.
30
+ # Signature: ->(actor:, action:, resource:, record:, context:) { true }
30
31
  # action is one of :read, :create, :update, :destroy, :execute.
31
- # config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
32
+ # nil hook: authenticated web requests are allowed; MCP serves no tools/data.
33
+ # Hook return of false or nil denies the request (no disclose/mutate).
34
+ # config.authorize = ->(actor:, action:, resource:, record:, context:) { actor&.admin? }
32
35
  config.authorize = nil
33
36
 
34
37
  # Optional sign-out action in the topbar.
@@ -6,12 +6,7 @@ module AdminSuite
6
6
  class ResourcesControllerTest < ActiveSupport::TestCase
7
7
  class TestController < ResourcesController
8
8
  attr_writer :test_resource_config, :test_params
9
- attr_reader :filter_calls, :paginated_scope
10
-
11
- def initialize
12
- super
13
- @filter_calls = 0
14
- end
9
+ attr_reader :paginated_scope, :pagy_limit
15
10
 
16
11
  def params
17
12
  @test_params ||= {}
@@ -23,18 +18,27 @@ module AdminSuite
23
18
  @test_resource_config
24
19
  end
25
20
 
26
- def filtered_collection
27
- @filter_calls += 1
28
- { total: 37 }
29
- end
30
-
31
- def paginate_collection(scope)
21
+ def pagy(scope, limit:)
32
22
  @paginated_scope = scope
23
+ @pagy_limit = limit
33
24
  [ Object.new, :paginated ]
34
25
  end
35
26
  end
36
27
 
28
+ class StatsModel
29
+ class << self
30
+ attr_accessor :all_calls
31
+
32
+ def all
33
+ self.all_calls = all_calls.to_i + 1
34
+ { total: 37 }
35
+ end
36
+ end
37
+ end
38
+
37
39
  class StatsResource < Admin::Base::Resource
40
+ model StatsModel
41
+
38
42
  index do
39
43
  stats do
40
44
  stat :legacy, -> { 11 }
@@ -72,11 +76,13 @@ module AdminSuite
72
76
  test "index reuses one filtered unpaginated scope for stats and pagination" do
73
77
  controller = TestController.new
74
78
  controller.test_resource_config = StatsResource
79
+ StatsModel.all_calls = 0
75
80
 
76
81
  controller.index
77
82
 
78
- assert_equal 1, controller.filter_calls
83
+ assert_equal 1, StatsModel.all_calls
79
84
  assert_equal({ total: 37 }, controller.paginated_scope)
85
+ assert_equal 25, controller.pagy_limit
80
86
  assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
81
87
  assert_equal :paginated, controller.instance_variable_get(:@collection)
82
88
  end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # `LinkingFixtures::Company` (an AR-backed fixture: it genuinely subclasses
6
+ # the `ActiveRecord::Base` stub defined in test_helper.rb) and its
7
+ # registered `Admin::Resources::LinkingCompanyResource` both live in
8
+ # test_helper.rb, not here -- established there because Tasks 4 and 7 reuse
9
+ # them, and test_helper.rb is required by every test file regardless of
10
+ # which single file `rake test TEST=...` runs. Everything below is specific
11
+ # to this task's link-rendering assertions.
12
+ module LinkingFixtures
13
+ # Same shape as Company, but deliberately never registered as an
14
+ # Admin::Resources::* class -- exercises the "no resource registered"
15
+ # degrade path in `auto_admin_suite_path_for`.
16
+ class UnregisteredVendor < ActiveRecord::Base
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id, :name
20
+
21
+ def initialize(id: 3, name: "Ghost Vendor")
22
+ @id = id
23
+ @name = name
24
+ end
25
+
26
+ def self.column_names = %w[id name]
27
+ def self.primary_key = "id"
28
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
29
+ def self.find(_id) = new
30
+ def to_param = id.to_s
31
+ def attributes = { "id" => id, "name" => name }
32
+ end
33
+
34
+ # `item_display_title` calls `item.name` unguarded when `respond_to?(:name)`
35
+ # is true. This record's `#name` raises, simulating a host model whose
36
+ # display method blows up on bad data -- the point is that a single bad
37
+ # row must degrade, not 500 the entire page.
38
+ class RaisingCo < ActiveRecord::Base
39
+ extend ActiveModel::Naming
40
+
41
+ attr_reader :id
42
+
43
+ def initialize(id: 9)
44
+ @id = id
45
+ end
46
+
47
+ def name
48
+ raise "boom: no display name for you"
49
+ end
50
+
51
+ def self.column_names = %w[id name]
52
+ def self.primary_key = "id"
53
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
54
+ def self.find(_id) = new
55
+ def to_param = id.to_s
56
+ def attributes = { "id" => id }
57
+ end
58
+
59
+ # Simulates an unsaved/unpersisted AR record: real `ActiveRecord::Base#to_param`
60
+ # returns nil when `persisted?` is false. `auto_admin_suite_path_for`'s
61
+ # `resource_path(..., id: nil)` call must degrade to no-link, not 500.
62
+ class UnpersistedVendor < ActiveRecord::Base
63
+ extend ActiveModel::Naming
64
+
65
+ attr_reader :id, :name
66
+
67
+ def initialize
68
+ @id = nil
69
+ @name = "Draft Vendor"
70
+ end
71
+
72
+ def self.column_names = %w[id name]
73
+ def self.primary_key = "id"
74
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
75
+ def self.find(_id) = new
76
+ def to_param = nil
77
+ def attributes = { "id" => id, "name" => name }
78
+ end
79
+
80
+ # PORO host resource (mirrors the RedirectFixtures/ReadOnlyResourceFixtures
81
+ # convention). Its index column returns each of the AR values above, and
82
+ # its show page renders an association table where one column is itself an
83
+ # AR value -- the two call sites Task 3 unifies (`render_column_value`'s
84
+ # fallback branch and `format_table_cell`'s AR branch).
85
+ class Deal
86
+ extend ActiveModel::Naming
87
+
88
+ attr_reader :id, :label, :company
89
+
90
+ def initialize(id:, label:, company:)
91
+ @id = id
92
+ @label = label
93
+ @company = company
94
+ end
95
+
96
+ def self.all = ReadOnlyResourceFixtures::Relation.new([
97
+ new(id: 1, label: "Q3 renewal", company: Company.new),
98
+ new(id: 2, label: "Raises on display", company: RaisingCo.new),
99
+ new(id: 3, label: "Unpersisted vendor", company: UnpersistedVendor.new),
100
+ new(id: 4, label: "No resource registered", company: UnregisteredVendor.new)
101
+ ])
102
+ def self.column_names = %w[id label]
103
+ def self.primary_key = "id"
104
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
105
+ def self.find(id) = all.find { |d| d.to_param == id.to_s }
106
+ def to_param = id.to_s
107
+ def attributes = { "id" => id, "label" => label }
108
+
109
+ def line_items
110
+ [
111
+ LineItem.new(1, "Renewal", Company.new),
112
+ LineItem.new(2, "Ghost line", UnregisteredVendor.new)
113
+ ]
114
+ end
115
+ end
116
+
117
+ LineItem = Struct.new(:id, :label, :company)
118
+
119
+ # Five deals, each pointing at a *distinct instance* of the *same*
120
+ # `LinkingFixtures::Company` class. `auto_admin_suite_path_for`'s registry
121
+ # lookup is keyed on `item.class`, not identity, so a correct memo
122
+ # resolves the resource once for this whole page -- pre-memo, each of
123
+ # the 5 rows independently re-ran the full registry scan.
124
+ class RepeatedCompanyDeal
125
+ extend ActiveModel::Naming
126
+
127
+ attr_reader :id, :label, :company
128
+
129
+ def initialize(id:, label:, company:)
130
+ @id = id
131
+ @label = label
132
+ @company = company
133
+ end
134
+
135
+ ROWS = Array.new(5) { |i| new(id: i + 1, label: "Deal #{i + 1}", company: Company.new(id: 100 + i, name: "Company #{i + 1}")) }
136
+
137
+ def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
138
+ def self.column_names = %w[id label]
139
+ def self.primary_key = "id"
140
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
141
+ def self.find(id) = all.find { |d| d.to_param == id.to_s }
142
+ def to_param = id.to_s
143
+ def attributes = { "id" => id, "label" => label }
144
+ end
145
+ end
146
+
147
+ module Admin
148
+ module Resources
149
+ class LinkingRepeatedCompanyDealResource < Admin::Base::Resource
150
+ model LinkingFixtures::RepeatedCompanyDeal
151
+ portal :ops
152
+ section :observability
153
+
154
+ index do
155
+ columns do
156
+ column :label
157
+ column :company
158
+ end
159
+ end
160
+ end
161
+
162
+ class LinkingRaisingCoResource < Admin::Base::Resource
163
+ model LinkingFixtures::RaisingCo
164
+ portal :ops
165
+ section :observability
166
+ end
167
+
168
+ class LinkingUnpersistedVendorResource < Admin::Base::Resource
169
+ model LinkingFixtures::UnpersistedVendor
170
+ portal :ops
171
+ section :observability
172
+ end
173
+
174
+ class LinkingDealResource < Admin::Base::Resource
175
+ model LinkingFixtures::Deal
176
+ portal :ops
177
+ section :observability
178
+
179
+ index do
180
+ columns do
181
+ column :label
182
+ column :company
183
+ end
184
+ # `Deal.all` is a `ReadOnlyResourceFixtures::Relation` -- it has no
185
+ # `#includes` method at all. This exercises Task 4's "skip
186
+ # silently, don't raise" path on every request this test file
187
+ # already makes to `/linking_deals`, not just a dedicated test.
188
+ includes :company
189
+ end
190
+
191
+ show do
192
+ section :line_items, association: :line_items, display: :table, columns: [ :label, :company ]
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ module AdminSuite
199
+ class AssociationLinkingTest < ActionDispatch::IntegrationTest
200
+ # ERB auto-escapes `<%= %>` output, so the literal bug string
201
+ # (`#<Company:0x...>`) never appears as raw `#<` in `response.body` --
202
+ # it shows up HTML-entity-escaped as `#&lt;LinkingFixtures::Company:0x...&gt;`.
203
+ # A `refute_includes response.body, "#<"` check would therefore pass
204
+ # vacuously against the *un*fixed code. Assert on the class-name leak
205
+ # itself instead, which survives escaping either way.
206
+ AR_INSPECT_LEAK = "LinkingFixtures::"
207
+
208
+ test "an index column returning an AR value links to that record's admin page" do
209
+ get "/internal/admin_suite/ops/linking_deals"
210
+ assert_response :success
211
+
212
+ refute_includes response.body, AR_INSPECT_LEAK
213
+ assert_includes response.body, "Acme Corp"
214
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
215
+ end
216
+
217
+ test "an AR value with a raising display title degrades instead of 500ing the page" do
218
+ get "/internal/admin_suite/ops/linking_deals"
219
+ assert_response :success
220
+ refute_includes response.body, AR_INSPECT_LEAK
221
+ end
222
+
223
+ test "an unpersisted AR record (to_param -> nil) renders plain text, not a broken link" do
224
+ get "/internal/admin_suite/ops/linking_deals"
225
+ assert_response :success
226
+ assert_includes response.body, "Draft Vendor"
227
+ refute_match %r{<a[^>]*>\s*Draft Vendor\s*</a>}, response.body
228
+ end
229
+
230
+ test "an AR value with no registered resource renders plain text, not a broken link" do
231
+ get "/internal/admin_suite/ops/linking_deals"
232
+ assert_response :success
233
+ assert_includes response.body, "Ghost Vendor"
234
+ refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
235
+ end
236
+
237
+ test "an association table cell that is an AR value renders as a link too" do
238
+ get "/internal/admin_suite/ops/linking_deals/1"
239
+ assert_response :success
240
+
241
+ refute_includes response.body, AR_INSPECT_LEAK
242
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
243
+ # The second line item's company has no registered resource: plain text.
244
+ assert_includes response.body, "Ghost Vendor"
245
+ refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
246
+ end
247
+
248
+ # Non-vacuous by construction: it counts actual invocations of
249
+ # `ensure_admin_resources_loaded_for!` -- called exactly once per
250
+ # registry *cache miss* inside `admin_suite_resource_for`, and nowhere
251
+ # else in the gem. (A page-wide spy on
252
+ # `Admin::Base::Resource.registered_resources` itself was tried first
253
+ # and rejected: navigation building calls it many more times per
254
+ # request for unrelated reasons -- e.g. `resources_for_portal` -- so
255
+ # that count is dominated by noise this task didn't touch.)
256
+ #
257
+ # Pre-memo, `auto_admin_suite_path_for` called
258
+ # `ensure_admin_resources_loaded_for!(item.class)` unconditionally on
259
+ # every invocation -- 5 rows of the same class meant 5 calls (and 2
260
+ # full registry scans apiece: the `.any?` inside it, plus the
261
+ # `registered_resources.find` right after). Memoized, only the first
262
+ # row's lookup is a cache miss; the other 4 hit
263
+ # `@admin_suite_resource_for` and never call this method at all -- so
264
+ # the call count for 5 rows must be 1, not 5. A test that only
265
+ # asserted "the memo hash is non-empty" would still pass if the memo
266
+ # were consulted but the scan ran unconditionally anyway; this asserts
267
+ # the scan was actually skipped.
268
+ test "auto_admin_suite_path_for memoizes the registry lookup per class, not per row" do
269
+ call_count = 0
270
+ helper_module = AdminSuite::BaseHelper
271
+ original_method = helper_module.instance_method(:ensure_admin_resources_loaded_for!)
272
+
273
+ helper_module.send(:define_method, :ensure_admin_resources_loaded_for!) do |model_class|
274
+ call_count += 1
275
+ original_method.bind(self).call(model_class)
276
+ end
277
+
278
+ begin
279
+ get "/internal/admin_suite/ops/linking_repeated_company_deals"
280
+ assert_response :success
281
+ assert_includes response.body, "Company 1"
282
+ assert_includes response.body, "Company 5"
283
+ ensure
284
+ helper_module.send(:define_method, :ensure_admin_resources_loaded_for!, original_method)
285
+ end
286
+
287
+ assert_equal 1, call_count,
288
+ "expected exactly one registry lookup for 5 rows of the same class (LinkingFixtures::Company) " \
289
+ "-- got #{call_count}, which means the memo isn't preventing a rescan per row"
290
+ end
291
+ end
292
+ end
@@ -43,6 +43,16 @@ module AdminSuite
43
43
  end
44
44
  end
45
45
 
46
+ test "unknown auth strategy fails closed and lists registered strategies" do
47
+ with_config(allow_unauthenticated: false, auth_strategy: :typo) do
48
+ get ROOT
49
+
50
+ assert_response :forbidden
51
+ assert_includes response.body, "Unknown AdminSuite auth strategy :typo"
52
+ assert_includes response.body, "http_basic"
53
+ end
54
+ end
55
+
46
56
  test "legacy authenticate lambda still works" do
47
57
  denials = ->(controller) { controller.head :forbidden }
48
58
  with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: denials) do
@@ -66,13 +66,29 @@ module AdminSuite
66
66
  with_authorize(->(**) { false }) do
67
67
  get BASE
68
68
  assert_response :forbidden
69
+ refute_includes response.body, "Gadget one"
69
70
  end
70
71
  end
71
72
 
72
- test "authorize receives actor, mapped action, resource, record, controller" do
73
+ test "nil authorize result denies with 403 and does not disclose the record" do
74
+ with_authorize(->(**) { nil }) do
75
+ get "#{BASE}/1"
76
+ assert_response :forbidden
77
+ refute_includes response.body, "Gadget one"
78
+ end
79
+ end
80
+
81
+ test "nil authorize result cannot mutate" do
82
+ with_authorize(->(**) { nil }) do
83
+ delete "#{BASE}/1"
84
+ assert_response :forbidden
85
+ end
86
+ end
87
+
88
+ test "authorize receives actor, mapped action, resource, record, context" do
73
89
  captured = nil
74
- hook = lambda do |actor:, action:, resource:, record:, controller:|
75
- captured = { action: action, resource: resource, record: record }
90
+ hook = lambda do |actor:, action:, resource:, record:, context:|
91
+ captured = { action: action, resource: resource, record: record, context: context }
76
92
  true
77
93
  end
78
94
 
@@ -83,6 +99,7 @@ module AdminSuite
83
99
  assert_equal :read, captured[:action]
84
100
  assert_equal Admin::Resources::AuthzGadgetResource, captured[:resource]
85
101
  assert_instance_of AuthzFixtures::Gadget, captured[:record]
102
+ assert_equal :web, captured[:context].surface
86
103
  end
87
104
 
88
105
  test "destroy maps to :destroy" do