ruact 0.0.2 → 0.0.4
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 8.1 — full-request-cycle spec covering POST /__ruact/fn/:name dispatch.
|
|
4
|
+
#
|
|
5
|
+
# Boots a minimal Rails::Application with `Ruact::Railtie` (which mounts the
|
|
6
|
+
# `ruact/server_functions/endpoint#dispatch_action` route) and exercises:
|
|
7
|
+
# - Registry lookup (known + unknown name)
|
|
8
|
+
# - JSON request body → `params` shadow inside the block
|
|
9
|
+
# - FormData / urlencoded body → same shadow
|
|
10
|
+
# - host controller's `before_action` chain runs before the block
|
|
11
|
+
# - block return value rendered as JSON
|
|
12
|
+
# - rescue_from on the host controller catches errors raised inside the block
|
|
13
|
+
#
|
|
14
|
+
# Follows the Story 7.9 pattern (controller_request_spec.rb) — request-cycle
|
|
15
|
+
# subsystems are loaded HERE so the rest of the suite continues to use
|
|
16
|
+
# spec/support/rails_stub.rb without paying the full Rails boot cost.
|
|
17
|
+
require "action_controller/railtie"
|
|
18
|
+
require "action_view/railtie"
|
|
19
|
+
|
|
20
|
+
require "spec_helper"
|
|
21
|
+
require "rack/test"
|
|
22
|
+
require "tempfile"
|
|
23
|
+
|
|
24
|
+
require "ruact/controller"
|
|
25
|
+
require "ruact/server_functions/endpoint_controller"
|
|
26
|
+
|
|
27
|
+
# Re-run-5 (2026-05-15) — explicitly require `ruact/railtie` (NOT just
|
|
28
|
+
# `ruact`, which is cached as already-loaded by `spec_helper.rb`'s
|
|
29
|
+
# earlier `require "ruact"` that ran BEFORE Rails was defined and
|
|
30
|
+
# therefore skipped the conditional `require_relative "ruact/railtie"
|
|
31
|
+
# if defined?(Rails)` at the bottom of `ruact.rb`). Loading the
|
|
32
|
+
# Railtie file directly registers `Ruact::Railtie` with Rails so its
|
|
33
|
+
# `routes.prepend` AND `config.to_prepare` initializers (latter wires
|
|
34
|
+
# `Ruact::ErbPreprocessorHook` into `ActionView::Template::Handlers::ERB`)
|
|
35
|
+
# fire when the test app's `initialize!` runs.
|
|
36
|
+
require "ruact/railtie"
|
|
37
|
+
|
|
38
|
+
# Re-run-2 (2026-05-14): exercise the REAL `ActiveRecord::RecordInvalid`
|
|
39
|
+
# rather than a structural stub. ActiveRecord is part of Rails (already a
|
|
40
|
+
# dev dep via `gem "rails"`), so requiring `active_model` for the underlying
|
|
41
|
+
# validation error and `active_record` for `RecordInvalid` is cheap. The
|
|
42
|
+
# require is local to this spec — the rest of the suite continues to use
|
|
43
|
+
# the lightweight rails_stub.rb path.
|
|
44
|
+
require "active_model"
|
|
45
|
+
require "active_record"
|
|
46
|
+
require "i18n"
|
|
47
|
+
|
|
48
|
+
# Load ActiveModel + ActiveRecord locale files so `RecordInvalid#message`
|
|
49
|
+
# resolves the `errors.messages.record_invalid` translation key — without
|
|
50
|
+
# this, `error.message` returns "Translation missing: en.activemodel.errors..."
|
|
51
|
+
# instead of the human-readable "Validation failed: Title can't be blank".
|
|
52
|
+
{
|
|
53
|
+
"activemodel" => "active_model",
|
|
54
|
+
"activerecord" => "active_record"
|
|
55
|
+
}.each do |gem_name, dir|
|
|
56
|
+
spec = Gem.loaded_specs[gem_name]
|
|
57
|
+
next unless spec
|
|
58
|
+
|
|
59
|
+
locale_file = File.join(spec.gem_dir, "lib", dir, "locale", "en.yml")
|
|
60
|
+
I18n.load_path << locale_file if File.exist?(locale_file)
|
|
61
|
+
end
|
|
62
|
+
I18n.backend.load_translations
|
|
63
|
+
|
|
64
|
+
# Reuse the Rails::Application booted by `controller_request_spec.rb` if it
|
|
65
|
+
# has already been loaded into this RSpec process — Rails does not support
|
|
66
|
+
# two distinct `Rails::Application` subclasses initialized in the same
|
|
67
|
+
# process (the second `initialize!` raises `FrozenError` on shared internal
|
|
68
|
+
# state). When this spec runs alone, build a dedicated minimal app.
|
|
69
|
+
require_relative "../controller_request_spec" if defined?(Rails::Application) &&
|
|
70
|
+
!defined?(ControllerRequestSpecSupport)
|
|
71
|
+
|
|
72
|
+
# Re-run-5 (2026-05-15) — when reusing the Story 7.9 test app, append
|
|
73
|
+
# the gem's `POST /__ruact/fn/:name` route AT LOAD TIME (before any
|
|
74
|
+
# test runs). This is the only safe window: once `initialize!` has run
|
|
75
|
+
# for the app (driven by EITHER spec's first `boot!`), adding routes
|
|
76
|
+
# post-finalization is unreliable. Doing it here, at spec file load,
|
|
77
|
+
# guarantees the route lands BEFORE either spec calls `boot!`.
|
|
78
|
+
# Review F10 (2026-05-19 re-review) — idempotence guard. This file's
|
|
79
|
+
# top-level block can be re-executed when RSpec's runner `Kernel.load`s the
|
|
80
|
+
# file from an explicit `rspec <files>...` invocation that ALSO names
|
|
81
|
+
# `endpoint_controller_rescue_spec.rb` (whose `require_relative` already
|
|
82
|
+
# loaded this file once). Without the guard, the second pass calls
|
|
83
|
+
# `routes.append` against the same name and Rails raises
|
|
84
|
+
# `ArgumentError: Invalid route name, already in use: 'ruact_server_function_spec'`.
|
|
85
|
+
# The flag lives on `ControllerRequestSpecSupport` (not on a top-level
|
|
86
|
+
# constant) so it ties the dedupe to the actual Rails app the route is
|
|
87
|
+
# attached to.
|
|
88
|
+
if defined?(ControllerRequestSpecSupport) &&
|
|
89
|
+
!ControllerRequestSpecSupport.instance_variable_get(:@__ruact_endpoint_route_appended)
|
|
90
|
+
ControllerRequestSpecSupport.instance_variable_set(:@__ruact_endpoint_route_appended, true)
|
|
91
|
+
ControllerRequestSpecSupport.app_class.routes.append do
|
|
92
|
+
post "/__ruact/fn/:name",
|
|
93
|
+
to: "ruact/server_functions/endpoint#dispatch_action",
|
|
94
|
+
as: :ruact_server_function_spec,
|
|
95
|
+
constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
require "ruact/server_action"
|
|
100
|
+
|
|
101
|
+
module DispatchRequestSpecSupport
|
|
102
|
+
class << self
|
|
103
|
+
def app_class
|
|
104
|
+
@app_class ||=
|
|
105
|
+
if defined?(ControllerRequestSpecSupport)
|
|
106
|
+
ControllerRequestSpecSupport.app_class
|
|
107
|
+
else
|
|
108
|
+
build_app_class
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def boot!
|
|
113
|
+
return if @booted
|
|
114
|
+
|
|
115
|
+
if defined?(ControllerRequestSpecSupport)
|
|
116
|
+
ControllerRequestSpecSupport.boot!
|
|
117
|
+
else
|
|
118
|
+
# Re-run-5 — when this spec runs standalone (no Story 7.9 app
|
|
119
|
+
# in the process), draw the gem route on its own app BEFORE
|
|
120
|
+
# initialize! so dispatch tests find it.
|
|
121
|
+
app_class.routes.append do
|
|
122
|
+
post "/__ruact/fn/:name",
|
|
123
|
+
to: "ruact/server_functions/endpoint#dispatch_action",
|
|
124
|
+
as: :ruact_server_function_standalone,
|
|
125
|
+
constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
|
|
126
|
+
end
|
|
127
|
+
app_class.instance.initialize!
|
|
128
|
+
end
|
|
129
|
+
@booted = true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def build_app_class
|
|
135
|
+
Class.new(Rails::Application) do
|
|
136
|
+
config.eager_load = false
|
|
137
|
+
config.consider_all_requests_local = true
|
|
138
|
+
config.action_controller.perform_caching = false
|
|
139
|
+
config.action_dispatch.show_exceptions = :none
|
|
140
|
+
config.logger = Logger.new(IO::NULL)
|
|
141
|
+
config.active_support.deprecation = :silence
|
|
142
|
+
config.secret_key_base = "x" * 64
|
|
143
|
+
config.hosts.clear if config.respond_to?(:hosts)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Minimal ActiveModel-compatible record class so a REAL
|
|
149
|
+
# `ActiveRecord::RecordInvalid` instance can be constructed (the error's
|
|
150
|
+
# `#initialize(record)` reads `record.errors.full_messages`). This keeps
|
|
151
|
+
# the AC9 spec faithful to the literal AC wording while not requiring a
|
|
152
|
+
# full ActiveRecord schema/setup.
|
|
153
|
+
class StubPost
|
|
154
|
+
include ActiveModel::Model
|
|
155
|
+
|
|
156
|
+
attr_accessor :title
|
|
157
|
+
|
|
158
|
+
validates :title, presence: true
|
|
159
|
+
|
|
160
|
+
# Override `i18n_scope` to `:activerecord` so `RecordInvalid#message`
|
|
161
|
+
# resolves the `activerecord.errors.messages.record_invalid` key
|
|
162
|
+
# ("Validation failed: %{errors}") instead of falling through to the
|
|
163
|
+
# missing `activemodel.errors.messages.record_invalid`.
|
|
164
|
+
def self.i18n_scope
|
|
165
|
+
:activerecord
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class TestController < ActionController::Base
|
|
170
|
+
include Ruact::Controller
|
|
171
|
+
|
|
172
|
+
rescue_from RuntimeError do |error|
|
|
173
|
+
render(json: { error: error.message, error_class: error.class.name }, status: :unprocessable_entity)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
rescue_from ActiveRecord::RecordInvalid do |error|
|
|
177
|
+
render(
|
|
178
|
+
json: { error: error.message, error_class: error.class.name, validation: true },
|
|
179
|
+
status: :unprocessable_entity
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
before_action :require_token, only: %i[authed_action]
|
|
184
|
+
|
|
185
|
+
# Re-run-3 (2026-05-15) — simulates a host before_action that touches
|
|
186
|
+
# `request.body` (e.g., a generic audit/logging filter that reads the
|
|
187
|
+
# raw POST body for signature verification). Pre-batch, `body.read`
|
|
188
|
+
# advanced the IO to EOF, so the action's own `body.read` returned
|
|
189
|
+
# `""` and silently coerced the action call to `{}`. The fix uses
|
|
190
|
+
# `request.raw_post` (Rack-cached) so the action still sees the
|
|
191
|
+
# original body. The `body_peek` action below proves it.
|
|
192
|
+
before_action :peek_body, only: %i[body_peek]
|
|
193
|
+
def peek_body
|
|
194
|
+
@peeked = request.body.read.tap { request.body.rewind if request.body.respond_to?(:rewind) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# spec_helper wipes the registries between examples (lazy-init singletons
|
|
198
|
+
# reset to fresh instances), so the controller's class-body `ruact_action`
|
|
199
|
+
# declarations would only populate the original singleton — invisible to
|
|
200
|
+
# the new one a subsequent example sees. Re-register at the start of every
|
|
201
|
+
# example via this class method instead.
|
|
202
|
+
def self.register_ruact_actions!
|
|
203
|
+
ruact_action(:echo) { |params| { "echoed" => params.to_unsafe_h } }
|
|
204
|
+
|
|
205
|
+
ruact_action(:fail_hard) { |_params| raise "intentional failure" }
|
|
206
|
+
|
|
207
|
+
ruact_action(:authed_action) { |params| { "ok" => true, "by" => params[:by] } }
|
|
208
|
+
|
|
209
|
+
ruact_action(:capture_both) do |params|
|
|
210
|
+
{
|
|
211
|
+
"block_params" => params.to_unsafe_h,
|
|
212
|
+
"request_params_name" => self.params[:name]
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
ruact_action(:strong_params_demo) do |params|
|
|
217
|
+
permitted = params.require(:post).permit(:title, :body)
|
|
218
|
+
{ "permitted" => permitted.to_h }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
ruact_action(:nil_return) { |_p| nil }
|
|
222
|
+
|
|
223
|
+
ruact_action(:invalid_record) do |_p|
|
|
224
|
+
record = DispatchRequestSpecSupport::StubPost.new
|
|
225
|
+
record.valid? # populates record.errors
|
|
226
|
+
raise ActiveRecord::RecordInvalid, record
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
ruact_action(:body_peek) do |params|
|
|
230
|
+
{ "echoed" => params.to_unsafe_h }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
ruact_action(:routing_identity) do |_p|
|
|
234
|
+
# Re-run-2 (2026-05-14) — proves that `params[:controller]` and
|
|
235
|
+
# `params[:action]` inside the host action describe the HOST class,
|
|
236
|
+
# not the gem endpoint route.
|
|
237
|
+
{
|
|
238
|
+
"controller" => controller_path,
|
|
239
|
+
"action" => action_name,
|
|
240
|
+
"params_controller" => self.params[:controller],
|
|
241
|
+
"params_action" => self.params[:action]
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def require_token
|
|
249
|
+
return if request.headers["X-Test-Token"] == "secret"
|
|
250
|
+
|
|
251
|
+
render(json: { error: "unauthorized" }, status: :unauthorized)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
Rails.application = nil if Rails.respond_to?(:application) && !Rails.application.is_a?(Rails::Application)
|
|
257
|
+
|
|
258
|
+
RSpec.describe "Story 8.1: POST /__ruact/fn/:name dispatch", :story_8_1 do
|
|
259
|
+
include Rack::Test::Methods
|
|
260
|
+
|
|
261
|
+
let(:app_class) { DispatchRequestSpecSupport.app_class }
|
|
262
|
+
let(:app) { app_class.instance }
|
|
263
|
+
|
|
264
|
+
before do
|
|
265
|
+
Rails.logger = Logger.new(IO::NULL)
|
|
266
|
+
DispatchRequestSpecSupport.boot!
|
|
267
|
+
# spec_helper resets the registry singletons between examples — re-register
|
|
268
|
+
# so the endpoint controller can resolve the test action names. (Production
|
|
269
|
+
# gets re-registrations naturally from class-body evaluation at controller
|
|
270
|
+
# autoload; the test environment short-circuits that.)
|
|
271
|
+
DispatchRequestSpecSupport::TestController.register_ruact_actions!
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
describe "AC2 — happy-path dispatch" do
|
|
275
|
+
it "dispatches a registered action with JSON body and returns the block's return value as JSON" do
|
|
276
|
+
post "/__ruact/fn/echo", { "title" => "Hi" }.to_json, { "CONTENT_TYPE" => "application/json" }
|
|
277
|
+
expect(last_response.status).to eq(200)
|
|
278
|
+
expect(last_response.headers["Content-Type"]).to include("application/json")
|
|
279
|
+
expect(JSON.parse(last_response.body)).to eq("echoed" => { "title" => "Hi" })
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "returns 404 with a structured error for an unknown action name" do
|
|
283
|
+
post "/__ruact/fn/no_such_thing", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
284
|
+
expect(last_response.status).to eq(404)
|
|
285
|
+
expect(JSON.parse(last_response.body)).to eq("error" => "unknown ruact action: :no_such_thing")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it "accepts form-encoded request bodies (FormData / urlencoded)" do
|
|
289
|
+
post "/__ruact/fn/echo", { "title" => "From form" }
|
|
290
|
+
expect(last_response.status).to eq(200)
|
|
291
|
+
body = JSON.parse(last_response.body)
|
|
292
|
+
expect(body.fetch("echoed")).to include("title" => "From form")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "treats an empty request body as an empty params hash" do
|
|
296
|
+
post "/__ruact/fn/echo", "", { "CONTENT_TYPE" => "application/json" }
|
|
297
|
+
expect(last_response.status).to eq(200)
|
|
298
|
+
expect(JSON.parse(last_response.body)).to eq("echoed" => {})
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "preserves form-encoded body fields named `name`, `action`, `controller` " \
|
|
302
|
+
"(review-batch 2 — drop spurious `.except`)" do
|
|
303
|
+
post "/__ruact/fn/echo", { "name" => "alice", "action" => "submit", "controller" => "foo" }
|
|
304
|
+
expect(last_response.status).to eq(200)
|
|
305
|
+
body = JSON.parse(last_response.body)
|
|
306
|
+
expect(body.fetch("echoed")).to include(
|
|
307
|
+
"name" => "alice",
|
|
308
|
+
"action" => "submit",
|
|
309
|
+
"controller" => "foo"
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "returns a structured 400 on malformed JSON instead of silently treating it as {} " \
|
|
314
|
+
"(re-run-4 #1 — structured bad-request response, not raw JSON::ParserError)" do
|
|
315
|
+
# Pre-Re-run-4 this surfaced a raw `JSON::ParserError` from inside
|
|
316
|
+
# the action body. Now `ruact_action`'s defined method catches the
|
|
317
|
+
# parse error and renders a 400 with a `{error}` JSON body — same
|
|
318
|
+
# contract as the unknown-action 404 path.
|
|
319
|
+
post "/__ruact/fn/echo", "{ not json", { "CONTENT_TYPE" => "application/json" }
|
|
320
|
+
expect(last_response.status).to eq(400)
|
|
321
|
+
body = JSON.parse(last_response.body)
|
|
322
|
+
expect(body.fetch("error")).to match(/malformed JSON body/)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
describe "AC3 — before_action chain runs before the block" do
|
|
327
|
+
it "the host's before_action short-circuits without ever executing the block" do
|
|
328
|
+
post "/__ruact/fn/authed_action",
|
|
329
|
+
{ "by" => "alice" }.to_json,
|
|
330
|
+
{ "CONTENT_TYPE" => "application/json" }
|
|
331
|
+
expect(last_response.status).to eq(401)
|
|
332
|
+
expect(JSON.parse(last_response.body)).to eq("error" => "unauthorized")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "the block runs only when the before_action passes" do
|
|
336
|
+
post "/__ruact/fn/authed_action",
|
|
337
|
+
{ "by" => "alice" }.to_json,
|
|
338
|
+
{ "CONTENT_TYPE" => "application/json", "HTTP_X_TEST_TOKEN" => "secret" }
|
|
339
|
+
expect(last_response.status).to eq(200)
|
|
340
|
+
expect(JSON.parse(last_response.body)).to eq("ok" => true, "by" => "alice")
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
describe "AC5 — params shadow inside the block" do
|
|
345
|
+
it "the block's `params` argument carries the action-call args; the controller's " \
|
|
346
|
+
"`params` accessor carries the routing data (controller/action)" do
|
|
347
|
+
# Re-run-4 (#2): `:name` is no longer injected into
|
|
348
|
+
# `request.path_parameters` (would shadow a legitimate body field
|
|
349
|
+
# named `:name`). The block's `params` is the body; the controller's
|
|
350
|
+
# `params` carries `:controller` and `:action` (Rails routing data).
|
|
351
|
+
post "/__ruact/fn/capture_both",
|
|
352
|
+
{ "title" => "From body" }.to_json,
|
|
353
|
+
{ "CONTENT_TYPE" => "application/json" }
|
|
354
|
+
body = JSON.parse(last_response.body)
|
|
355
|
+
expect(body.fetch("block_params")).to eq("title" => "From body")
|
|
356
|
+
expect(body.fetch("request_params_name")).to be_nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it "preserves a body field literally named `:name` (re-run-4 #2 — no leak from path_parameters)" do
|
|
360
|
+
# Send `{ "name": "alice" }` as the body. Pre-batch the dispatcher
|
|
361
|
+
# had injected `name: "send_name"` into path_parameters which
|
|
362
|
+
# merged into params and shadowed the body field; the block would
|
|
363
|
+
# have seen `params[:name] == "send_name"`. Now it sees "alice".
|
|
364
|
+
post "/__ruact/fn/echo",
|
|
365
|
+
{ "name" => "alice" }.to_json,
|
|
366
|
+
{ "CONTENT_TYPE" => "application/json" }
|
|
367
|
+
expect(last_response.status).to eq(200)
|
|
368
|
+
expect(JSON.parse(last_response.body)).to eq("echoed" => { "name" => "alice" })
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
describe "AC3 — rescue_from on host controller catches block errors" do
|
|
373
|
+
it "wraps a block-raised RuntimeError into a structured 422 via the host's rescue_from" do
|
|
374
|
+
post "/__ruact/fn/fail_hard", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
375
|
+
expect(last_response.status).to eq(422)
|
|
376
|
+
body = JSON.parse(last_response.body)
|
|
377
|
+
expect(body).to eq("error" => "intentional failure", "error_class" => "RuntimeError")
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
describe "AC2 — 204 No Content for nil block return (review-batch 1 2026-05-14)" do
|
|
382
|
+
it "renders 204 with empty body when the block returns nil" do
|
|
383
|
+
post "/__ruact/fn/nil_return", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
384
|
+
expect(last_response.status).to eq(204)
|
|
385
|
+
expect(last_response.body).to eq("")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
describe "AC8 — CSRF contract (review-batch 5 2026-05-14)" do
|
|
390
|
+
# The gem-level endpoint MUST skip forgery protection itself — otherwise
|
|
391
|
+
# the route would reject requests before reaching the host controller
|
|
392
|
+
# that's supposed to be the source of truth for CSRF. The host's
|
|
393
|
+
# `protect_from_forgery` then enforces (or doesn't, in API mode).
|
|
394
|
+
it "EndpointController applies skip_forgery_protection at the class level (Story 8.3 — CSRF " \
|
|
395
|
+
"for controller-hosted actions is delegated; verify_authenticity_token is wired " \
|
|
396
|
+
"conditionally for the standalone branch only — see Story 8.3 AC5)" do
|
|
397
|
+
callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
|
|
398
|
+
verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
|
|
399
|
+
|
|
400
|
+
if verify_callback
|
|
401
|
+
# Story 8.3 — the callback exists but is gated by `dispatching_standalone?`,
|
|
402
|
+
# so it never fires on the controller-hosted dispatch path (the host's own
|
|
403
|
+
# `protect_from_forgery` remains the single source of CSRF truth for that branch).
|
|
404
|
+
expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
|
|
405
|
+
else
|
|
406
|
+
# Pre-Story-8.3: the callback was removed unconditionally.
|
|
407
|
+
expect(callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it "EndpointController inherits from ActionController::Base — runs the full CSRF middleware stack " \
|
|
412
|
+
"on dispatch (allowing the host's protect_from_forgery to take effect)" do
|
|
413
|
+
expect(Ruact::ServerFunctions::EndpointController.ancestors).to include(ActionController::Base)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# End-to-end CSRF behavior — a host controller with protect_from_forgery
|
|
417
|
+
# rejecting an invalid token — is a Rails-stack integration concern
|
|
418
|
+
# that requires session middleware + a real Rails request cycle with
|
|
419
|
+
# `config.action_controller.allow_forgery_protection = true`. The
|
|
420
|
+
# contract is preserved by virtue of (a) the gem skipping forgery on
|
|
421
|
+
# ITS endpoint and (b) delegating to `host_class.dispatch` which runs
|
|
422
|
+
# the host's own `verify_authenticity_token` filter. Pre-batch-5
|
|
423
|
+
# versions of the story file flagged this as deferred to Story 8.2's
|
|
424
|
+
# `<form action={fn}>` integration where CSRF is the user-visible path.
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
describe "Story 8.5 — regression: multipart UploadedFile passes through ruact_action_raw_args", :story_8_5 do
|
|
428
|
+
# Regression guard against future refactors of the controller-hosted
|
|
429
|
+
# branch's multipart path (controller.rb `ruact_action_raw_args` →
|
|
430
|
+
# `request.request_parameters`). The deep request-cycle coverage moved to
|
|
431
|
+
# the v2 concern in Story 9.1 (`spec/ruact/server_upload_request_spec.rb`);
|
|
432
|
+
# this single example pins the controller-hosted pass-through here so the
|
|
433
|
+
# dispatch suite catches a regression even if that file moves.
|
|
434
|
+
it "params[:cover] reaches the block as ActionDispatch::Http::UploadedFile" do
|
|
435
|
+
fixture_path = File.expand_path("../../support/fixtures/pixel.png", __dir__)
|
|
436
|
+
DispatchRequestSpecSupport::TestController.ruact_action(:upload_check) do |params|
|
|
437
|
+
{ "klass" => params[:cover].class.name }
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
post "/__ruact/fn/upload_check",
|
|
441
|
+
{ "cover" => Rack::Test::UploadedFile.new(fixture_path, "image/png") }
|
|
442
|
+
expect(last_response.status).to eq(200)
|
|
443
|
+
expect(JSON.parse(last_response.body).fetch("klass")).to eq("ActionDispatch::Http::UploadedFile")
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Story 9.1 review patch (2026-06-08, round 4) — the v1 endpoint stays alive
|
|
448
|
+
# as the strangler-fig safety net until Story 9.9 and still shares the
|
|
449
|
+
# salvaged upload guard. The deep upload matrix was re-anchored on the v2
|
|
450
|
+
# concern (and `endpoint_controller_upload_spec.rb` removed), so this minimal
|
|
451
|
+
# OBSERVABLE-CONTRACT smoke spec keeps the v1 endpoint's 413 path from
|
|
452
|
+
# regressing before demolition. Not the old implementation-coupled matrix —
|
|
453
|
+
# just the wire-visible contract through `POST /__ruact/fn/:name`.
|
|
454
|
+
describe "Story 9.1 — v1 endpoint upload-limit smoke (strangler safety net)", :story_9_1 do
|
|
455
|
+
before do
|
|
456
|
+
# spec_helper's global before-hook resets @config; re-prime AFTER it
|
|
457
|
+
# (local before runs after global) so the tight cap sticks for the body.
|
|
458
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
459
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
460
|
+
Ruact.configure { |c| c.max_upload_bytes = 1024 }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
it "an oversized multipart POST /__ruact/fn/:name rejects with 413 + structured upload_limit body" do
|
|
464
|
+
large = Tempfile.new(["big", ".bin"])
|
|
465
|
+
large.binmode
|
|
466
|
+
large.write("x" * 4096) # 4 KB > the 1 KB cap
|
|
467
|
+
large.rewind
|
|
468
|
+
|
|
469
|
+
post "/__ruact/fn/oversized_smoke",
|
|
470
|
+
{ "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream") },
|
|
471
|
+
{ "HTTP_ACCEPT" => "application/json" }
|
|
472
|
+
|
|
473
|
+
expect(last_response.status).to eq(413)
|
|
474
|
+
body = JSON.parse(last_response.body)
|
|
475
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
476
|
+
expect(body.fetch("error_class")).to eq("Ruact::UploadTooLargeError")
|
|
477
|
+
expect(body.fetch("upload_limit")).to include("limit_bytes" => 1024)
|
|
478
|
+
ensure
|
|
479
|
+
large.close
|
|
480
|
+
large.unlink
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
describe "Re-run-3 — before_action reads request.body (#3 raw_post fix)" do
|
|
485
|
+
it "the action still sees the original body when a before_action already drained it" do
|
|
486
|
+
# Pre-Re-run-3: the before_action's `body.read` advanced the IO to EOF;
|
|
487
|
+
# the action's own `body.read` returned `""` → `ruact_action_raw_args`
|
|
488
|
+
# silently coerced to `{}` → echoed empty params. Now: `request.raw_post`
|
|
489
|
+
# is Rack-cached, so the action sees the full body.
|
|
490
|
+
post "/__ruact/fn/body_peek", { "title" => "Hello" }.to_json, { "CONTENT_TYPE" => "application/json" }
|
|
491
|
+
expect(last_response.status).to eq(200)
|
|
492
|
+
expect(JSON.parse(last_response.body)).to eq("echoed" => { "title" => "Hello" })
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
describe "Re-run-2 — host routing identity (#6 path params)" do
|
|
497
|
+
it "inside the host action, params[:controller] and params[:action] describe " \
|
|
498
|
+
"the host, not the gem endpoint route" do
|
|
499
|
+
post "/__ruact/fn/routing_identity", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
500
|
+
expect(last_response.status).to eq(200)
|
|
501
|
+
body = JSON.parse(last_response.body)
|
|
502
|
+
expect(body.fetch("controller")).to eq("dispatch_request_spec_support/test")
|
|
503
|
+
expect(body.fetch("action")).to eq("routing_identity")
|
|
504
|
+
expect(body.fetch("params_controller")).to eq("dispatch_request_spec_support/test")
|
|
505
|
+
expect(body.fetch("params_action")).to eq("routing_identity")
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
describe "Re-run-2 — unknown action returns 404 even when body is malformed (#5)" do
|
|
510
|
+
it "does not parse the body before lookup, so a corrupted JSON for an unknown " \
|
|
511
|
+
"name still returns the 404 shape" do
|
|
512
|
+
# Pre-Re-run-2 this raised ParseError on body parse before the lookup.
|
|
513
|
+
post "/__ruact/fn/no_such_thing", "{ not json", { "CONTENT_TYPE" => "application/json" }
|
|
514
|
+
expect(last_response.status).to eq(404)
|
|
515
|
+
expect(JSON.parse(last_response.body)).to eq("error" => "unknown ruact action: :no_such_thing")
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
describe "AC9 — ActiveRecord::RecordInvalid (re-run-2 #9 — real AR class, not a stub)" do
|
|
520
|
+
it "wraps a real ActiveRecord::RecordInvalid into a structured 422 via the host's rescue_from" do
|
|
521
|
+
post "/__ruact/fn/invalid_record", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
522
|
+
expect(last_response.status).to eq(422)
|
|
523
|
+
body = JSON.parse(last_response.body)
|
|
524
|
+
expect(body.fetch("error_class")).to eq("ActiveRecord::RecordInvalid")
|
|
525
|
+
expect(body.fetch("error")).to match(/Title can't be blank/i)
|
|
526
|
+
expect(body.fetch("validation")).to be(true)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
describe "Task 5.4 — strong-parameters API works on the shadowed `params`" do
|
|
531
|
+
it "params.require(:post).permit(:title, :body) returns the permitted hash" do
|
|
532
|
+
post "/__ruact/fn/strong_params_demo",
|
|
533
|
+
{ "post" => { "title" => "Hi", "body" => "Body", "evil" => "ignored" } }.to_json,
|
|
534
|
+
{ "CONTENT_TYPE" => "application/json" }
|
|
535
|
+
expect(last_response.status).to eq(200)
|
|
536
|
+
body = JSON.parse(last_response.body)
|
|
537
|
+
expect(body.fetch("permitted")).to eq("title" => "Hi", "body" => "Body")
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
it "params.require(:post) ParameterMissing is caught by the Story 8.4 rescue_from " \
|
|
541
|
+
"→ 500 + structured payload (proves the shadowed params is a real ActionController::Parameters)" do
|
|
542
|
+
# Pre-Story 8.4 this raised through to Rack (spec app has
|
|
543
|
+
# `show_exceptions: :none`). Story 8.4's endpoint-level
|
|
544
|
+
# `rescue_from StandardError` now intercepts ParameterMissing and
|
|
545
|
+
# renders the structured 500 body. Asserting `error_class` proves
|
|
546
|
+
# the call truly reached `params.require(:post)` inside the block.
|
|
547
|
+
post "/__ruact/fn/strong_params_demo", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
548
|
+
expect(last_response.status).to eq(500)
|
|
549
|
+
body = JSON.parse(last_response.body)
|
|
550
|
+
expect(body.fetch("error_class")).to eq("ActionController::ParameterMissing")
|
|
551
|
+
expect(body.fetch("message")).to match(/param is missing.*post/i)
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Story 8.2 — multipart `<form action={fn}>` dispatch + end-to-end CSRF
|
|
556
|
+
# matrix. Inherits Story 8.1's AC8 (end-to-end CSRF) which was FORMALLY
|
|
557
|
+
# DEFERRED to this story (Re-run-5 scope clarification, 2026-05-15). Covers
|
|
558
|
+
# AC1 / AC5 / AC10 from the Story 8.2 spec. Nested inside the same top-level
|
|
559
|
+
# describe (RSpec/MultipleDescribes) so this file remains a single example
|
|
560
|
+
# group at the top level — the nested context exists so the spec's two
|
|
561
|
+
# story-tagged surfaces (8.1 baseline + 8.2 additions) live in one place.
|
|
562
|
+
describe "Story 8.2 — multipart `<form action>` dispatch", :story_8_2 do
|
|
563
|
+
describe "AC1 — multipart/form-data dispatch from <form action={fn}>" do
|
|
564
|
+
# R4 (2026-05-17 review patch): the previous spec passed plain
|
|
565
|
+
# Ruby hashes to `Rack::Test#post`, which sends an
|
|
566
|
+
# `application/x-www-form-urlencoded` body — NOT multipart. To
|
|
567
|
+
# exercise the real `<form action={fn}>` wire shape (the React 19
|
|
568
|
+
# runtime sends `Content-Type: multipart/form-data; boundary=…`),
|
|
569
|
+
# this helper hand-builds a multipart body. Each test below
|
|
570
|
+
# explicitly asserts `request.media_type == "multipart/form-data"`
|
|
571
|
+
# via the test app's `routing_identity` action so a future
|
|
572
|
+
# regression that quietly downgrades to urlencoded fails LOUDLY.
|
|
573
|
+
def multipart_post(path, fields)
|
|
574
|
+
boundary = "----RuactSpecBoundary#{SecureRandom.hex(8)}"
|
|
575
|
+
body = +""
|
|
576
|
+
fields.each do |key, value|
|
|
577
|
+
flatten_field(key, value).each do |(name, val)|
|
|
578
|
+
body << "--#{boundary}\r\n"
|
|
579
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
|
|
580
|
+
body << val.to_s
|
|
581
|
+
body << "\r\n"
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
body << "--#{boundary}--\r\n"
|
|
585
|
+
post path, body,
|
|
586
|
+
"CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}",
|
|
587
|
+
"CONTENT_LENGTH" => body.bytesize.to_s
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def flatten_field(key, value, prefix = nil)
|
|
591
|
+
full = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
592
|
+
case value
|
|
593
|
+
when Hash
|
|
594
|
+
value.flat_map { |k, v| flatten_field(k, v, full) }
|
|
595
|
+
else
|
|
596
|
+
[[full, value]]
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
it "dispatches a REAL multipart body and the block sees the fields as ActionController::Parameters" do
|
|
601
|
+
# The literal `<form action={createPost}>` round-trip: React 19
|
|
602
|
+
# invokes the function with FormData; the runtime POSTs as
|
|
603
|
+
# multipart; Rails' multipart parser unwraps the parts into
|
|
604
|
+
# `request.request_parameters`; `ruact_action_raw_args`
|
|
605
|
+
# (Story 8.1) surfaces them as the block's `params` shadow.
|
|
606
|
+
multipart_post "/__ruact/fn/echo",
|
|
607
|
+
{ "title" => "From form", "body" => "Form-encoded body" }
|
|
608
|
+
expect(last_response.status).to eq(200)
|
|
609
|
+
expect(last_response.headers["Content-Type"]).to include("application/json")
|
|
610
|
+
body = JSON.parse(last_response.body)
|
|
611
|
+
expect(body.fetch("echoed")).to include(
|
|
612
|
+
"title" => "From form",
|
|
613
|
+
"body" => "Form-encoded body"
|
|
614
|
+
)
|
|
615
|
+
# R4: prove the request media type was actually multipart — not
|
|
616
|
+
# the urlencoded fallback Rack::Test gives plain-hash bodies.
|
|
617
|
+
expect(last_request.media_type).to eq("multipart/form-data")
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
it "strong-parameters on the block's shadowed params works with a multipart body " \
|
|
621
|
+
"(`params.require(:post).permit(:title, :body)`)" do
|
|
622
|
+
# `params.require(:post).permit(...)` from inside the block — proves
|
|
623
|
+
# the shadowed params is a real ActionController::Parameters, with
|
|
624
|
+
# multipart-decoded nested-hash form fields.
|
|
625
|
+
multipart_post "/__ruact/fn/strong_params_demo",
|
|
626
|
+
{ "post" => { "title" => "Hi", "body" => "Body", "evil" => "ignored" } }
|
|
627
|
+
expect(last_response.status).to eq(200)
|
|
628
|
+
body = JSON.parse(last_response.body)
|
|
629
|
+
expect(body.fetch("permitted")).to eq("title" => "Hi", "body" => "Body")
|
|
630
|
+
expect(last_request.media_type).to eq("multipart/form-data")
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
it "returns 204 from a multipart submission when the block returns nil " \
|
|
634
|
+
"(parity with the JSON-body branch)" do
|
|
635
|
+
multipart_post "/__ruact/fn/nil_return", { "ignored" => "field" }
|
|
636
|
+
expect(last_response.status).to eq(204)
|
|
637
|
+
expect(last_response.body).to eq("")
|
|
638
|
+
expect(last_request.media_type).to eq("multipart/form-data")
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
it "routes ActiveRecord::RecordInvalid raised inside the block to the host's " \
|
|
642
|
+
"rescue_from from a multipart submission (status 422 + structured body)" do
|
|
643
|
+
multipart_post "/__ruact/fn/invalid_record", { "irrelevant" => "field" }
|
|
644
|
+
expect(last_response.status).to eq(422)
|
|
645
|
+
body = JSON.parse(last_response.body)
|
|
646
|
+
expect(body.fetch("error_class")).to eq("ActiveRecord::RecordInvalid")
|
|
647
|
+
expect(body.fetch("validation")).to be(true)
|
|
648
|
+
expect(last_request.media_type).to eq("multipart/form-data")
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
describe "AC5 — CSRF matrix (closes Story 8.1 AC8 deferral)" do
|
|
653
|
+
# The gem's EndpointController explicitly skips its own
|
|
654
|
+
# `verify_authenticity_token` so the host's `protect_from_forgery` is
|
|
655
|
+
# the single source of truth. The structural guarantee is asserted
|
|
656
|
+
# in Story 8.1's AC8 block above; this block exercises the
|
|
657
|
+
# downstream behaviour: a request that reaches the host action with
|
|
658
|
+
# the right token succeeds, one without it fails with 422 (Rails
|
|
659
|
+
# default response code for `InvalidAuthenticityToken`).
|
|
660
|
+
|
|
661
|
+
it "the gem endpoint inherits from ActionController::Base so the host's CSRF " \
|
|
662
|
+
"stack participates on dispatch" do
|
|
663
|
+
# Smoke-restated from Story 8.1's AC8 to keep this matrix self-contained;
|
|
664
|
+
# the full end-to-end CSRF round-trip (forgery_protection enabled + valid
|
|
665
|
+
# token accepted, invalid token rejected) would require flipping the
|
|
666
|
+
# spec-app's `config.action_controller.allow_forgery_protection` to true
|
|
667
|
+
# AND drawing in `ActionDispatch::Session::CookieStore` middleware — the
|
|
668
|
+
# spec app turns BOTH off (because every other test in this suite needs
|
|
669
|
+
# CSRF off to focus on dispatch mechanics). Documenting the boundary
|
|
670
|
+
# here keeps the matrix legible without rebooting Rails.
|
|
671
|
+
expect(Ruact::ServerFunctions::EndpointController.ancestors)
|
|
672
|
+
.to include(ActionController::Base)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it "the gem endpoint does NOT add an UNCONDITIONAL verify_authenticity_token to the chain " \
|
|
676
|
+
"(Story 8.3 — the callback exists for the standalone branch but is gated by " \
|
|
677
|
+
"`dispatching_standalone?`, so controller-hosted dispatch is unaffected)" do
|
|
678
|
+
callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
|
|
679
|
+
verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
|
|
680
|
+
if verify_callback
|
|
681
|
+
expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
|
|
682
|
+
else
|
|
683
|
+
expect(callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
it "API mode (host without protect_from_forgery) accepts the request without a token " \
|
|
688
|
+
"— the spec app's default state mirrors API-mode behaviour" do
|
|
689
|
+
# In the spec app, `allow_forgery_protection` is implicitly false (the
|
|
690
|
+
# framework default for `eager_load=false`). A POST without any CSRF
|
|
691
|
+
# header succeeds — the gem does not impose its own policy.
|
|
692
|
+
post "/__ruact/fn/echo", { "title" => "API mode" }
|
|
693
|
+
expect(last_response.status).to eq(200)
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
it "a valid X-CSRF-Token header is forwarded through to the host's session " \
|
|
697
|
+
"infrastructure (smoke — no token-rotation here, just delivery)" do
|
|
698
|
+
# The runtime's job is to read `<meta name=\"csrf-token\">` and
|
|
699
|
+
# forward as `X-CSRF-Token`. We can't easily round-trip the full
|
|
700
|
+
# `protect_from_forgery` flow here because that requires session
|
|
701
|
+
# middleware + `allow_forgery_protection = true`, both turned off
|
|
702
|
+
# in this suite. The request-level guarantee — header reaches the
|
|
703
|
+
# host action — is asserted by the routing-identity action below
|
|
704
|
+
# echoing all observable request state.
|
|
705
|
+
post "/__ruact/fn/routing_identity",
|
|
706
|
+
"{}",
|
|
707
|
+
{ "CONTENT_TYPE" => "application/json", "HTTP_X_CSRF_TOKEN" => "test-token" }
|
|
708
|
+
expect(last_response.status).to eq(200)
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Story 8.3 — standalone-host dispatch via /__ruact/fn/:name. Registers
|
|
714
|
+
# a Module hosting `:standalone_demo`, asserts the dispatcher branches
|
|
715
|
+
# through StandaloneDispatcher, asserts the host shape detection
|
|
716
|
+
# identifies the entry as a Module, and exercises the
|
|
717
|
+
# invalid-host-shape defense-in-depth branch. CSRF is covered separately
|
|
718
|
+
# in csrf_request_spec.rb (`Story 8.3 — standalone branch CSRF matrix`).
|
|
719
|
+
describe "Story 8.3 — standalone-host dispatch via /__ruact/fn/:name", :story_8_3 do
|
|
720
|
+
# Flip the EndpointController's allow_forgery_protection to false for
|
|
721
|
+
# this describe block so the standalone branch behaves as API-mode —
|
|
722
|
+
# matching the rest of dispatch_request_spec.rb. The protected path
|
|
723
|
+
# (allow_forgery_protection = true) is exercised in csrf_request_spec.rb.
|
|
724
|
+
around do |example|
|
|
725
|
+
previous = Ruact::ServerFunctions::EndpointController.allow_forgery_protection
|
|
726
|
+
Ruact::ServerFunctions::EndpointController.allow_forgery_protection = false
|
|
727
|
+
example.run
|
|
728
|
+
ensure
|
|
729
|
+
Ruact::ServerFunctions::EndpointController.allow_forgery_protection = previous
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
before(:all) do
|
|
733
|
+
# Declare the standalone host module ONCE — registries are reset between
|
|
734
|
+
# examples but the module reference must stay stable.
|
|
735
|
+
unless defined?(DispatchSpecStandaloneHost)
|
|
736
|
+
Object.const_set(:DispatchSpecStandaloneHost, Module.new)
|
|
737
|
+
DispatchSpecStandaloneHost.extend(Ruact::ServerAction)
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
before do
|
|
742
|
+
# spec_helper resets the registries between examples; always re-register
|
|
743
|
+
# so the entry is freshly bound to the live registry instance.
|
|
744
|
+
DispatchSpecStandaloneHost.module_eval do
|
|
745
|
+
ruact_action(:standalone_demo) do |params|
|
|
746
|
+
{
|
|
747
|
+
"message" => params[:message].to_s,
|
|
748
|
+
"host_kind" => "module",
|
|
749
|
+
"before_action_fired" => false
|
|
750
|
+
}
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
it "dispatches a standalone-hosted action and returns the block's return value as JSON" do
|
|
756
|
+
post "/__ruact/fn/standalone_demo",
|
|
757
|
+
{ "message" => "from standalone" }.to_json,
|
|
758
|
+
{ "CONTENT_TYPE" => "application/json" }
|
|
759
|
+
expect(last_response.status).to eq(200)
|
|
760
|
+
expect(last_response.headers["Content-Type"]).to include("application/json")
|
|
761
|
+
expect(JSON.parse(last_response.body)).to eq(
|
|
762
|
+
"message" => "from standalone",
|
|
763
|
+
"host_kind" => "module",
|
|
764
|
+
"before_action_fired" => false
|
|
765
|
+
)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
it "the entry's host is a Module (not a Class) — proves the codegen-side path " \
|
|
769
|
+
"cannot tell standalone-hosted apart from controller-hosted (same accessor shape)" do
|
|
770
|
+
entry = Ruact.action_registry.entries[:standalone_demo]
|
|
771
|
+
expect(entry.controller).to be_a(Module)
|
|
772
|
+
expect(entry.controller).not_to be_a(Class)
|
|
773
|
+
expect(entry.js_identifier).to eq("standaloneDemo")
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
it "the EndpointController's standalone_host? predicate identifies the host as standalone" do
|
|
777
|
+
entry = Ruact.action_registry.entries[:standalone_demo]
|
|
778
|
+
expect(Ruact::ServerFunctions::EndpointController.standalone_host?(entry.controller)).to be(true)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
describe "Story 8.4 — standalone block raise produces structured payload", :story_8_4 do
|
|
782
|
+
before do
|
|
783
|
+
DispatchSpecStandaloneHost.module_eval do
|
|
784
|
+
ruact_action(:standalone_boom) { |_p| raise "standalone explosion" }
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
it "an unrescued StandardError raised inside the standalone block falls through to " \
|
|
789
|
+
"EndpointController's rescue_from StandardError and returns 500 + structured body" do
|
|
790
|
+
post "/__ruact/fn/standalone_boom", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
791
|
+
expect(last_response.status).to eq(500)
|
|
792
|
+
body = JSON.parse(last_response.body)
|
|
793
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
794
|
+
expect(body.fetch("action_name")).to eq("standalone_boom")
|
|
795
|
+
expect(body.fetch("error_class")).to eq("RuntimeError")
|
|
796
|
+
expect(body.fetch("message")).to eq("standalone explosion")
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
it "an invalid host shape (neither Class nor extending Ruact::ServerAction) renders 500 " \
|
|
801
|
+
"with the documented error message (defense-in-depth against registry injection)" do
|
|
802
|
+
# Manually inject a bogus entry — proves the dispatcher's host-shape
|
|
803
|
+
# validation surfaces clearly when something has gone very wrong.
|
|
804
|
+
bogus = Ruact::ServerFunctions::RegistryEntry.new(
|
|
805
|
+
ruby_symbol: :bogus_host,
|
|
806
|
+
js_identifier: "bogusHost",
|
|
807
|
+
kind: :action,
|
|
808
|
+
controller: "not_a_class_or_standalone_module",
|
|
809
|
+
block: ->(_p) {}
|
|
810
|
+
)
|
|
811
|
+
Ruact.action_registry.instance_variable_get(:@entries)[:bogus_host] = bogus
|
|
812
|
+
|
|
813
|
+
post "/__ruact/fn/bogus_host", "{}", { "CONTENT_TYPE" => "application/json" }
|
|
814
|
+
expect(last_response.status).to eq(500)
|
|
815
|
+
body = JSON.parse(last_response.body)
|
|
816
|
+
expect(body.fetch("error")).to match(/invalid host shape/)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|