funicular 0.3.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 +486 -1
- data/demo/local_notes.html +207 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +227 -12
- 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 +58 -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 +655 -574
- 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 +800 -530
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- 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 -6909
- 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/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- 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 +264 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- 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/sig_tags_test.rb +30 -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/minitest/view_context_test.rb +15 -15
- data/mrbgem.rake +2 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +226 -24
- data/mrblib/db.rb +3116 -0
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/form_builder.rb +10 -10
- 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 +122 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +25 -4
- data/sig/db.rbs +328 -0
- data/sig/error_boundary.rbs +4 -4
- 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 +19 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +23 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
|
@@ -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
|
|
@@ -3,12 +3,12 @@ class GreetingComponent < Funicular::Component
|
|
|
3
3
|
{ title: "Default Title", items: [] }
|
|
4
4
|
end
|
|
5
5
|
|
|
6
|
-
def render
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
def render
|
|
7
|
+
div(class: "greeting") do
|
|
8
|
+
h1 { state[:title] }
|
|
9
|
+
ul do
|
|
10
10
|
state[:items].each do |item|
|
|
11
|
-
|
|
11
|
+
li(key: item["id"]) { item["name"] }
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
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
|
data/minitest/form_for_test.rb
CHANGED
|
@@ -19,8 +19,8 @@ class FormForTest < Minitest::Test
|
|
|
19
19
|
@submitted = data
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def render
|
|
23
|
-
|
|
22
|
+
def render
|
|
23
|
+
form_for(:comment, on_submit: :handle_submit) do |f|
|
|
24
24
|
f.textarea(:body)
|
|
25
25
|
f.submit("Post")
|
|
26
26
|
end
|
data/minitest/hydration_test.rb
CHANGED
|
@@ -25,8 +25,8 @@ class HydrationMatchTest < Minitest::Test
|
|
|
25
25
|
# invoked here (we feed vnodes directly); it only satisfies the abstract API.
|
|
26
26
|
def probe
|
|
27
27
|
klass = Class.new(Funicular::Component) do
|
|
28
|
-
def render
|
|
29
|
-
|
|
28
|
+
def render
|
|
29
|
+
div { "x" }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def match?(vnode, dom)
|
|
@@ -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
|
|
@@ -63,6 +63,242 @@ class PicorubyHelperTest < Minitest::Test
|
|
|
63
63
|
assert_includes html, "defer"
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# --- page metadata (docs decisions 12/13) -----------------------------
|
|
67
|
+
|
|
68
|
+
def test_disabled_include_tag_omits_all_local_database_metadata
|
|
69
|
+
html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
70
|
+
refute_includes html, "data-funicular-local-database"
|
|
71
|
+
refute_includes html, "data-funicular-application-id"
|
|
72
|
+
refute_includes html, "data-funicular-user-key"
|
|
73
|
+
refute_includes html, "data-funicular-anonymous-only"
|
|
74
|
+
refute_includes html, "data-funicular-epoch"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_disabled_include_tag_does_not_call_the_identity_resolver_or_session
|
|
78
|
+
@config.user_key = ->(_controller) { flunk "resolver was called" }
|
|
79
|
+
session = DisabledSession.new
|
|
80
|
+
view = metadata_view(user_key: "u1", session: session)
|
|
81
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
82
|
+
refute_includes html, "data-funicular-local-database"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_include_tag_embeds_anonymous_only
|
|
86
|
+
@config.local_database = true
|
|
87
|
+
@config.anonymous_only = true
|
|
88
|
+
html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
89
|
+
assert_includes html, 'data-funicular-local-database="true"'
|
|
90
|
+
assert_includes html, 'data-funicular-application-id="funicular"'
|
|
91
|
+
assert_includes html, 'data-funicular-anonymous-only="true"'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_enabled_include_tag_requires_an_identity_declaration
|
|
95
|
+
@config.local_database = true
|
|
96
|
+
error = assert_raises(ArgumentError) do
|
|
97
|
+
@view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
98
|
+
end
|
|
99
|
+
assert_includes error.message, "user_key"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A harness with the controller/request surface a real view has. The
|
|
103
|
+
# helper reads the session through request.session (the view's own
|
|
104
|
+
# session helper delegates to the controller, where an action named
|
|
105
|
+
# "session" shadows it).
|
|
106
|
+
class MetadataHarness < Harness
|
|
107
|
+
attr_accessor :controller, :request
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
FakeController = Struct.new(:current_user_key)
|
|
111
|
+
FakeViewRequest = Struct.new(:session)
|
|
112
|
+
|
|
113
|
+
def metadata_view(user_key: nil, session: {})
|
|
114
|
+
view = MetadataHarness.new
|
|
115
|
+
view.controller = FakeController.new(user_key)
|
|
116
|
+
view.request = FakeViewRequest.new(session)
|
|
117
|
+
view
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A view rendered by a controller that routes an action named
|
|
121
|
+
# "session": the view's session delegate would invoke that action.
|
|
122
|
+
# The helper must go through request.session and never notice.
|
|
123
|
+
class SessionShadowedHarness < MetadataHarness
|
|
124
|
+
def session
|
|
125
|
+
raise "the session delegate was invoked instead of request.session"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def test_the_helper_never_calls_the_view_session_delegate
|
|
130
|
+
@config.local_database = true
|
|
131
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
132
|
+
view = SessionShadowedHarness.new
|
|
133
|
+
view.controller = FakeController.new("u1")
|
|
134
|
+
view.request = FakeViewRequest.new({})
|
|
135
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
136
|
+
assert_includes html, "data-funicular-epoch"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_include_tag_embeds_user_key_and_epoch
|
|
140
|
+
@config.local_database = true
|
|
141
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
142
|
+
session = {}
|
|
143
|
+
view = metadata_view(user_key: "u1", session: session)
|
|
144
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
145
|
+
assert_includes html, 'data-funicular-user-key="u1"'
|
|
146
|
+
assert_includes html, 'data-funicular-user-key-configured="true"'
|
|
147
|
+
# The epoch on the page is THE session entry the response header
|
|
148
|
+
# stamps: page and responses cannot disagree at render time.
|
|
149
|
+
epoch = session["funicular_epochs"]["funicular"]["epoch"]
|
|
150
|
+
assert_includes html, %(data-funicular-epoch="#{epoch}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What request.session looks like in a session-less Rails API app.
|
|
154
|
+
class DisabledSession
|
|
155
|
+
def enabled?
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def [](_key)
|
|
160
|
+
raise "sessions are disabled in this application"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def []=(_key, _value)
|
|
164
|
+
raise "sessions are disabled in this application"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_a_disabled_session_skips_the_epoch_without_breaking
|
|
169
|
+
@config.local_database = true
|
|
170
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
171
|
+
view = metadata_view(user_key: "u1", session: DisabledSession.new)
|
|
172
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
173
|
+
refute_includes html, "data-funicular-epoch"
|
|
174
|
+
assert_includes html, 'data-funicular-user-key="u1"'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_the_resolver_feeds_attribute_and_epoch_from_one_evaluation
|
|
178
|
+
# A racy resolver (current_user changing between two calls) must
|
|
179
|
+
# not embed user A's namespace while stamping user B's identity
|
|
180
|
+
# into the epoch entry: the helper evaluates it exactly once.
|
|
181
|
+
@config.local_database = true
|
|
182
|
+
calls = 0
|
|
183
|
+
@config.user_key = lambda do |_controller|
|
|
184
|
+
calls += 1
|
|
185
|
+
"u#{calls}"
|
|
186
|
+
end
|
|
187
|
+
session = {}
|
|
188
|
+
view = metadata_view(user_key: "ignored", session: session)
|
|
189
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
190
|
+
assert_equal 1, calls
|
|
191
|
+
assert_includes html, 'data-funicular-user-key="u1"'
|
|
192
|
+
assert_equal '["v1","funicular","user","u1"]',
|
|
193
|
+
session["funicular_epochs"]["funicular"]["identity"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def test_signed_out_omits_the_user_key_attribute
|
|
197
|
+
@config.local_database = true
|
|
198
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
199
|
+
view = metadata_view(user_key: nil)
|
|
200
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
201
|
+
refute_includes html, "data-funicular-user-key="
|
|
202
|
+
assert_includes html, 'data-funicular-user-key-configured="true"'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def test_metadata_values_are_html_escaped
|
|
206
|
+
@config.local_database = true
|
|
207
|
+
@config.application_id = %q{"><script>alert(1)</script>}
|
|
208
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
209
|
+
view = metadata_view(user_key: %q{"><img src=x>})
|
|
210
|
+
html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
|
|
211
|
+
refute_includes html, "<script>alert(1)</script>"
|
|
212
|
+
refute_includes html, "<img src=x>"
|
|
213
|
+
assert_includes html, "">"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def test_caller_data_attributes_survive_alongside_the_metadata
|
|
217
|
+
@config.local_database = true
|
|
218
|
+
@config.anonymous_only = true
|
|
219
|
+
html = @view.picoruby_include_tag(source: :local_dist, base_styles: false,
|
|
220
|
+
data: { turbo_track: "reload" })
|
|
221
|
+
assert_includes html, 'data-turbo-track="reload"'
|
|
222
|
+
assert_includes html, 'data-funicular-application-id="funicular"'
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_caller_data_cannot_override_the_framework_metadata
|
|
226
|
+
# A data: override of the epoch or the user key would make the
|
|
227
|
+
# page disagree with its own responses (instant terminal) or boot
|
|
228
|
+
# the wrong namespace: the framework's values win, in every key
|
|
229
|
+
# spelling that would render as the same HTML attribute.
|
|
230
|
+
@config.local_database = true
|
|
231
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
232
|
+
session = {}
|
|
233
|
+
view = metadata_view(user_key: "u1", session: session)
|
|
234
|
+
html = view.picoruby_include_tag(
|
|
235
|
+
source: :local_dist, base_styles: false,
|
|
236
|
+
data: { funicular_epoch: "evil-symbol",
|
|
237
|
+
"funicular_user_key" => "evil-string",
|
|
238
|
+
"funicular-application-id" => "evil-dashed",
|
|
239
|
+
turbo_track: "reload" })
|
|
240
|
+
refute_includes html, "evil-symbol"
|
|
241
|
+
refute_includes html, "evil-string"
|
|
242
|
+
refute_includes html, "evil-dashed"
|
|
243
|
+
assert_includes html, 'data-funicular-user-key="u1"'
|
|
244
|
+
assert_includes html, 'data-funicular-application-id="funicular"'
|
|
245
|
+
epoch = session["funicular_epochs"]["funicular"]["epoch"]
|
|
246
|
+
assert_includes html, %(data-funicular-epoch="#{epoch}")
|
|
247
|
+
# The caller's unrelated attribute still rides along, and no
|
|
248
|
+
# attribute is emitted twice.
|
|
249
|
+
assert_includes html, 'data-turbo-track="reload"'
|
|
250
|
+
assert_equal 1, html.scan("data-funicular-epoch=").size
|
|
251
|
+
assert_equal 1, html.scan("data-funicular-user-key=").size
|
|
252
|
+
assert_equal 1, html.scan("data-funicular-application-id=").size
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def test_caller_data_cannot_enable_the_disabled_local_database
|
|
256
|
+
html = @view.picoruby_include_tag(
|
|
257
|
+
source: :local_dist, base_styles: false,
|
|
258
|
+
data: { funicular_local_database: "true",
|
|
259
|
+
"funicular-application-id" => "injected",
|
|
260
|
+
funicular_anonymous_only: "true" })
|
|
261
|
+
refute_includes html, "data-funicular-local-database"
|
|
262
|
+
refute_includes html, "data-funicular-application-id"
|
|
263
|
+
refute_includes html, "data-funicular-anonymous-only"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def test_top_level_options_cannot_override_the_framework_metadata
|
|
267
|
+
# The contract values can bypass the data: hash entirely and arrive
|
|
268
|
+
# as top-level options, in any spelling the tag builder accepts.
|
|
269
|
+
@config.local_database = true
|
|
270
|
+
@config.user_key = ->(controller) { controller.current_user_key }
|
|
271
|
+
session = {}
|
|
272
|
+
view = metadata_view(user_key: "u1", session: session)
|
|
273
|
+
html = view.picoruby_include_tag(
|
|
274
|
+
source: :local_dist, base_styles: false,
|
|
275
|
+
**{ "data-funicular-epoch" => "evil-dashed",
|
|
276
|
+
:"data-funicular-user-key" => "evil-symbol",
|
|
277
|
+
data_funicular_application_id: "evil-underscored",
|
|
278
|
+
"data-turbo-track" => "reload" })
|
|
279
|
+
refute_includes html, "evil-dashed"
|
|
280
|
+
refute_includes html, "evil-symbol"
|
|
281
|
+
refute_includes html, "evil-underscored"
|
|
282
|
+
assert_includes html, 'data-funicular-user-key="u1"'
|
|
283
|
+
assert_includes html, 'data-funicular-application-id="funicular"'
|
|
284
|
+
epoch = session["funicular_epochs"]["funicular"]["epoch"]
|
|
285
|
+
assert_includes html, %(data-funicular-epoch="#{epoch}")
|
|
286
|
+
# Unrelated top-level data attributes are the caller's business.
|
|
287
|
+
assert_includes html, 'data-turbo-track="reload"'
|
|
288
|
+
assert_equal 1, html.scan("data-funicular-epoch=").size
|
|
289
|
+
assert_equal 1, html.scan("data-funicular-user-key=").size
|
|
290
|
+
assert_equal 1, html.scan("data-funicular-application-id=").size
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def test_top_level_options_cannot_enable_the_disabled_local_database
|
|
294
|
+
html = @view.picoruby_include_tag(
|
|
295
|
+
source: :local_dist, base_styles: false,
|
|
296
|
+
**{ "data-funicular-local-database" => "true",
|
|
297
|
+
data_funicular_application_id: "injected" })
|
|
298
|
+
refute_includes html, "data-funicular-local-database"
|
|
299
|
+
refute_includes html, "injected"
|
|
300
|
+
end
|
|
301
|
+
|
|
66
302
|
def test_cdn_source_uses_versioned_jsdelivr_url
|
|
67
303
|
@config.cdn_version = "1.2.3"
|
|
68
304
|
html = @view.picoruby_include_tag(source: :cdn, base_styles: false)
|
data/minitest/schema_test.rb
CHANGED
|
@@ -88,6 +88,26 @@ class SchemaDerivationTest < Minitest::Test
|
|
|
88
88
|
assert_equal({ "update" => { method: "PATCH", path: "/x/:id" } }, schema[:endpoints])
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def test_build_skips_validators_for_readonly_attributes
|
|
92
|
+
schema = Funicular::Schema.build(
|
|
93
|
+
Account,
|
|
94
|
+
attributes: {
|
|
95
|
+
# name has a presence validator, but a readonly (server-managed)
|
|
96
|
+
# attribute is never submitted by the client, so its validators
|
|
97
|
+
# must not be inlined at all.
|
|
98
|
+
"name" => { type: "string", readonly: true }
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
assert_equal({ type: "string", readonly: true }, schema[:attributes]["name"])
|
|
102
|
+
|
|
103
|
+
# String keys behave the same.
|
|
104
|
+
schema = Funicular::Schema.build(
|
|
105
|
+
Account,
|
|
106
|
+
attributes: { "name" => { "type" => "string", "readonly" => true } }
|
|
107
|
+
)
|
|
108
|
+
refute schema[:attributes]["name"].key?(:validations)
|
|
109
|
+
end
|
|
110
|
+
|
|
91
111
|
def test_length_range_is_serialized_as_min_and_max
|
|
92
112
|
klass = Class.new do
|
|
93
113
|
include ActiveModel::Validations
|
|
@@ -99,6 +119,33 @@ class SchemaDerivationTest < Minitest::Test
|
|
|
99
119
|
assert_equal({ "minimum" => 2, "maximum" => 10 }, result["bio"]["length"])
|
|
100
120
|
end
|
|
101
121
|
|
|
122
|
+
def test_bare_numericality_keeps_allow_nil
|
|
123
|
+
# numericality with no constraints serializes to a bare true; the
|
|
124
|
+
# allow_nil flag must upgrade it to a Hash instead of being dropped.
|
|
125
|
+
klass = Class.new do
|
|
126
|
+
include ActiveModel::Validations
|
|
127
|
+
def self.name = "Aged"
|
|
128
|
+
attr_accessor :age
|
|
129
|
+
validates :age, numericality: true, allow_nil: true
|
|
130
|
+
end
|
|
131
|
+
result = Funicular::Schema.validations_for(klass, ["age"])
|
|
132
|
+
assert_equal({ "allow_nil" => true }, result["age"]["numericality"])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_format_unescapes_ruby_hash_escape
|
|
136
|
+
# Ruby regexp literals escape "#" to suppress interpolation and the escape
|
|
137
|
+
# survives in Regexp#source; JS RegExp under the u flag rejects "\#" as an
|
|
138
|
+
# invalid identity escape (URI::MailTo::EMAIL_REGEXP is the wild example).
|
|
139
|
+
klass = Class.new do
|
|
140
|
+
include ActiveModel::Validations
|
|
141
|
+
def self.name = "Mailish"
|
|
142
|
+
attr_accessor :email
|
|
143
|
+
validates :email, format: { with: /\A[a-z.!\#$%&]+\z/ }
|
|
144
|
+
end
|
|
145
|
+
result = Funicular::Schema.validations_for(klass, ["email"])
|
|
146
|
+
assert_equal "^[a-z.!#$%&]+$", result["email"]["format"]["with"]
|
|
147
|
+
end
|
|
148
|
+
|
|
102
149
|
def test_format_multiline_flag_is_translated
|
|
103
150
|
klass = Class.new do
|
|
104
151
|
include ActiveModel::Validations
|