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.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/CHANGELOG.md +203 -18
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/assets/vendor/chart.umd.min.js +14 -0
- 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 +71 -17
- data/app/helpers/admin_suite/base_helper.rb +187 -10
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/resources/index.html.erb +25 -5
- data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
- data/app/views/layouts/admin_suite/application.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +46 -32
- 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 +66 -8
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- 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/renderer_registry.rb +11 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -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 +23 -7
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/index_table_test.rb +289 -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/navigation_sections_test.rb +21 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +369 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- 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/definition_loader_test.rb +12 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/form_field_renderer_test.rb +83 -11
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- 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/lib/renderer_test.rb +23 -7
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +73 -8
- metadata +76 -9
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
- 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
|
|
data/lib/admin_suite/version.rb
CHANGED
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
|
-
#
|
|
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 :
|
|
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
|
|
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,
|
|
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 `#<LinkingFixtures::Company:0x...>`.
|
|
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
|
|
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:,
|
|
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
|