funicular 0.4.0 → 0.5.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 +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
|
Binary file
|
data/lib/funicular/version.rb
CHANGED
data/lib/funicular.rb
CHANGED
data/lib/tasks/funicular.rake
CHANGED
|
@@ -101,12 +101,20 @@ namespace :funicular do
|
|
|
101
101
|
|
|
102
102
|
FileUtils.cp(source_js, dest_js)
|
|
103
103
|
FileUtils.cp(source_css, dest_css)
|
|
104
|
-
FileUtils.cp(source_initializer, dest_initializer)
|
|
105
104
|
|
|
106
105
|
puts "Installed Funicular debug assets:"
|
|
107
106
|
puts " - #{dest_js}"
|
|
108
107
|
puts " - #{dest_css}"
|
|
109
|
-
|
|
108
|
+
|
|
109
|
+
# The initializer belongs to the application once it exists:
|
|
110
|
+
# re-running the installer must never clobber its configuration
|
|
111
|
+
# (source choice, local_database opt-in, user_key).
|
|
112
|
+
if File.exist?(dest_initializer)
|
|
113
|
+
puts "Skipped #{dest_initializer} (exists; delete it first to reinstall the template)"
|
|
114
|
+
else
|
|
115
|
+
FileUtils.cp(source_initializer, dest_initializer)
|
|
116
|
+
puts " - #{dest_initializer}"
|
|
117
|
+
end
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
desc "Install vendored PicoRuby.wasm artifacts (dist + debug) into public/picoruby/"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The mrblib runtime is plain Ruby, so the CRuby suite can cover the
|
|
6
|
+
# Response#body alias and the handler error report format directly;
|
|
7
|
+
# the browser-side wiring ships with the next picoruby.wasm rebuild.
|
|
8
|
+
class CallbackErrorVisibilityTest < Minitest::Test
|
|
9
|
+
def setup
|
|
10
|
+
Funicular::SSR::Runtime.load_framework!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Assigning the anonymous class to a constant names it, so the report
|
|
14
|
+
# prints a real component name. Defined lazily: Funicular::Component
|
|
15
|
+
# only exists after the framework loads.
|
|
16
|
+
def probe_component
|
|
17
|
+
unless defined?(::CheckoutProbeComponent)
|
|
18
|
+
Object.const_set(:CheckoutProbeComponent, Class.new(Funicular::Component) do
|
|
19
|
+
def render; end
|
|
20
|
+
end)
|
|
21
|
+
end
|
|
22
|
+
::CheckoutProbeComponent.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_response_body_is_an_alias_of_data
|
|
26
|
+
response = Funicular::HTTP::Response.new(200, { "id" => 1 })
|
|
27
|
+
assert_equal response.data, response.body
|
|
28
|
+
assert_equal({ "id" => 1 }, response.body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_report_handler_error_names_component_handler_and_event
|
|
32
|
+
error = ArgumentError.new("wrong number of arguments (given 1, expected 0)")
|
|
33
|
+
|
|
34
|
+
out, _err = capture_io do
|
|
35
|
+
probe_component.report_handler_error("click", "#handle_save", error)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
assert_includes out, "CheckoutProbeComponent#handle_save"
|
|
39
|
+
assert_includes out, "(onclick)"
|
|
40
|
+
assert_includes out, "ArgumentError: wrong number of arguments"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_report_handler_error_never_raises
|
|
44
|
+
capture_io do
|
|
45
|
+
assert_nil probe_component.report_handler_error("click", "#x", RuntimeError.new("y"))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -61,4 +61,82 @@ class ConfigurationTest < Minitest::Test
|
|
|
61
61
|
assert_equal vendored, @config.cdn_version
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
|
+
|
|
65
|
+
# --- local-database namespace settings (docs decisions 12/13) ---------
|
|
66
|
+
|
|
67
|
+
def test_namespace_defaults
|
|
68
|
+
assert_equal false, @config.local_database
|
|
69
|
+
assert_equal "funicular", @config.application_id
|
|
70
|
+
assert_nil @config.user_key
|
|
71
|
+
assert_equal false, @config.anonymous_only
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_local_database_setter_normalizes_truthiness
|
|
75
|
+
@config.local_database = Object.new
|
|
76
|
+
assert_equal true, @config.local_database
|
|
77
|
+
@config.local_database = nil
|
|
78
|
+
assert_equal false, @config.local_database
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_disabled_local_database_needs_no_identity
|
|
82
|
+
assert_equal true, @config.validate_local_database!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_enabled_local_database_requires_an_identity_declaration
|
|
86
|
+
@config.local_database = true
|
|
87
|
+
error = assert_raises(ArgumentError) do
|
|
88
|
+
@config.validate_local_database!
|
|
89
|
+
end
|
|
90
|
+
assert_includes error.message, "user_key"
|
|
91
|
+
assert_includes error.message, "anonymous_only"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_either_identity_declaration_satisfies_local_database
|
|
95
|
+
@config.local_database = true
|
|
96
|
+
@config.anonymous_only = true
|
|
97
|
+
assert_equal true, @config.validate_local_database!
|
|
98
|
+
|
|
99
|
+
fresh = Funicular::Configuration.new
|
|
100
|
+
fresh.local_database = true
|
|
101
|
+
fresh.user_key = ->(_controller) { nil }
|
|
102
|
+
assert_equal true, fresh.validate_local_database!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_application_id_rejects_empty_values
|
|
106
|
+
error = assert_raises(ArgumentError) { @config.application_id = "" }
|
|
107
|
+
assert_includes error.message, "application_id"
|
|
108
|
+
error = assert_raises(ArgumentError) { @config.application_id = nil }
|
|
109
|
+
assert_includes error.message, "application_id"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_application_id_is_canonicalized_with_to_s
|
|
113
|
+
@config.application_id = :chat_app
|
|
114
|
+
assert_equal "chat_app", @config.application_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_user_key_must_be_callable
|
|
118
|
+
error = assert_raises(ArgumentError) { @config.user_key = "not callable" }
|
|
119
|
+
assert_includes error.message, "callable"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_user_key_and_anonymous_only_are_mutually_exclusive
|
|
123
|
+
# The reliably-detectable server-side config error: whichever
|
|
124
|
+
# setter comes second raises, in both orders.
|
|
125
|
+
@config.user_key = ->(controller) { nil }
|
|
126
|
+
error = assert_raises(ArgumentError) { @config.anonymous_only = true }
|
|
127
|
+
assert_includes error.message, "mutually exclusive"
|
|
128
|
+
|
|
129
|
+
fresh = Funicular::Configuration.new
|
|
130
|
+
fresh.anonymous_only = true
|
|
131
|
+
error = assert_raises(ArgumentError) do
|
|
132
|
+
fresh.user_key = ->(controller) { nil }
|
|
133
|
+
end
|
|
134
|
+
assert_includes error.message, "mutually exclusive"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_anonymous_only_false_never_conflicts
|
|
138
|
+
@config.user_key = ->(controller) { nil }
|
|
139
|
+
@config.anonymous_only = false
|
|
140
|
+
assert_equal false, @config.anonymous_only
|
|
141
|
+
end
|
|
64
142
|
end
|
data/minitest/dsl_test.rb
CHANGED
|
@@ -164,6 +164,33 @@ class DSLTest < Minitest::Test
|
|
|
164
164
|
assert_equal "btn btn--on extra", vnode.children[2].props[:class]
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
def test_style_value_behaves_like_the_string_it_wraps
|
|
168
|
+
klass = Class.new(Funicular::Component) do
|
|
169
|
+
styles do
|
|
170
|
+
field "field-base"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render
|
|
174
|
+
div(class: styles.field + " col-span-2") { "x" }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
vnode = klass.new.build_vdom
|
|
179
|
+
assert_equal "field-base col-span-2", vnode.props[:class]
|
|
180
|
+
|
|
181
|
+
value = klass.new.styles.field
|
|
182
|
+
combined = value + "!"
|
|
183
|
+
assert_kind_of Funicular::StyleValue, combined
|
|
184
|
+
assert_equal "field-base!", combined.to_s
|
|
185
|
+
assert_equal "field-basefield-base", (value + value).to_s
|
|
186
|
+
# Like String#+: non-String operands raise instead of being to_s-ed.
|
|
187
|
+
assert_raises(TypeError) { value + 123 }
|
|
188
|
+
# No to_str on purpose: the mruby client's String#+ never coerces,
|
|
189
|
+
# so CRuby must reject the reversed form the same way the browser
|
|
190
|
+
# does instead of letting SSR accept code the client raises on.
|
|
191
|
+
assert_raises(TypeError) { "prefix " + value }
|
|
192
|
+
end
|
|
193
|
+
|
|
167
194
|
def test_unknown_style_raises
|
|
168
195
|
klass = Class.new(Funicular::Component) do
|
|
169
196
|
styles do
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "funicular/epoch_header"
|
|
5
|
+
|
|
6
|
+
# Exercises Funicular::EpochHeader, the middleware that writes
|
|
7
|
+
# X-Funicular-Epoch on every outgoing response: from the env value the
|
|
8
|
+
# controller concern pinned, from the stored session entry when the
|
|
9
|
+
# request died before the concern ran, or not at all when neither
|
|
10
|
+
# exists.
|
|
11
|
+
class EpochHeaderTest < Minitest::Test
|
|
12
|
+
# Lowercase: the Rack 3 spec requires lowercase response header
|
|
13
|
+
# names, and HTTP header lookups are case-insensitive client-side.
|
|
14
|
+
HEADER = "x-funicular-epoch"
|
|
15
|
+
|
|
16
|
+
def setup
|
|
17
|
+
@config = Funicular::Configuration.new
|
|
18
|
+
@config.local_database = true
|
|
19
|
+
Funicular.instance_variable_set(:@configuration, @config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def teardown
|
|
23
|
+
Funicular.instance_variable_set(:@configuration, nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def app_returning(status, headers = {})
|
|
27
|
+
->(_env) { [status, headers, ["body"]] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_writes_the_env_pinned_epoch
|
|
31
|
+
middleware = Funicular::EpochHeader.new(app_returning(200))
|
|
32
|
+
_, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
|
|
33
|
+
assert_equal "e-ctrl", headers[HEADER]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_disabled_local_database_leaves_the_response_untouched
|
|
37
|
+
@config.local_database = false
|
|
38
|
+
headers = { HEADER => "inner", "X-Funicular-Epoch" => "legacy" }
|
|
39
|
+
middleware = Funicular::EpochHeader.new(app_returning(200, headers))
|
|
40
|
+
_, result, = middleware.call({ "funicular.epoch" => "outer" })
|
|
41
|
+
assert_same headers, result
|
|
42
|
+
assert_equal "inner", result[HEADER]
|
|
43
|
+
assert_equal "legacy", result["X-Funicular-Epoch"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_falls_back_to_the_stored_session_epoch
|
|
47
|
+
# The concern never ran (an earlier before_action raised, say),
|
|
48
|
+
# but the session already carries this app's epoch: stamped
|
|
49
|
+
# without rotation.
|
|
50
|
+
session = {
|
|
51
|
+
"funicular_epochs" => {
|
|
52
|
+
"funicular" => { "identity" => "i", "epoch" => "e-stored" },
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
middleware = Funicular::EpochHeader.new(app_returning(500))
|
|
56
|
+
_, headers, = middleware.call({ "rack.session" => session })
|
|
57
|
+
assert_equal "e-stored", headers[HEADER]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_the_env_value_wins_over_the_stored_one
|
|
61
|
+
session = {
|
|
62
|
+
"funicular_epochs" => {
|
|
63
|
+
"funicular" => { "identity" => "i", "epoch" => "e-stored" },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
middleware = Funicular::EpochHeader.new(app_returning(200))
|
|
67
|
+
_, headers, = middleware.call(
|
|
68
|
+
{ "funicular.epoch" => "e-ctrl", "rack.session" => session })
|
|
69
|
+
assert_equal "e-ctrl", headers[HEADER]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_without_any_epoch_the_header_stays_absent
|
|
73
|
+
middleware = Funicular::EpochHeader.new(app_returning(200))
|
|
74
|
+
_, headers, = middleware.call({})
|
|
75
|
+
refute headers.key?(HEADER)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_an_inner_header_is_dropped_when_no_epoch_is_available
|
|
79
|
+
# No env value and no session: the contract is "no authoritative
|
|
80
|
+
# epoch, no header". An inner layer's leftover would otherwise
|
|
81
|
+
# survive as the page's only epoch signal -- and one stamped
|
|
82
|
+
# before the session rotated would read as a MATCH, keeping a page
|
|
83
|
+
# alive that decision 13 wants terminal.
|
|
84
|
+
middleware = Funicular::EpochHeader.new(
|
|
85
|
+
app_returning(200, { HEADER => "stale-pre-login" }))
|
|
86
|
+
_, headers, = middleware.call({})
|
|
87
|
+
refute headers.key?(HEADER)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_a_legacy_cased_inner_header_is_dropped_without_an_epoch_too
|
|
91
|
+
middleware = Funicular::EpochHeader.new(
|
|
92
|
+
app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
|
|
93
|
+
_, headers, = middleware.call({})
|
|
94
|
+
refute headers.key?("X-Funicular-Epoch")
|
|
95
|
+
refute headers.key?(HEADER)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_respects_the_configured_application_id
|
|
99
|
+
@config.application_id = "second_app"
|
|
100
|
+
session = {
|
|
101
|
+
"funicular_epochs" => {
|
|
102
|
+
"funicular" => { "identity" => "i", "epoch" => "e-other" },
|
|
103
|
+
"second_app" => { "identity" => "i", "epoch" => "e-mine" },
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
middleware = Funicular::EpochHeader.new(app_returning(200))
|
|
107
|
+
_, headers, = middleware.call({ "rack.session" => session })
|
|
108
|
+
assert_equal "e-mine", headers[HEADER]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_overwrites_a_stale_inner_header
|
|
112
|
+
# An inner layer's header may have been stamped BEFORE a login/
|
|
113
|
+
# logout rotated the epoch; keeping it would let an old page apply
|
|
114
|
+
# the post-transition response as a match. The framework's value
|
|
115
|
+
# is authoritative.
|
|
116
|
+
middleware = Funicular::EpochHeader.new(
|
|
117
|
+
app_returning(200, { HEADER => "stale-pre-login" }))
|
|
118
|
+
_, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
|
|
119
|
+
assert_equal "e-ctrl", headers[HEADER]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_a_legacy_cased_inner_header_cannot_ride_out_as_a_duplicate
|
|
123
|
+
# Plain header hashes are case-sensitive: a stale capitalized
|
|
124
|
+
# spelling must be removed, not merely shadowed by our lowercase
|
|
125
|
+
# (Rack 3) name.
|
|
126
|
+
middleware = Funicular::EpochHeader.new(
|
|
127
|
+
app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
|
|
128
|
+
_, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
|
|
129
|
+
assert_equal "e-ctrl", headers[HEADER]
|
|
130
|
+
refute headers.key?("X-Funicular-Epoch")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_the_header_name_is_lowercase_for_rack_3
|
|
134
|
+
middleware = Funicular::EpochHeader.new(app_returning(200))
|
|
135
|
+
_, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
|
|
136
|
+
assert_equal ["x-funicular-epoch"], headers.keys
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_a_broken_session_read_never_masks_the_response
|
|
140
|
+
broken = Object.new
|
|
141
|
+
def broken.[](_key)
|
|
142
|
+
raise "session store exploded"
|
|
143
|
+
end
|
|
144
|
+
middleware = Funicular::EpochHeader.new(app_returning(500))
|
|
145
|
+
status, headers, = middleware.call({ "rack.session" => broken })
|
|
146
|
+
assert_equal 500, status
|
|
147
|
+
refute headers.key?(HEADER)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "funicular/epoch_stamping"
|
|
5
|
+
require "funicular/epoch_header"
|
|
6
|
+
|
|
7
|
+
# Exercises Funicular::EpochStamping (the controller mixin the Railtie
|
|
8
|
+
# installs) and its interplay with the EpochHeader middleware. The
|
|
9
|
+
# around_action stamps the epoch before the action AND re-stamps in
|
|
10
|
+
# its ensure with the post-action identity: a login/logout inside the
|
|
11
|
+
# action rotates on its own response, and a raising action still gets
|
|
12
|
+
# a truthful header on the exception page.
|
|
13
|
+
class EpochStampingTest < Minitest::Test
|
|
14
|
+
class FakeRequest
|
|
15
|
+
attr_reader :env, :session
|
|
16
|
+
|
|
17
|
+
def initialize(env = {}, session = nil)
|
|
18
|
+
@env = env
|
|
19
|
+
@session = session
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class FakeControllerBase
|
|
24
|
+
def self.around_action(name)
|
|
25
|
+
(@around_actions ||= []) << name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.around_actions
|
|
29
|
+
@around_actions || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :request, :session
|
|
33
|
+
|
|
34
|
+
def initialize(session, env = {})
|
|
35
|
+
@request = FakeRequest.new(env, session)
|
|
36
|
+
@session = session
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A minimal filter chain: the action block runs inside every
|
|
40
|
+
# registered around_action, exceptions propagating like Rails'.
|
|
41
|
+
def process(&action)
|
|
42
|
+
chain = self.class.around_actions.reverse.inject(action) do |inner, name|
|
|
43
|
+
-> { send(name) { inner.call } }
|
|
44
|
+
end
|
|
45
|
+
chain.call
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class StampedController < FakeControllerBase
|
|
50
|
+
include Funicular::EpochStamping
|
|
51
|
+
|
|
52
|
+
attr_accessor :current_user_key
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What request.session looks like in a session-less Rails API app:
|
|
56
|
+
# enabled? says no, every read and write raises.
|
|
57
|
+
class DisabledSession
|
|
58
|
+
def enabled?
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def [](_key)
|
|
63
|
+
raise "sessions are disabled in this application"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def []=(_key, _value)
|
|
67
|
+
raise "sessions are disabled in this application"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def setup
|
|
72
|
+
@config = Funicular::Configuration.new
|
|
73
|
+
@config.local_database = true
|
|
74
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
75
|
+
Funicular.instance_variable_set(:@configuration, @config)
|
|
76
|
+
@session = {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def teardown
|
|
80
|
+
Funicular.instance_variable_set(:@configuration, nil)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def session_epoch
|
|
84
|
+
entry = @session["funicular_epochs"]
|
|
85
|
+
entry ? entry["funicular"]["epoch"] : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Runs one fake request: the controller starts as initial_key, the
|
|
89
|
+
# action block may mutate it (login/logout), the env epoch is what
|
|
90
|
+
# the middleware would write.
|
|
91
|
+
def run_request(initial_key, env = {}, &action)
|
|
92
|
+
controller = StampedController.new(@session, env)
|
|
93
|
+
controller.current_user_key = initial_key
|
|
94
|
+
controller.process { action ? action.call(controller) : nil }
|
|
95
|
+
env["funicular.epoch"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_including_registers_the_around_action
|
|
99
|
+
assert_includes StampedController.around_actions, :stamp_funicular_epoch
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# An application may route an action named "session" (a schema
|
|
103
|
+
# endpoint, say): that action SHADOWS the controller's session
|
|
104
|
+
# accessor. The concern must read request.session and never notice.
|
|
105
|
+
# (Not a StampedController subclass: the fake around_action registry
|
|
106
|
+
# is a class ivar and does not inherit.)
|
|
107
|
+
class SessionShadowedController < FakeControllerBase
|
|
108
|
+
include Funicular::EpochStamping
|
|
109
|
+
|
|
110
|
+
attr_accessor :current_user_key
|
|
111
|
+
|
|
112
|
+
def session
|
|
113
|
+
raise "the session action was invoked instead of the accessor"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_the_stamp_never_calls_the_controller_session_accessor
|
|
118
|
+
env = {}
|
|
119
|
+
controller = SessionShadowedController.new(@session, env)
|
|
120
|
+
controller.current_user_key = "u1"
|
|
121
|
+
ran = false
|
|
122
|
+
controller.process { ran = true }
|
|
123
|
+
assert ran
|
|
124
|
+
refute_nil env["funicular.epoch"]
|
|
125
|
+
assert_equal session_epoch, env["funicular.epoch"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_disabled_local_database_does_not_touch_the_session_or_resolver
|
|
129
|
+
@config.local_database = false
|
|
130
|
+
@config.user_key = ->(_controller) { flunk "resolver was called" }
|
|
131
|
+
ran = false
|
|
132
|
+
session = {}
|
|
133
|
+
controller = StampedController.new(session)
|
|
134
|
+
controller.current_user_key = "u1"
|
|
135
|
+
controller.process { ran = true }
|
|
136
|
+
assert ran
|
|
137
|
+
assert_empty session
|
|
138
|
+
assert_nil controller.request.env["funicular.epoch"]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_the_epoch_lands_in_the_request_env
|
|
142
|
+
epoch = run_request("u1")
|
|
143
|
+
refute_nil epoch
|
|
144
|
+
refute_empty epoch
|
|
145
|
+
# Same user, same session: stable across requests.
|
|
146
|
+
assert_equal epoch, run_request("u1")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_the_env_value_matches_the_session_entry
|
|
150
|
+
epoch = run_request("u1")
|
|
151
|
+
assert_equal epoch, session_epoch
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def test_a_login_inside_the_action_rotates_on_its_own_response
|
|
155
|
+
# The page was rendered signed-out; the LOGIN action itself flips
|
|
156
|
+
# the identity mid-request. Its own response must already carry
|
|
157
|
+
# the rotated epoch -- with only a pre-action stamp, an old page
|
|
158
|
+
# would apply the login response as an epoch match and the
|
|
159
|
+
# rotation would be a request late.
|
|
160
|
+
anonymous = run_request(nil)
|
|
161
|
+
login_epoch = run_request(nil) do |controller|
|
|
162
|
+
controller.current_user_key = "u1"
|
|
163
|
+
end
|
|
164
|
+
refute_equal anonymous, login_epoch
|
|
165
|
+
assert_equal session_epoch, login_epoch
|
|
166
|
+
# And it holds on the user's next request.
|
|
167
|
+
assert_equal login_epoch, run_request("u1")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_a_logout_inside_the_action_rotates_on_its_own_response
|
|
171
|
+
signed_in = run_request("u1")
|
|
172
|
+
logout_epoch = run_request("u1") do |controller|
|
|
173
|
+
controller.current_user_key = nil
|
|
174
|
+
end
|
|
175
|
+
refute_equal signed_in, logout_epoch
|
|
176
|
+
assert_equal session_epoch, logout_epoch
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_a_disabled_session_skips_stamping_but_runs_the_action
|
|
180
|
+
# Session-less Rails API apps (no session middleware) must keep
|
|
181
|
+
# working with Funicular merely loaded: the epoch feature stays
|
|
182
|
+
# off instead of raising on every action.
|
|
183
|
+
ran = false
|
|
184
|
+
controller = StampedController.new(DisabledSession.new)
|
|
185
|
+
controller.current_user_key = "u1"
|
|
186
|
+
controller.process { ran = true }
|
|
187
|
+
assert ran
|
|
188
|
+
assert_nil controller.request.env["funicular.epoch"]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_an_enabled_session_object_still_stamps
|
|
192
|
+
enabled = Hash.new
|
|
193
|
+
def enabled.enabled?
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
controller = StampedController.new(enabled)
|
|
197
|
+
controller.current_user_key = "u1"
|
|
198
|
+
controller.process { nil }
|
|
199
|
+
refute_nil controller.request.env["funicular.epoch"]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def test_a_raising_action_still_produces_a_stamped_response
|
|
203
|
+
# The exception renderer builds a FRESH response none of the
|
|
204
|
+
# controller's headers survive into; the ensure re-stamp plus the
|
|
205
|
+
# middleware cover it -- with the POST-transition identity even
|
|
206
|
+
# when the action logged the user in before dying.
|
|
207
|
+
inner = lambda do |env|
|
|
208
|
+
controller = StampedController.new(@session, env)
|
|
209
|
+
controller.current_user_key = nil
|
|
210
|
+
begin
|
|
211
|
+
controller.process do
|
|
212
|
+
controller.current_user_key = "u1"
|
|
213
|
+
raise "boom in the action"
|
|
214
|
+
end
|
|
215
|
+
rescue RuntimeError
|
|
216
|
+
[500, {}, ["error page"]]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
status, headers, = Funicular::EpochHeader.new(inner).call({})
|
|
220
|
+
assert_equal 500, status
|
|
221
|
+
assert_equal session_epoch, headers["x-funicular-epoch"]
|
|
222
|
+
assert_equal '["v1","funicular","user","u1"]',
|
|
223
|
+
@session["funicular_epochs"]["funicular"]["identity"]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Reads both props and state so SSR.render_component tests can assert
|
|
2
|
+
# each channel is wired through. Not routed on purpose: rendering it
|
|
3
|
+
# is only possible through the single-component entry point.
|
|
4
|
+
class ProbeComponent < Funicular::Component
|
|
5
|
+
def initialize_state
|
|
6
|
+
{ note: "default note" }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def render
|
|
10
|
+
div(class: "probe") do
|
|
11
|
+
h2 { props[:label].to_s }
|
|
12
|
+
p { state[:note] }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
Funicular::SSR::Runtime.load_framework!
|
|
6
|
+
|
|
7
|
+
# Router-side navigation guard: a component may veto navigation with a
|
|
8
|
+
# confirmation message (mrblib logic exercised under CRuby; the browser
|
|
9
|
+
# integration is covered by test/navigation_guard_test.rb and the
|
|
10
|
+
# beforeunload listener).
|
|
11
|
+
class NavigationGuardTest < Minitest::Test
|
|
12
|
+
class FreeComponent
|
|
13
|
+
def navigation_guard
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class GuardedComponent
|
|
19
|
+
def navigation_guard
|
|
20
|
+
"Unsaved changes will be lost. Leave?"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def setup
|
|
25
|
+
@router = Funicular::Router.new(nil)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def teardown
|
|
29
|
+
Funicular.confirm_handler = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_leaving_is_allowed_without_a_current_component
|
|
33
|
+
assert_equal true, @router.leave_allowed?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_leaving_is_allowed_when_the_guard_returns_nil
|
|
37
|
+
@router.instance_variable_set(:@current_component, FreeComponent.new)
|
|
38
|
+
assert_equal true, @router.leave_allowed?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_guard_message_is_prompted_and_denial_blocks_leaving
|
|
42
|
+
@router.instance_variable_set(:@current_component, GuardedComponent.new)
|
|
43
|
+
asked = []
|
|
44
|
+
Funicular.confirm_handler = ->(message) { asked << message; false }
|
|
45
|
+
|
|
46
|
+
assert_equal false, @router.leave_allowed?
|
|
47
|
+
assert_equal [ "Unsaved changes will be lost. Leave?" ], asked
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_confirmation_allows_leaving
|
|
51
|
+
@router.instance_variable_set(:@current_component, GuardedComponent.new)
|
|
52
|
+
Funicular.confirm_handler = ->(_message) { true }
|
|
53
|
+
|
|
54
|
+
assert_equal true, @router.leave_allowed?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_confirm_defaults_to_true_on_the_server
|
|
58
|
+
# SSR must never block: without a handler, server mode allows leaving.
|
|
59
|
+
assert_equal true, Funicular.confirm("anything")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_component_guard_defaults_to_nil
|
|
63
|
+
assert_nil Funicular::Component.new.navigation_guard
|
|
64
|
+
end
|
|
65
|
+
end
|