archipelago-rails 0.1.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 +7 -0
- data/Appraisals +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/Rakefile +17 -0
- data/app/controllers/archipelago/application_controller.rb +10 -0
- data/app/controllers/archipelago/islands_controller.rb +158 -0
- data/config/database.yml +5 -0
- data/config/routes.rb +6 -0
- data/lib/archipelago/action.rb +121 -0
- data/lib/archipelago/broadcasts.rb +17 -0
- data/lib/archipelago/channel.rb +36 -0
- data/lib/archipelago/configuration.rb +23 -0
- data/lib/archipelago/context.rb +14 -0
- data/lib/archipelago/engine.rb +21 -0
- data/lib/archipelago/params_dsl.rb +143 -0
- data/lib/archipelago/registry.rb +25 -0
- data/lib/archipelago/resolver.rb +54 -0
- data/lib/archipelago/response.rb +35 -0
- data/lib/archipelago/security/origin_validator.rb +32 -0
- data/lib/archipelago/security/redirect_validator.rb +30 -0
- data/lib/archipelago/test_helpers.rb +31 -0
- data/lib/archipelago/view_helper.rb +31 -0
- data/lib/archipelago-rails.rb +3 -0
- data/lib/archipelago.rb +71 -0
- data/lib/generators/archipelago/install/install_generator.rb +43 -0
- data/lib/generators/archipelago/install/react/react_generator.rb +380 -0
- data/lib/generators/archipelago/install/react/templates/entry.js.tt +13 -0
- data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +96 -0
- data/lib/generators/archipelago/install/react_generator.rb +3 -0
- data/lib/generators/archipelago/install_generator.rb +3 -0
- data/lib/generators/archipelago/island/island_generator.rb +44 -0
- data/lib/generators/archipelago/island/templates/action.rb.tt +11 -0
- data/lib/generators/archipelago/island/templates/component.tsx.tt +14 -0
- data/lib/generators/archipelago/island_generator.rb +3 -0
- data/test/archipelago/action_test.rb +136 -0
- data/test/archipelago/broadcasts_test.rb +29 -0
- data/test/archipelago/channel_test.rb +15 -0
- data/test/archipelago/notifications_test.rb +60 -0
- data/test/archipelago/origin_validator_test.rb +36 -0
- data/test/archipelago/params_dsl_test.rb +51 -0
- data/test/archipelago/redirect_validator_test.rb +28 -0
- data/test/archipelago/resolver_test.rb +80 -0
- data/test/archipelago/response_test.rb +30 -0
- data/test/archipelago/view_helper_test.rb +32 -0
- data/test/controllers/islands_controller_test.rb +115 -0
- data/test/generators/install_generator_test.rb +26 -0
- data/test/generators/island_generator_test.rb +20 -0
- data/test/generators/react_install_generator_test.rb +67 -0
- data/test/test_helper.rb +35 -0
- metadata +180 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
require "active_model"
|
|
5
|
+
begin
|
|
6
|
+
require "active_record"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
class RecordInvalid < StandardError
|
|
10
|
+
attr_reader :record
|
|
11
|
+
|
|
12
|
+
def initialize(record)
|
|
13
|
+
@record = record
|
|
14
|
+
super("Record invalid")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ActionTest < ArchipelagoTestCase
|
|
21
|
+
DummyContext = Struct.new(:user, :request, :params, :session)
|
|
22
|
+
|
|
23
|
+
class ForbiddenAction < Archipelago::Action
|
|
24
|
+
authorize { false }
|
|
25
|
+
|
|
26
|
+
def perform
|
|
27
|
+
props ok: true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class MissingAuthorizationAction < Archipelago::Action
|
|
32
|
+
def perform
|
|
33
|
+
props ok: true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class RedirectAction < Archipelago::Action
|
|
38
|
+
authorize { true }
|
|
39
|
+
|
|
40
|
+
def perform
|
|
41
|
+
redirect_to "https://evil.test/redirect"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class BroadcastAction < Archipelago::Action
|
|
46
|
+
authorize { true }
|
|
47
|
+
|
|
48
|
+
def perform
|
|
49
|
+
props members: [1, 2]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class ValidRedirectAction < Archipelago::Action
|
|
54
|
+
authorize { true }
|
|
55
|
+
|
|
56
|
+
def perform
|
|
57
|
+
redirect_to "/teams/1"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class RecordModel
|
|
62
|
+
include ActiveModel::Model
|
|
63
|
+
|
|
64
|
+
attr_accessor :email
|
|
65
|
+
|
|
66
|
+
validates :email, presence: true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class RecordInvalidAction < Archipelago::Action
|
|
70
|
+
authorize { true }
|
|
71
|
+
|
|
72
|
+
def perform
|
|
73
|
+
model = RecordModel.new(email: nil)
|
|
74
|
+
model.valid?
|
|
75
|
+
raise ActiveRecord::RecordInvalid.new(model)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_authorization_failure_returns_forbidden
|
|
80
|
+
payload = ForbiddenAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
|
|
81
|
+
|
|
82
|
+
assert_equal({ status: "forbidden" }, payload)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_missing_authorization_raises_when_required
|
|
86
|
+
Archipelago.configure { |config| config.authorize_by_default = true }
|
|
87
|
+
|
|
88
|
+
assert_raises(Archipelago::MissingAuthorization) do
|
|
89
|
+
MissingAuthorizationAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_invalid_redirect_raises
|
|
94
|
+
assert_raises(Archipelago::InvalidRedirect) do
|
|
95
|
+
RedirectAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_relative_redirect_is_allowed
|
|
100
|
+
payload = ValidRedirectAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
|
|
101
|
+
|
|
102
|
+
assert_equal "redirect", payload[:status]
|
|
103
|
+
assert_equal "/teams/1", payload[:location]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_broadcasts_when_stream_param_present
|
|
107
|
+
captured = nil
|
|
108
|
+
original_broadcast = Archipelago.method(:broadcast)
|
|
109
|
+
previous_verbose, $VERBOSE = $VERBOSE, nil
|
|
110
|
+
|
|
111
|
+
Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
|
|
112
|
+
captured = [stream_name, props, version]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
BroadcastAction.new(
|
|
117
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
118
|
+
raw_params: { __stream: "TeamMembers:1" }
|
|
119
|
+
).call
|
|
120
|
+
ensure
|
|
121
|
+
Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
|
|
122
|
+
$VERBOSE = previous_verbose
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
assert_equal "TeamMembers:1", captured[0]
|
|
126
|
+
assert_equal({ members: [1, 2] }, captured[1])
|
|
127
|
+
assert_kind_of Integer, captured[2]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_maps_record_invalid_errors
|
|
131
|
+
payload = RecordInvalidAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
|
|
132
|
+
|
|
133
|
+
assert_equal "error", payload[:status]
|
|
134
|
+
assert_equal ["Email can't be blank"], payload[:errors]["email"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class BroadcastsTest < ArchipelagoTestCase
|
|
6
|
+
ServerStub = Struct.new(:broadcasts) do
|
|
7
|
+
def broadcast(stream_name, payload)
|
|
8
|
+
broadcasts << [stream_name, payload]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_broadcasts_ok_payload_with_version
|
|
13
|
+
server = ServerStub.new([])
|
|
14
|
+
original_server_method = ActionCable.method(:server)
|
|
15
|
+
previous_verbose, $VERBOSE = $VERBOSE, nil
|
|
16
|
+
|
|
17
|
+
ActionCable.singleton_class.define_method(:server) { server }
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
payload = Archipelago::Broadcasts.broadcast("TeamMembers:1", props: { members: [1] }, version: 10)
|
|
21
|
+
|
|
22
|
+
assert_equal "ok", payload[:status]
|
|
23
|
+
assert_equal ["TeamMembers:1", payload], server.broadcasts.first
|
|
24
|
+
ensure
|
|
25
|
+
ActionCable.singleton_class.define_method(:server, original_server_method)
|
|
26
|
+
$VERBOSE = previous_verbose
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class ChannelTest < ArchipelagoTestCase
|
|
6
|
+
def test_stream_pattern_accepts_safe_names
|
|
7
|
+
assert_match Archipelago::IslandChannel::STREAM_PATTERN, "TeamMembers:1"
|
|
8
|
+
assert_match Archipelago::IslandChannel::STREAM_PATTERN, "teams:abc_123"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_stream_pattern_rejects_unsafe_names
|
|
12
|
+
refute_match Archipelago::IslandChannel::STREAM_PATTERN, "../etc/passwd"
|
|
13
|
+
refute_match Archipelago::IslandChannel::STREAM_PATTERN, "team members"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
module Islands
|
|
6
|
+
module NotificationDemo
|
|
7
|
+
class Run < Archipelago::Action
|
|
8
|
+
authorize { true }
|
|
9
|
+
|
|
10
|
+
def perform
|
|
11
|
+
props message: "ok"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class InvalidRun < Archipelago::Action
|
|
16
|
+
param :email, :string, required: true
|
|
17
|
+
authorize { true }
|
|
18
|
+
|
|
19
|
+
def perform
|
|
20
|
+
props message: "nope"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class NotificationsTest < ArchipelagoTestCase
|
|
27
|
+
DummyContext = Struct.new(:user, :request, :params, :session)
|
|
28
|
+
|
|
29
|
+
def test_emits_resolve_notification
|
|
30
|
+
payloads = []
|
|
31
|
+
|
|
32
|
+
callback = lambda do |_name, _start, _finish, _id, payload|
|
|
33
|
+
payloads << payload
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ActiveSupport::Notifications.subscribed(callback, "archipelago.action.resolve") do
|
|
37
|
+
Archipelago::Resolver.new.resolve(component: "NotificationDemo", operation: "run")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
assert_equal "NotificationDemo", payloads.first[:component]
|
|
41
|
+
assert_equal "run", payloads.first[:operation]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_emits_error_notification_for_validation_failure
|
|
45
|
+
payloads = []
|
|
46
|
+
|
|
47
|
+
callback = lambda do |_name, _start, _finish, _id, payload|
|
|
48
|
+
payloads << payload
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ActiveSupport::Notifications.subscribed(callback, "archipelago.action.error") do
|
|
52
|
+
Islands::NotificationDemo::InvalidRun.new(
|
|
53
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
54
|
+
raw_params: {}
|
|
55
|
+
).call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
assert_equal "validation", payloads.first[:reason]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class OriginValidatorTest < ArchipelagoTestCase
|
|
6
|
+
RequestStub = Struct.new(:headers, :protocol, :host, :port)
|
|
7
|
+
|
|
8
|
+
def test_skips_when_strict_mode_disabled
|
|
9
|
+
config = Archipelago::Configuration.new
|
|
10
|
+
config.strict_origin_check = false
|
|
11
|
+
|
|
12
|
+
request = RequestStub.new({ "Origin" => "https://example.com" }, "https://", "example.com", 443)
|
|
13
|
+
|
|
14
|
+
assert Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_allows_matching_origin
|
|
18
|
+
config = Archipelago::Configuration.new
|
|
19
|
+
config.strict_origin_check = true
|
|
20
|
+
|
|
21
|
+
request = RequestStub.new({ "Origin" => "https://example.com" }, "https://", "example.com", 443)
|
|
22
|
+
|
|
23
|
+
assert Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_rejects_mismatched_origin
|
|
27
|
+
config = Archipelago::Configuration.new
|
|
28
|
+
config.strict_origin_check = true
|
|
29
|
+
|
|
30
|
+
request = RequestStub.new({ "Origin" => "https://attacker.test" }, "https://", "example.com", 443)
|
|
31
|
+
|
|
32
|
+
assert_raises(Archipelago::InvalidOrigin) do
|
|
33
|
+
Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class ParamsDSLTest < ArchipelagoTestCase
|
|
6
|
+
class ParamAction < Archipelago::Action
|
|
7
|
+
param :team_id, :integer, required: true
|
|
8
|
+
param :email, :string, required: true, strip: true, downcase: true
|
|
9
|
+
param :notify, :boolean, default: false
|
|
10
|
+
|
|
11
|
+
authorize { true }
|
|
12
|
+
|
|
13
|
+
def perform
|
|
14
|
+
props team_id: team_id, email: email, notify: notify
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
DummyContext = Struct.new(:user, :request, :params, :session)
|
|
19
|
+
|
|
20
|
+
def test_coerces_and_transforms_params
|
|
21
|
+
payload = ParamAction.new(
|
|
22
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
23
|
+
raw_params: { "team_id" => "10", "email" => " USER@EXAMPLE.COM " }
|
|
24
|
+
).call
|
|
25
|
+
|
|
26
|
+
assert_equal "ok", payload[:status]
|
|
27
|
+
assert_equal 10, payload[:props][:team_id]
|
|
28
|
+
assert_equal "user@example.com", payload[:props][:email]
|
|
29
|
+
assert_equal false, payload[:props][:notify]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_reports_required_errors
|
|
33
|
+
payload = ParamAction.new(
|
|
34
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
35
|
+
raw_params: { "team_id" => "10" }
|
|
36
|
+
).call
|
|
37
|
+
|
|
38
|
+
assert_equal "error", payload[:status]
|
|
39
|
+
assert_equal ["is required"], payload[:errors]["email"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_reports_coercion_errors
|
|
43
|
+
payload = ParamAction.new(
|
|
44
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
45
|
+
raw_params: { "team_id" => "x", "email" => "a@b.c" }
|
|
46
|
+
).call
|
|
47
|
+
|
|
48
|
+
assert_equal "error", payload[:status]
|
|
49
|
+
assert_equal ["is invalid"], payload[:errors]["team_id"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class RedirectValidatorTest < ArchipelagoTestCase
|
|
6
|
+
def test_accepts_relative_path
|
|
7
|
+
validator = Archipelago::Security::RedirectValidator.new
|
|
8
|
+
|
|
9
|
+
assert_equal "/teams/1", validator.validate!("/teams/1")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_rejects_unlisted_absolute_host
|
|
13
|
+
validator = Archipelago::Security::RedirectValidator.new
|
|
14
|
+
|
|
15
|
+
assert_raises(Archipelago::InvalidRedirect) do
|
|
16
|
+
validator.validate!("https://example.com/path")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_allows_allowlisted_absolute_host
|
|
21
|
+
config = Archipelago::Configuration.new
|
|
22
|
+
config.allowed_redirect_hosts = ["app.example.com"]
|
|
23
|
+
|
|
24
|
+
validator = Archipelago::Security::RedirectValidator.new(configuration: config)
|
|
25
|
+
|
|
26
|
+
assert_equal "https://app.example.com/path", validator.validate!("https://app.example.com/path")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
module Islands
|
|
6
|
+
module TeamMembers
|
|
7
|
+
class AddMember < Archipelago::Action
|
|
8
|
+
authorize { true }
|
|
9
|
+
|
|
10
|
+
def perform
|
|
11
|
+
props members: []
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Admin
|
|
17
|
+
module Users
|
|
18
|
+
class Create < Archipelago::Action
|
|
19
|
+
authorize { true }
|
|
20
|
+
|
|
21
|
+
def perform
|
|
22
|
+
props users: []
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ResolverTest < ArchipelagoTestCase
|
|
30
|
+
class CustomHandler < Archipelago::Action
|
|
31
|
+
authorize { true }
|
|
32
|
+
|
|
33
|
+
def perform
|
|
34
|
+
props override: true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def setup
|
|
39
|
+
super
|
|
40
|
+
Archipelago.configure { |config| config.root_namespace = "Islands" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_resolves_by_convention
|
|
44
|
+
resolved = Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "add_member")
|
|
45
|
+
|
|
46
|
+
assert_equal Islands::TeamMembers::AddMember, resolved
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_resolves_namespaced_component
|
|
50
|
+
resolved = Archipelago::Resolver.new.resolve(component: "Admin__Users", operation: "create")
|
|
51
|
+
|
|
52
|
+
assert_equal Islands::Admin::Users::Create, resolved
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_registry_override_takes_precedence
|
|
56
|
+
Archipelago.map "TeamMembers#add_member" => CustomHandler
|
|
57
|
+
|
|
58
|
+
resolved = Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "add_member")
|
|
59
|
+
|
|
60
|
+
assert_equal CustomHandler, resolved
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_rejects_invalid_component_name
|
|
64
|
+
assert_raises(Archipelago::ResolutionError) do
|
|
65
|
+
Archipelago::Resolver.new.resolve(component: "team_members", operation: "add_member")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_rejects_invalid_operation_name
|
|
70
|
+
assert_raises(Archipelago::ResolutionError) do
|
|
71
|
+
Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "AddMember")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_raises_for_missing_handler
|
|
76
|
+
assert_raises(Archipelago::ResolutionError) do
|
|
77
|
+
Archipelago::Resolver.new.resolve(component: "UnknownIsland", operation: "add_member")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class ResponseTest < ArchipelagoTestCase
|
|
6
|
+
def test_ok_payload
|
|
7
|
+
payload = Archipelago::Response.ok(props: { members: [] }, version: 7)
|
|
8
|
+
|
|
9
|
+
assert_equal "ok", payload[:status]
|
|
10
|
+
assert_equal({ members: [] }, payload[:props])
|
|
11
|
+
assert_equal 7, payload[:version]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_redirect_payload
|
|
15
|
+
payload = Archipelago::Response.redirect(location: "/teams/1")
|
|
16
|
+
|
|
17
|
+
assert_equal({ status: "redirect", location: "/teams/1" }, payload)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_error_payload
|
|
21
|
+
payload = Archipelago::Response.error(errors: { "email" => ["is invalid"] })
|
|
22
|
+
|
|
23
|
+
assert_equal "error", payload[:status]
|
|
24
|
+
assert_equal ["is invalid"], payload[:errors]["email"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_forbidden_payload
|
|
28
|
+
assert_equal({ status: "forbidden" }, Archipelago::Response.forbidden)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class ViewHelperTest < ArchipelagoTestCase
|
|
6
|
+
ViewContext = Class.new do
|
|
7
|
+
include ActionView::Helpers::TagHelper
|
|
8
|
+
include Archipelago::ViewHelper
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_renders_island_div_with_data_attributes
|
|
12
|
+
html = ViewContext.new.archipelago_island(
|
|
13
|
+
"TeamMembers",
|
|
14
|
+
props: { members: [{ id: 1 }] },
|
|
15
|
+
params: { team_id: 9 },
|
|
16
|
+
instance: "team_9_members",
|
|
17
|
+
stream: true,
|
|
18
|
+
class: "island"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert_includes html, "data-island=\"true\""
|
|
22
|
+
assert_includes html, "data-component=\"TeamMembers\""
|
|
23
|
+
assert_includes html, "data-stream=\"TeamMembers:team_9_members\""
|
|
24
|
+
assert_includes html, "class=\"island\""
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_requires_instance_when_stream_true
|
|
28
|
+
assert_raises(ArgumentError) do
|
|
29
|
+
ViewContext.new.archipelago_island("TeamMembers", props: {}, params: {}, stream: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
require "rails/all"
|
|
5
|
+
require "action_dispatch/testing/integration"
|
|
6
|
+
|
|
7
|
+
unless defined?(ArchipelagoDummyApp)
|
|
8
|
+
class ArchipelagoDummyApp < Rails::Application
|
|
9
|
+
config.root = File.expand_path("../../", __dir__)
|
|
10
|
+
config.eager_load = false
|
|
11
|
+
config.secret_key_base = "test-key"
|
|
12
|
+
config.hosts << "www.example.com"
|
|
13
|
+
|
|
14
|
+
routes.append do
|
|
15
|
+
mount Archipelago::Engine => "/islands"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ArchipelagoDummyApp.initialize!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Islands
|
|
23
|
+
module TeamMembers
|
|
24
|
+
class AddMember < Archipelago::Action
|
|
25
|
+
authorize { true }
|
|
26
|
+
|
|
27
|
+
def perform
|
|
28
|
+
props members: [{ id: 1, email: "person@example.com" }]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Forbid < Archipelago::Action
|
|
33
|
+
authorize { false }
|
|
34
|
+
|
|
35
|
+
def perform
|
|
36
|
+
props blocked: false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class DebugMeta < Archipelago::Action
|
|
41
|
+
param :team_id, :integer, required: true
|
|
42
|
+
param :email, :string, strip: true, downcase: true, default: "fallback@example.com"
|
|
43
|
+
param :tags, :array, default: -> { [] }
|
|
44
|
+
|
|
45
|
+
authorize { true }
|
|
46
|
+
|
|
47
|
+
def perform
|
|
48
|
+
props ok: true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class IslandsControllerTest < ActionDispatch::IntegrationTest
|
|
55
|
+
def setup
|
|
56
|
+
super
|
|
57
|
+
Archipelago.reset_configuration!
|
|
58
|
+
Archipelago.registry.clear!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_success_response_contract
|
|
62
|
+
post "/islands/TeamMembers/add_member", params: { email: "person@example.com" }, as: :json
|
|
63
|
+
|
|
64
|
+
assert_response :ok
|
|
65
|
+
body = JSON.parse(response.body)
|
|
66
|
+
assert_equal "ok", body["status"]
|
|
67
|
+
assert_equal "person@example.com", body.fetch("props").fetch("members").first.fetch("email")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_forbidden_response_contract
|
|
71
|
+
post "/islands/TeamMembers/forbid", params: {}, as: :json
|
|
72
|
+
|
|
73
|
+
assert_response :forbidden
|
|
74
|
+
body = JSON.parse(response.body)
|
|
75
|
+
assert_equal "forbidden", body["status"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_resolution_failure_is_404
|
|
79
|
+
post "/islands/Unknown/add_member", params: {}, as: :json
|
|
80
|
+
|
|
81
|
+
assert_response :not_found
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_debug_payload_includes_param_metadata_for_registry_actions
|
|
85
|
+
Archipelago.map "TeamMembers#debug_meta" => Islands::TeamMembers::DebugMeta
|
|
86
|
+
|
|
87
|
+
get "/islands/__debug"
|
|
88
|
+
|
|
89
|
+
assert_response :ok
|
|
90
|
+
body = JSON.parse(response.body)
|
|
91
|
+
|
|
92
|
+
entry = body.fetch("registry_actions").find do |candidate|
|
|
93
|
+
candidate["component"] == "TeamMembers" && candidate["operation"] == "debug_meta"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
refute_nil entry
|
|
97
|
+
assert_equal "Islands::TeamMembers::DebugMeta", entry["handler"]
|
|
98
|
+
|
|
99
|
+
team_id = entry.fetch("params").find { |param| param["name"] == "team_id" }
|
|
100
|
+
assert_equal "integer", team_id["type"]
|
|
101
|
+
assert_equal true, team_id["required"]
|
|
102
|
+
assert_equal({ "provided" => false }, team_id["default"])
|
|
103
|
+
|
|
104
|
+
email = entry.fetch("params").find { |param| param["name"] == "email" }
|
|
105
|
+
assert_equal "string", email["type"]
|
|
106
|
+
assert_equal({ "provided" => true, "value" => "fallback@example.com" }, email["default"])
|
|
107
|
+
assert_equal(
|
|
108
|
+
{ "strip" => true, "downcase" => true, "upcase" => false },
|
|
109
|
+
email["transforms"]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
tags = entry.fetch("params").find { |param| param["name"] == "tags" }
|
|
113
|
+
assert_equal({ "provided" => true, "kind" => "callable" }, tags["default"])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
require "rails/generators/test_case"
|
|
5
|
+
require "generators/archipelago/install_generator"
|
|
6
|
+
|
|
7
|
+
class InstallGeneratorTest < Rails::Generators::TestCase
|
|
8
|
+
tests Archipelago::Generators::InstallGenerator
|
|
9
|
+
destination File.expand_path("../../tmp", __dir__)
|
|
10
|
+
|
|
11
|
+
setup :prepare_destination
|
|
12
|
+
|
|
13
|
+
def setup
|
|
14
|
+
super
|
|
15
|
+
FileUtils.mkdir_p(File.join(destination_root, "config"))
|
|
16
|
+
File.write(File.join(destination_root, "config/routes.rb"), "Rails.application.routes.draw do\nend\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_generates_initializer_and_islands_dir
|
|
20
|
+
run_generator
|
|
21
|
+
|
|
22
|
+
assert_file "app/islands"
|
|
23
|
+
assert_file "config/initializers/archipelago.rb"
|
|
24
|
+
assert_file "config/routes.rb", /mount Archipelago::Engine => "\/islands"/
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
require "rails/generators/test_case"
|
|
5
|
+
require "generators/archipelago/island_generator"
|
|
6
|
+
|
|
7
|
+
class IslandGeneratorTest < Rails::Generators::TestCase
|
|
8
|
+
tests Archipelago::Generators::IslandGenerator
|
|
9
|
+
destination File.expand_path("../../tmp", __dir__)
|
|
10
|
+
|
|
11
|
+
setup :prepare_destination
|
|
12
|
+
|
|
13
|
+
def test_generates_action_and_component_files
|
|
14
|
+
run_generator %w[TeamMembers add_member remove_member]
|
|
15
|
+
|
|
16
|
+
assert_file "app/islands/team_members/add_member.rb", /class AddMember < Archipelago::Action/
|
|
17
|
+
assert_file "app/islands/team_members/remove_member.rb", /class RemoveMember < Archipelago::Action/
|
|
18
|
+
assert_file "app/javascript/islands/TeamMembers.tsx", /useIslandProps/
|
|
19
|
+
end
|
|
20
|
+
end
|