admin_suite 0.2.8 → 0.3.0
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/CHANGELOG.md +38 -1
- data/CONTRIBUTING.md +2 -2
- data/Gemfile +3 -0
- data/README.md +10 -24
- data/app/controllers/admin_suite/application_controller.rb +53 -11
- data/app/controllers/admin_suite/resources_controller.rb +63 -9
- data/app/views/admin_suite/resources/index.html.erb +3 -1
- data/app/views/admin_suite/resources/show.html.erb +2 -2
- data/lib/admin/base/filter_builder.rb +2 -1
- data/lib/admin/base/resource.rb +14 -2
- data/lib/admin_suite/auth/host_hook.rb +25 -0
- data/lib/admin_suite/auth/http_basic.rb +46 -0
- data/lib/admin_suite/auth/strategy.rb +25 -0
- data/lib/admin_suite/auth.rb +33 -0
- data/lib/admin_suite/configuration.rb +8 -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 +16 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +24 -7
- data/test/controllers/resources_controller_test.rb +80 -0
- data/test/dummy/config/initializers/admin_suite_auth.rb +8 -0
- data/test/integration/authentication_test.rb +99 -0
- data/test/integration/authorization_test.rb +131 -0
- data/test/integration/read_only_resource_test.rb +130 -0
- data/test/lib/auth_http_basic_test.rb +62 -0
- data/test/lib/auth_resolution_test.rb +75 -0
- data/test/lib/auth_test.rb +29 -0
- data/test/lib/resource_observability_extensions_test.rb +53 -0
- data/test/test_helper.rb +43 -0
- metadata +15 -14
- data/docs/README.md +0 -26
- data/docs/actions.md +0 -98
- data/docs/configuration.md +0 -284
- data/docs/development.md +0 -64
- data/docs/docs_viewer.md +0 -79
- data/docs/fields.md +0 -188
- data/docs/installation.md +0 -80
- data/docs/portals.md +0 -140
- data/docs/releasing.md +0 -67
- data/docs/resources.md +0 -237
- data/docs/theming.md +0 -63
- data/docs/troubleshooting.md +0 -50
|
@@ -2,16 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
# AdminSuite configuration (host app adapter layer).
|
|
4
4
|
AdminSuite.configure do |config|
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
5
|
+
# --- Authentication (REQUIRED — AdminSuite fails closed) ---
|
|
6
|
+
#
|
|
7
|
+
# Built-in HTTP Basic (reads ADMIN_SUITE_USERNAME / ADMIN_SUITE_PASSWORD):
|
|
8
|
+
config.auth_strategy = :http_basic
|
|
9
|
+
# config.auth_options = { username: ENV["ADMIN_SUITE_USERNAME"], password: ENV["ADMIN_SUITE_PASSWORD"] }
|
|
10
|
+
#
|
|
11
|
+
# Or a custom strategy (e.g. your SSO). Subclass AdminSuite::Auth::Strategy,
|
|
12
|
+
# return an actor from #authenticate!(controller), and register it:
|
|
13
|
+
# AdminSuite::Auth.register(:my_sso, MySsoStrategy)
|
|
14
|
+
# config.auth_strategy = :my_sso
|
|
15
|
+
#
|
|
16
|
+
# Or the legacy lambda (still supported):
|
|
17
|
+
# config.authenticate = ->(controller) { ... render/redirect to deny ... }
|
|
18
|
+
#
|
|
19
|
+
# Development/test only — run without authentication (ignored in production):
|
|
20
|
+
# config.allow_unauthenticated = true
|
|
21
|
+
|
|
22
|
+
# Host before_actions the engine skips (it authenticates itself):
|
|
23
|
+
# config.skip_host_before_actions = [ :require_authentication ]
|
|
8
24
|
|
|
9
|
-
# Actor used for actions/auditing/authorization
|
|
10
|
-
#
|
|
25
|
+
# Actor used for actions/auditing/authorization when your strategy does not
|
|
26
|
+
# provide one (legacy fallback):
|
|
11
27
|
config.current_actor = ->(controller) { controller.respond_to?(:current_user) ? controller.current_user : nil }
|
|
12
28
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
29
|
+
# Authorization hook — called for every resource request:
|
|
30
|
+
# action is one of :read, :create, :update, :destroy, :execute.
|
|
31
|
+
# config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
|
|
15
32
|
config.authorize = nil
|
|
16
33
|
|
|
17
34
|
# Optional sign-out action in the topbar.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
class ResourcesControllerTest < ActiveSupport::TestCase
|
|
7
|
+
class TestController < ResourcesController
|
|
8
|
+
attr_writer :test_resource_config
|
|
9
|
+
attr_reader :filter_calls, :paginated_scope
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@filter_calls = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def resource_config
|
|
19
|
+
@test_resource_config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def filtered_collection
|
|
23
|
+
@filter_calls += 1
|
|
24
|
+
{ total: 37 }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def paginate_collection(scope)
|
|
28
|
+
@paginated_scope = scope
|
|
29
|
+
[ Object.new, :paginated ]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class StatsResource < Admin::Base::Resource
|
|
34
|
+
index do
|
|
35
|
+
stats do
|
|
36
|
+
stat :legacy, -> { 11 }
|
|
37
|
+
stat :filtered, ->(scope) { scope.fetch(:total) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class BrokenStatsResource < Admin::Base::Resource
|
|
43
|
+
index do
|
|
44
|
+
stats do
|
|
45
|
+
stat :broken, ->(_scope) { raise "boom" }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test "stats preserve zero arity calculators and pass the filtered scope to one arity calculators" do
|
|
51
|
+
controller = TestController.new
|
|
52
|
+
controller.test_resource_config = StatsResource
|
|
53
|
+
scope = { total: 37 }
|
|
54
|
+
|
|
55
|
+
stats = controller.send(:calculate_stats, scope)
|
|
56
|
+
|
|
57
|
+
assert_equal 11, stats.first[:value]
|
|
58
|
+
assert_equal 37, stats.second[:value]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test "stats preserve the existing calculator rescue behavior" do
|
|
62
|
+
controller = TestController.new
|
|
63
|
+
controller.test_resource_config = BrokenStatsResource
|
|
64
|
+
|
|
65
|
+
assert_equal "N/A", controller.send(:calculate_stats, Object.new).first[:value]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
test "index reuses one filtered unpaginated scope for stats and pagination" do
|
|
69
|
+
controller = TestController.new
|
|
70
|
+
controller.test_resource_config = StatsResource
|
|
71
|
+
|
|
72
|
+
controller.index
|
|
73
|
+
|
|
74
|
+
assert_equal 1, controller.filter_calls
|
|
75
|
+
assert_equal({ total: 37 }, controller.paginated_scope)
|
|
76
|
+
assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
|
|
77
|
+
assert_equal :paginated, controller.instance_variable_get(:@collection)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The dummy app's pre-auth integration tests exercise pages without
|
|
4
|
+
# authentication. Fail-closed is covered explicitly in
|
|
5
|
+
# test/integration/authentication_test.rb by flipping this off.
|
|
6
|
+
AdminSuite.configure do |config|
|
|
7
|
+
config.allow_unauthenticated = true
|
|
8
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "minitest/mock"
|
|
5
|
+
|
|
6
|
+
module AdminSuite
|
|
7
|
+
class AuthenticationTest < ActionDispatch::IntegrationTest
|
|
8
|
+
ROOT = "/internal/admin_suite"
|
|
9
|
+
|
|
10
|
+
def with_config(**overrides)
|
|
11
|
+
saved = overrides.keys.index_with { |k| AdminSuite.config.public_send(k) }
|
|
12
|
+
overrides.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
|
|
13
|
+
yield
|
|
14
|
+
ensure
|
|
15
|
+
saved.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
test "unconfigured auth fails closed with 403" do
|
|
19
|
+
with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: nil) do
|
|
20
|
+
get ROOT
|
|
21
|
+
assert_response :forbidden
|
|
22
|
+
assert_includes response.body, "no authentication is configured"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
test "allow_unauthenticated opens access outside production" do
|
|
27
|
+
with_config(allow_unauthenticated: true, auth_strategy: nil, authenticate: nil) do
|
|
28
|
+
get ROOT
|
|
29
|
+
assert_response :success
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "http_basic strategy denies without credentials and allows with them" do
|
|
34
|
+
with_config(allow_unauthenticated: false, auth_strategy: :http_basic,
|
|
35
|
+
auth_options: { username: "ravi", password: "s3cret" }) do
|
|
36
|
+
get ROOT
|
|
37
|
+
assert_response :unauthorized # Basic challenge
|
|
38
|
+
|
|
39
|
+
get ROOT, headers: {
|
|
40
|
+
"Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("ravi", "s3cret")
|
|
41
|
+
}
|
|
42
|
+
assert_response :success
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "legacy authenticate lambda still works" do
|
|
47
|
+
denials = ->(controller) { controller.head :forbidden }
|
|
48
|
+
with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: denials) do
|
|
49
|
+
get ROOT
|
|
50
|
+
assert_response :forbidden
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
test "allow_unauthenticated is ignored in production" do
|
|
55
|
+
with_config(allow_unauthenticated: true, auth_strategy: nil, authenticate: nil) do
|
|
56
|
+
Rails.stub(:env, ActiveSupport::StringInquirer.new("production")) do
|
|
57
|
+
get ROOT
|
|
58
|
+
assert_response :forbidden
|
|
59
|
+
assert_includes response.body, "no authentication is configured"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "current_actor is consulted at most once per request on the legacy path" do
|
|
65
|
+
calls = 0
|
|
66
|
+
passes = ->(_controller) {}
|
|
67
|
+
counting_actor = lambda { |_controller|
|
|
68
|
+
calls += 1
|
|
69
|
+
nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: passes,
|
|
73
|
+
current_actor: counting_actor) do
|
|
74
|
+
get ROOT
|
|
75
|
+
assert_response :success
|
|
76
|
+
assert_equal 1, calls
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "legacy authenticate lambda passes but a raising current_actor lambda is rescued to nil" do
|
|
81
|
+
passes = ->(_controller) {}
|
|
82
|
+
raising_actor = ->(_controller) { raise "boom" }
|
|
83
|
+
|
|
84
|
+
with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: passes,
|
|
85
|
+
current_actor: raising_actor) do
|
|
86
|
+
get ROOT
|
|
87
|
+
assert_response :success
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
test "host filter skip list is config-driven with require_authentication default" do
|
|
92
|
+
assert_equal [ :require_authentication ], AdminSuite.config.skip_host_before_actions
|
|
93
|
+
# The controller must consume config rather than hardcode the name:
|
|
94
|
+
source = AdminSuite::Engine.root.join("app/controllers/admin_suite/application_controller.rb").read
|
|
95
|
+
assert_includes source, "skip_host_before_actions"
|
|
96
|
+
refute_match(/skip_before_action :require_authentication\b/, source)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Reuses the in-memory fixture pattern from read_only_resource_test.rb.
|
|
6
|
+
module AuthzFixtures
|
|
7
|
+
class Gadget
|
|
8
|
+
extend ActiveModel::Naming
|
|
9
|
+
|
|
10
|
+
attr_reader :id, :name
|
|
11
|
+
|
|
12
|
+
def initialize(id: 1, name: "Gadget one")
|
|
13
|
+
@id = id
|
|
14
|
+
@name = name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new([ new ])
|
|
18
|
+
def self.column_names = %w[id name]
|
|
19
|
+
def self.primary_key = "id"
|
|
20
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
21
|
+
|
|
22
|
+
def self.find(id)
|
|
23
|
+
raise ActiveRecord::RecordNotFound unless id.to_s == "1"
|
|
24
|
+
new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_param = id.to_s
|
|
28
|
+
def attributes = { "id" => id, "name" => name }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module Admin
|
|
33
|
+
module Resources
|
|
34
|
+
class AuthzGadgetResource < Admin::Base::Resource
|
|
35
|
+
model AuthzFixtures::Gadget
|
|
36
|
+
portal :ops
|
|
37
|
+
section :observability
|
|
38
|
+
|
|
39
|
+
index do
|
|
40
|
+
columns { column :name }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module AdminSuite
|
|
47
|
+
class AuthorizationTest < ActionDispatch::IntegrationTest
|
|
48
|
+
BASE = "/internal/admin_suite/ops/authz_gadgets"
|
|
49
|
+
|
|
50
|
+
def with_authorize(hook)
|
|
51
|
+
saved = AdminSuite.config.authorize
|
|
52
|
+
AdminSuite.config.authorize = hook
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
AdminSuite.config.authorize = saved
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "nil authorize hook allows requests" do
|
|
59
|
+
with_authorize(nil) do
|
|
60
|
+
get BASE
|
|
61
|
+
assert_response :success
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "falsy authorize denies with 403" do
|
|
66
|
+
with_authorize(->(**) { false }) do
|
|
67
|
+
get BASE
|
|
68
|
+
assert_response :forbidden
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test "authorize receives actor, mapped action, resource, record, controller" do
|
|
73
|
+
captured = nil
|
|
74
|
+
hook = lambda do |actor:, action:, resource:, record:, controller:|
|
|
75
|
+
captured = { action: action, resource: resource, record: record }
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
with_authorize(hook) do
|
|
80
|
+
get "#{BASE}/1"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
assert_equal :read, captured[:action]
|
|
84
|
+
assert_equal Admin::Resources::AuthzGadgetResource, captured[:resource]
|
|
85
|
+
assert_instance_of AuthzFixtures::Gadget, captured[:record]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "destroy maps to :destroy" do
|
|
89
|
+
captured_action = nil
|
|
90
|
+
with_authorize(->(action:, **) { captured_action = action; false }) do
|
|
91
|
+
delete "#{BASE}/1"
|
|
92
|
+
end
|
|
93
|
+
assert_equal :destroy, captured_action
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
test "execute_action reaches the authorize hook with :execute before the action-name 404" do
|
|
97
|
+
captured_action = nil
|
|
98
|
+
with_authorize(->(action:, **) { captured_action = action; false }) do
|
|
99
|
+
post "#{BASE}/1/execute_action/anything"
|
|
100
|
+
end
|
|
101
|
+
assert_equal :execute, captured_action
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "bulk_action reaches the authorize hook with :execute before the action-name 404" do
|
|
105
|
+
captured_action = nil
|
|
106
|
+
with_authorize(->(action:, **) { captured_action = action; false }) do
|
|
107
|
+
post "#{BASE}/bulk_action/anything", params: { ids: [ "1" ] }
|
|
108
|
+
end
|
|
109
|
+
assert_equal :execute, captured_action
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
test "unregistered resource name 404s instead of constantizing a host model" do
|
|
113
|
+
get "/internal/admin_suite/ops/strings"
|
|
114
|
+
assert_response :not_found
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
test "unregistered resource name 404s on mutating verbs instead of reaching the model" do
|
|
118
|
+
delete "/internal/admin_suite/ops/strings/1"
|
|
119
|
+
assert_response :not_found
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
test "unregistered resource name 404s before the authorize hook ever runs" do
|
|
123
|
+
hook_called = false
|
|
124
|
+
with_authorize(->(**) { hook_called = true; false }) do
|
|
125
|
+
get "/internal/admin_suite/ops/strings"
|
|
126
|
+
assert_response :not_found
|
|
127
|
+
end
|
|
128
|
+
refute hook_called, "authorize hook must not run for an unregistered resource name"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module ReadOnlyResourceFixtures
|
|
6
|
+
class Widget
|
|
7
|
+
extend ActiveModel::Naming
|
|
8
|
+
|
|
9
|
+
attr_reader :id, :name
|
|
10
|
+
|
|
11
|
+
def initialize(id: 1, name: "Observed widget")
|
|
12
|
+
@id = id
|
|
13
|
+
@name = name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.all
|
|
17
|
+
Relation.new([ new ])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.column_names
|
|
21
|
+
%w[id name]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.primary_key
|
|
25
|
+
"id"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.columns_hash
|
|
29
|
+
{ "id" => Struct.new(:type).new(:integer) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.find(id)
|
|
33
|
+
raise ActiveRecord::RecordNotFound unless id.to_s == "1"
|
|
34
|
+
|
|
35
|
+
new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_param
|
|
39
|
+
id.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def attributes
|
|
43
|
+
{ "id" => id, "name" => name }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ping
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Admin
|
|
53
|
+
module Resources
|
|
54
|
+
class ReadOnlyWidgetResource < Admin::Base::Resource
|
|
55
|
+
model ReadOnlyResourceFixtures::Widget
|
|
56
|
+
portal :ops
|
|
57
|
+
section :observability
|
|
58
|
+
read_only
|
|
59
|
+
|
|
60
|
+
index do
|
|
61
|
+
columns do
|
|
62
|
+
column :name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
actions do
|
|
67
|
+
action :ping
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module AdminSuite
|
|
74
|
+
class ReadOnlyResourceTest < ActionDispatch::IntegrationTest
|
|
75
|
+
BASE_PATH = "/internal/admin_suite/ops/read_only_widgets"
|
|
76
|
+
|
|
77
|
+
test "direct built in mutation endpoints are rejected" do
|
|
78
|
+
get "#{BASE_PATH}/new"
|
|
79
|
+
assert_response :not_found
|
|
80
|
+
|
|
81
|
+
post BASE_PATH, params: { read_only_resource_fixtures_widget: { name: "changed" } }
|
|
82
|
+
assert_response :not_found
|
|
83
|
+
|
|
84
|
+
get "#{BASE_PATH}/1/edit"
|
|
85
|
+
assert_response :not_found
|
|
86
|
+
|
|
87
|
+
patch "#{BASE_PATH}/1", params: { read_only_resource_fixtures_widget: { name: "changed" } }
|
|
88
|
+
assert_response :not_found
|
|
89
|
+
|
|
90
|
+
delete "#{BASE_PATH}/1"
|
|
91
|
+
assert_response :not_found
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
test "index hides create and edit controls" do
|
|
95
|
+
get BASE_PATH
|
|
96
|
+
|
|
97
|
+
assert_response :success
|
|
98
|
+
assert_includes response.body, "Observed widget"
|
|
99
|
+
refute_includes response.body, "New Widget"
|
|
100
|
+
refute_match(/>\s*Edit\s*</, response.body)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
test "show mutation controls are conditional on write access" do
|
|
104
|
+
template = AdminSuite::Engine.root.join("app/views/admin_suite/resources/show.html.erb").read
|
|
105
|
+
|
|
106
|
+
assert_includes template, "has_edit_route = !resource_config.read_only?"
|
|
107
|
+
assert_includes template, "has_destroy_route = !resource_config.read_only?"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
test "toggle endpoint is rejected on read_only resources" do
|
|
111
|
+
post "#{BASE_PATH}/1/toggle", params: { field: "name" }
|
|
112
|
+
assert_response :not_found
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
test "undeclared execute_action names respond 404" do
|
|
116
|
+
post "#{BASE_PATH}/1/execute_action/nonexistent_action"
|
|
117
|
+
assert_response :not_found
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
test "undeclared bulk_action names respond 404" do
|
|
121
|
+
post "#{BASE_PATH}/bulk_action/nonexistent_bulk", params: { ids: ["1"] }
|
|
122
|
+
assert_response :not_found
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
test "declared member actions remain allowed on read_only resources" do
|
|
126
|
+
post "#{BASE_PATH}/1/execute_action/ping"
|
|
127
|
+
refute_equal 404, response.status
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
raise "unset ADMIN_SUITE_USERNAME in test env" if ENV["ADMIN_SUITE_USERNAME"]
|
|
4
|
+
|
|
5
|
+
require "test_helper"
|
|
6
|
+
|
|
7
|
+
module AdminSuite
|
|
8
|
+
class AuthHttpBasicTest < ActiveSupport::TestCase
|
|
9
|
+
# Minimal stand-in for the parts of ActionController the strategy touches.
|
|
10
|
+
class ControllerDouble
|
|
11
|
+
attr_reader :rendered_status, :challenged
|
|
12
|
+
|
|
13
|
+
def initialize(given_username: nil, given_password: nil)
|
|
14
|
+
@given_username = given_username
|
|
15
|
+
@given_password = given_password
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authenticate_with_http_basic
|
|
19
|
+
return false if @given_username.nil?
|
|
20
|
+
yield(@given_username, @given_password)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render(plain:, status:)
|
|
24
|
+
@rendered_status = status
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request_http_basic_authentication(_realm)
|
|
28
|
+
@challenged = true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "returns an actor for correct credentials" do
|
|
33
|
+
strategy = Auth::HttpBasic.new(username: "ravi", password: "s3cret")
|
|
34
|
+
controller = ControllerDouble.new(given_username: "ravi", given_password: "s3cret")
|
|
35
|
+
|
|
36
|
+
actor = strategy.authenticate!(controller)
|
|
37
|
+
|
|
38
|
+
assert_equal "ravi", actor.username
|
|
39
|
+
assert_equal "http-basic:ravi", actor.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test "challenges on wrong credentials and returns nil" do
|
|
43
|
+
strategy = Auth::HttpBasic.new(username: "ravi", password: "s3cret")
|
|
44
|
+
controller = ControllerDouble.new(given_username: "ravi", given_password: "wrong")
|
|
45
|
+
|
|
46
|
+
assert_nil strategy.authenticate!(controller)
|
|
47
|
+
assert controller.challenged
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test "denies with 403 when credentials are not configured" do
|
|
51
|
+
strategy = Auth::HttpBasic.new(username: nil, password: nil)
|
|
52
|
+
controller = ControllerDouble.new(given_username: "any", given_password: "any")
|
|
53
|
+
|
|
54
|
+
assert_nil strategy.authenticate!(controller)
|
|
55
|
+
assert_equal :forbidden, controller.rendered_status
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "is registered as :http_basic" do
|
|
59
|
+
assert_equal Auth::HttpBasic, Auth.lookup(:http_basic)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
class AuthResolutionTest < ActiveSupport::TestCase
|
|
7
|
+
class PerformableDouble
|
|
8
|
+
def initialize(performed:) = @performed = performed
|
|
9
|
+
def performed? = @performed
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
setup do
|
|
13
|
+
@saved = {
|
|
14
|
+
auth_strategy: AdminSuite.config.auth_strategy,
|
|
15
|
+
auth_options: AdminSuite.config.auth_options,
|
|
16
|
+
authenticate: AdminSuite.config.authenticate,
|
|
17
|
+
current_actor: AdminSuite.config.current_actor
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
teardown do
|
|
22
|
+
@saved.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
test "config defaults are fail-closed friendly" do
|
|
26
|
+
fresh = AdminSuite::Configuration.new
|
|
27
|
+
assert_nil fresh.auth_strategy
|
|
28
|
+
assert_equal({}, fresh.auth_options)
|
|
29
|
+
assert_equal false, fresh.allow_unauthenticated
|
|
30
|
+
assert_equal [ :require_authentication ], fresh.skip_host_before_actions
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "resolves a symbol strategy through the registry with options" do
|
|
34
|
+
AdminSuite.config.auth_strategy = :http_basic
|
|
35
|
+
AdminSuite.config.auth_options = { username: "u", password: "p" }
|
|
36
|
+
|
|
37
|
+
strategy = AdminSuite.resolved_auth_strategy
|
|
38
|
+
|
|
39
|
+
assert_instance_of Auth::HttpBasic, strategy
|
|
40
|
+
assert_equal "u", strategy.options[:username]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test "resolves a class strategy directly" do
|
|
44
|
+
klass = Class.new(Auth::Strategy)
|
|
45
|
+
AdminSuite.config.auth_strategy = klass
|
|
46
|
+
assert_instance_of klass, AdminSuite.resolved_auth_strategy
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "wraps legacy authenticate lambda when no strategy is set" do
|
|
50
|
+
AdminSuite.config.auth_strategy = nil
|
|
51
|
+
AdminSuite.config.authenticate = ->(_controller) { :called }
|
|
52
|
+
AdminSuite.config.current_actor = ->(_controller) { :legacy_actor }
|
|
53
|
+
|
|
54
|
+
strategy = AdminSuite.resolved_auth_strategy
|
|
55
|
+
assert_instance_of Auth::HostHook, strategy
|
|
56
|
+
|
|
57
|
+
actor = strategy.authenticate!(PerformableDouble.new(performed: false))
|
|
58
|
+
assert_equal :legacy_actor, actor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test "legacy lambda that halts (renders/redirects) denies" do
|
|
62
|
+
AdminSuite.config.auth_strategy = nil
|
|
63
|
+
AdminSuite.config.authenticate = ->(_controller) { :redirected_inside }
|
|
64
|
+
|
|
65
|
+
strategy = AdminSuite.resolved_auth_strategy
|
|
66
|
+
assert_nil strategy.authenticate!(PerformableDouble.new(performed: true))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "returns nil when nothing configured" do
|
|
70
|
+
AdminSuite.config.auth_strategy = nil
|
|
71
|
+
AdminSuite.config.authenticate = nil
|
|
72
|
+
assert_nil AdminSuite.resolved_auth_strategy
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
class AuthTest < ActiveSupport::TestCase
|
|
7
|
+
class FakeStrategy < AdminSuite::Auth::Strategy
|
|
8
|
+
def authenticate!(_controller) = :fake_actor
|
|
9
|
+
end
|
|
10
|
+
|
|
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")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
test "lookup of unknown strategy raises UnknownStrategyError" do
|
|
18
|
+
assert_raises(AdminSuite::Auth::UnknownStrategyError) do
|
|
19
|
+
AdminSuite::Auth.lookup(:nope)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test "base strategy exposes options and requires authenticate!" do
|
|
24
|
+
strategy = AdminSuite::Auth::Strategy.new(username: "u")
|
|
25
|
+
assert_equal({ username: "u" }, strategy.options)
|
|
26
|
+
assert_raises(NotImplementedError) { strategy.authenticate!(nil) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|