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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Exercises `hide_blank:` on `panel`/`section` `fields:` rows. Mirrors the
|
|
6
|
+
# `ReadOnlyResourceFixtures::Widget` shape (test/integration/read_only_resource_test.rb)
|
|
7
|
+
# so the show route resolves through the ordinary numeric-id `klass.find` path,
|
|
8
|
+
# not the slug/uuid/token fallback.
|
|
9
|
+
module HideBlankFixtures
|
|
10
|
+
class Widget
|
|
11
|
+
extend ActiveModel::Naming
|
|
12
|
+
|
|
13
|
+
attr_reader :id
|
|
14
|
+
|
|
15
|
+
def initialize(id: 1)
|
|
16
|
+
@id = id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.all
|
|
20
|
+
[ new ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.column_names
|
|
24
|
+
%w[id]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.primary_key
|
|
28
|
+
"id"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.columns_hash
|
|
32
|
+
{ "id" => Struct.new(:type).new(:integer) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.find(id)
|
|
36
|
+
raise ActiveRecord::RecordNotFound unless id.to_s == "1"
|
|
37
|
+
|
|
38
|
+
new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_param
|
|
42
|
+
id.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def attributes
|
|
46
|
+
{ "id" => id }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Non-blank control value -- always kept, hide_blank or not.
|
|
50
|
+
def title
|
|
51
|
+
"Widget One"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def blank_string
|
|
55
|
+
""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def whitespace_string
|
|
59
|
+
" "
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def nil_field
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def empty_array
|
|
67
|
+
[]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty_hash
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def false_flag
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def zero_count
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def zero_float
|
|
83
|
+
0.0
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
module Admin
|
|
89
|
+
module Resources
|
|
90
|
+
class HideBlankWidgetResource < Admin::Base::Resource
|
|
91
|
+
model HideBlankFixtures::Widget
|
|
92
|
+
portal :ops
|
|
93
|
+
section :observability
|
|
94
|
+
|
|
95
|
+
FIELDS = %i[
|
|
96
|
+
title blank_string whitespace_string nil_field
|
|
97
|
+
empty_array empty_hash false_flag zero_count zero_float
|
|
98
|
+
].freeze
|
|
99
|
+
|
|
100
|
+
show do
|
|
101
|
+
sidebar do
|
|
102
|
+
panel :sidebar_hidden, title: "Sidebar hidden", fields: FIELDS, hide_blank: true
|
|
103
|
+
panel :sidebar_default, title: "Sidebar default", fields: FIELDS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
section :main_hidden, title: "Main hidden", fields: FIELDS, hide_blank: true
|
|
107
|
+
section :main_default, title: "Main default", fields: FIELDS
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
module AdminSuite
|
|
114
|
+
class ShowHideBlankTest < ActionDispatch::IntegrationTest
|
|
115
|
+
PATH = "/internal/admin_suite/ops/hide_blank_widgets/1"
|
|
116
|
+
|
|
117
|
+
test "hide_blank: true hides nil, empty string, empty array, and empty hash" do
|
|
118
|
+
get PATH
|
|
119
|
+
assert_response :success
|
|
120
|
+
|
|
121
|
+
hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
|
|
122
|
+
refute_nil hidden_section, "expected to find the 'Main hidden' panel before 'Main default' in the body"
|
|
123
|
+
|
|
124
|
+
refute_includes hidden_section, "Nil field"
|
|
125
|
+
refute_includes hidden_section, "Blank string"
|
|
126
|
+
refute_includes hidden_section, "Empty array"
|
|
127
|
+
refute_includes hidden_section, "Empty hash"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
test "hide_blank: true keeps false, 0, 0.0, and whitespace-only strings" do
|
|
131
|
+
get PATH
|
|
132
|
+
assert_response :success
|
|
133
|
+
|
|
134
|
+
hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
|
|
135
|
+
refute_nil hidden_section
|
|
136
|
+
|
|
137
|
+
assert_includes hidden_section, "False flag"
|
|
138
|
+
assert_includes hidden_section, "Zero count"
|
|
139
|
+
assert_includes hidden_section, "Zero float"
|
|
140
|
+
assert_includes hidden_section, "Whitespace string"
|
|
141
|
+
# And the boolean must still render as the meaningful "No" state, not
|
|
142
|
+
# silently vanish.
|
|
143
|
+
assert_includes hidden_section, "No"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
test "hide_blank: true keeps genuinely non-blank fields" do
|
|
147
|
+
get PATH
|
|
148
|
+
assert_response :success
|
|
149
|
+
|
|
150
|
+
hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
|
|
151
|
+
assert_includes hidden_section, "Title"
|
|
152
|
+
assert_includes hidden_section, "Widget One"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
test "default (no hide_blank option) hides nothing -- every label row still renders" do
|
|
156
|
+
get PATH
|
|
157
|
+
assert_response :success
|
|
158
|
+
|
|
159
|
+
default_section = response.body[/Main default.*?(?=<\/html>)/m]
|
|
160
|
+
refute_nil default_section
|
|
161
|
+
|
|
162
|
+
%w[Title Blank\ string Whitespace\ string Nil\ field Empty\ array Empty\ hash False\ flag Zero\ count Zero\ float].each do |label|
|
|
163
|
+
assert_includes default_section, label, "expected label '#{label}' to render in the default (no hide_blank) panel"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
test "hide_blank: true on a sidebar panel hides the same blank fields as main" do
|
|
168
|
+
get PATH
|
|
169
|
+
assert_response :success
|
|
170
|
+
|
|
171
|
+
sidebar_hidden = response.body[/Sidebar hidden.*?(?=Sidebar default)/m]
|
|
172
|
+
refute_nil sidebar_hidden
|
|
173
|
+
|
|
174
|
+
refute_includes sidebar_hidden, "Nil field"
|
|
175
|
+
refute_includes sidebar_hidden, "Blank string"
|
|
176
|
+
refute_includes sidebar_hidden, "Empty array"
|
|
177
|
+
refute_includes sidebar_hidden, "Empty hash"
|
|
178
|
+
assert_includes sidebar_hidden, "False flag"
|
|
179
|
+
assert_includes sidebar_hidden, "Zero count"
|
|
180
|
+
assert_includes sidebar_hidden, "Zero float"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
test "default sidebar panel (no hide_blank) hides nothing" do
|
|
184
|
+
get PATH
|
|
185
|
+
assert_response :success
|
|
186
|
+
|
|
187
|
+
sidebar_default = response.body[/Sidebar default.*?(?=<div class="bg-white)/m] || response.body[/Sidebar default.*\z/m]
|
|
188
|
+
refute_nil sidebar_default
|
|
189
|
+
|
|
190
|
+
%w[Nil\ field Blank\ string Empty\ array Empty\ hash].each do |label|
|
|
191
|
+
assert_includes sidebar_default, label
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Final review Finding 3: the gem hard-depends on turbo-rails (`turbo_stream`
|
|
6
|
+
# in `ResourcesController#toggle`, `turbo_frame_tag` throughout the resource
|
|
7
|
+
# views) but never declared it in the gemspec. Before that dependency is
|
|
8
|
+
# declared, this test exercises the exact path the review flagged as broken
|
|
9
|
+
# in a turbo-less host: `format.turbo_stream` inside `respond_to` raises the
|
|
10
|
+
# moment it's evaluated, because the `:turbo_stream` MIME type is unregistered
|
|
11
|
+
# without turbo-rails loaded. See `test/test_helper.rb`'s (now removed)
|
|
12
|
+
# `TurboFrameTestHelper`, which only ever patched `turbo_frame_tag` -- it
|
|
13
|
+
# never made `turbo_stream`/the MIME type real, so this branch was untestable
|
|
14
|
+
# until the real dependency was declared.
|
|
15
|
+
module ToggleFixtures
|
|
16
|
+
class Widget
|
|
17
|
+
extend ActiveModel::Naming
|
|
18
|
+
|
|
19
|
+
attr_reader :id
|
|
20
|
+
attr_accessor :active
|
|
21
|
+
|
|
22
|
+
def initialize(id: 1, active: false)
|
|
23
|
+
@id = id
|
|
24
|
+
@active = active
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.all
|
|
28
|
+
ReadOnlyResourceFixtures::Relation.new([ new ])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.column_names
|
|
32
|
+
%w[id active]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.primary_key
|
|
36
|
+
"id"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.columns_hash
|
|
40
|
+
{ "id" => Struct.new(:type).new(:integer) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.find(id)
|
|
44
|
+
return new(id: 1, active: false) if id.to_s == "1"
|
|
45
|
+
|
|
46
|
+
raise ActiveRecord::RecordNotFound
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_param
|
|
50
|
+
id.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `dom_id` (via `ActionView::RecordIdentifier#record_key_for_dom_id`)
|
|
54
|
+
# calls `to_key` on the record -- `extend ActiveModel::Naming` alone
|
|
55
|
+
# only supplies `model_name`, not `to_key`.
|
|
56
|
+
def to_key
|
|
57
|
+
[ id ]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update!(attrs)
|
|
61
|
+
attrs.each { |key, value| public_send("#{key}=", value) }
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module Admin
|
|
68
|
+
module Resources
|
|
69
|
+
class ToggleWidgetResource < Admin::Base::Resource
|
|
70
|
+
model ToggleFixtures::Widget
|
|
71
|
+
portal :ops
|
|
72
|
+
section :observability
|
|
73
|
+
|
|
74
|
+
index do
|
|
75
|
+
columns do
|
|
76
|
+
column :active, type: :toggle
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
module AdminSuite
|
|
84
|
+
class ToggleTest < ActionDispatch::IntegrationTest
|
|
85
|
+
include ActionView::RecordIdentifier
|
|
86
|
+
|
|
87
|
+
BASE_PATH = "/internal/admin_suite/ops/toggle_widgets"
|
|
88
|
+
|
|
89
|
+
test "toggle's turbo_stream branch replaces the toggle cell with the flipped state" do
|
|
90
|
+
post "#{BASE_PATH}/1/toggle",
|
|
91
|
+
params: { field: "active" },
|
|
92
|
+
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
93
|
+
|
|
94
|
+
assert_response :success
|
|
95
|
+
assert_equal "text/vnd.turbo-stream.html", response.media_type
|
|
96
|
+
|
|
97
|
+
target = dom_id(ToggleFixtures::Widget.new(id: 1), "active_toggle")
|
|
98
|
+
assert_includes response.body, "<turbo-stream"
|
|
99
|
+
assert_includes response.body, %(action="replace")
|
|
100
|
+
assert_includes response.body, %(target="#{target}")
|
|
101
|
+
# The record started with `active: false`; the toggle flips it to
|
|
102
|
+
# `true` before rendering, so the replaced cell must reflect "on".
|
|
103
|
+
assert_includes response.body, "is-on"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -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
|
|
@@ -41,6 +41,18 @@ module AdminSuite
|
|
|
41
41
|
before.each_value { |definition| AdminSuite::PortalRegistry.register(definition) }
|
|
42
42
|
end
|
|
43
43
|
else
|
|
44
|
+
# :dashboards/:actions have no accumulating registry to snapshot --
|
|
45
|
+
# unlike :resources/:portals (which pick up real fixtures registered
|
|
46
|
+
# by *other* test files at load time), reset! for these two kinds
|
|
47
|
+
# only clears a single flag/definition object
|
|
48
|
+
# (`root_dashboard_loaded`/`root_dashboard_definition`,
|
|
49
|
+
# `handlers_loaded`) that is entirely local to whichever test set it,
|
|
50
|
+
# via its own `with_globs`/`with_dashboard`-style ensure block. So
|
|
51
|
+
# there is nothing cross-test left to preserve, and "restore" here
|
|
52
|
+
# deliberately degrades to a second `reset!` call rather than a real
|
|
53
|
+
# snapshot+restore -- do not read this branch as returning a
|
|
54
|
+
# value-preserving restore the way the :resources/:portals branches
|
|
55
|
+
# above do.
|
|
44
56
|
-> { AdminSuite::DefinitionLoader.reset!(kind) }
|
|
45
57
|
end
|
|
46
58
|
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
|