ruact 0.0.1 → 0.0.3

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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.2 review patch R3 + R10 (2026-05-17) — end-to-end CSRF matrix
4
+ # for `POST /__ruact/fn/:name`. Closes Story 8.1's AC8 deferral with
5
+ # REAL forgery-protection round-trips:
6
+ #
7
+ # - missing token → 422
8
+ # - invalid token → 422
9
+ # - VALID token → 200 + action return value (R10 — was structural-
10
+ # only before; now a real Rails round-trip)
11
+ # - API-mode → 200 without any token (cross-referenced)
12
+ #
13
+ # Mounts onto the existing Rails::Application that `controller_request_spec.rb`
14
+ # boots (the Story 7.9 minimal app — `config.secret_key_base` set,
15
+ # Cookies + Session middleware in the default Rails middleware stack).
16
+ # Appending routes to that shared app sidesteps the singleton constraint
17
+ # (only one Rails::Application per process) AND gets a fully-wired
18
+ # `Rails.application.key_generator` for `form_authenticity_token` to
19
+ # derive keys from — Rack::Builder-only specs cannot reproduce that
20
+ # wiring without effectively re-implementing the Rails middleware chain.
21
+
22
+ require "spec_helper"
23
+ require "rack/test"
24
+
25
+ require "action_controller/railtie"
26
+ require "action_view/railtie"
27
+ require "action_dispatch"
28
+ require "ruact/controller"
29
+ require "ruact/server_functions/endpoint_controller"
30
+ require "ruact/server_action"
31
+ require "ruact/railtie"
32
+
33
+ # Reuse the Rails::Application booted by `controller_request_spec.rb` —
34
+ # Rails does not support two `Rails::Application` subclasses initialized
35
+ # in the same process. When this spec runs alone, the require below
36
+ # loads `controller_request_spec.rb`, which defines
37
+ # `ControllerRequestSpecSupport.app_class`.
38
+ require_relative "../controller_request_spec" if defined?(Rails::Application) &&
39
+ !defined?(ControllerRequestSpecSupport)
40
+
41
+ module CsrfRequestSpecSupport
42
+ # The protected controller — `allow_forgery_protection = true` (per-
43
+ # controller override of the spec app's default `false`) is what
44
+ # makes `protect_from_forgery` actually fire on this class.
45
+ #
46
+ # Story 8.2 review patch R17 (2026-05-17) — the `rescue_from
47
+ # InvalidAuthenticityToken` shortcut was REMOVED. Real Rails hosts
48
+ # using `protect_from_forgery with: :exception` let the exception
49
+ # bubble; with `show_exceptions: :none` (the spec app's config) it
50
+ # propagates to Rack and the test sees it directly. Asserting the
51
+ # exception class is a stronger guarantee than asserting a custom
52
+ # body the test itself produced — it proves the dispatcher reaches
53
+ # the host's CSRF callback chain unchanged.
54
+ class CSRFTestController < ActionController::Base
55
+ self.allow_forgery_protection = true
56
+ protect_from_forgery with: :exception
57
+
58
+ include Ruact::Controller
59
+
60
+ def self.register_ruact_actions!
61
+ ruact_action(:csrf_demo) { |params| { "ok" => true, "echoed" => params.to_unsafe_h } }
62
+ end
63
+ end
64
+
65
+ # Non-CSRF sibling controller — emits a freshly-minted authenticity
66
+ # token tied to the request's session. The token is masked per-
67
+ # request (R9 in the spec body); `valid_authenticity_token?` accepts
68
+ # any per-request derivation of the session's master.
69
+ class TokenEmitterController < ActionController::Base
70
+ self.allow_forgery_protection = false
71
+
72
+ def emit
73
+ # Force session creation so the cookie comes back to Rack::Test
74
+ # and the next request lands in the same session.
75
+ session[:_warm] = true
76
+ render json: { token: form_authenticity_token }
77
+ end
78
+ end
79
+
80
+ CSRF_ROUTES_APPENDED = false
81
+
82
+ class << self
83
+ # Idempotent — appends the CSRF spec's two routes to the shared
84
+ # Story 7.9 app the first time it's called. Routes can only be
85
+ # appended BEFORE `initialize!` runs reliably; we call
86
+ # `boot!` immediately after so the routes land.
87
+ def append_routes!
88
+ return if @routes_appended
89
+
90
+ ControllerRequestSpecSupport.app_class.routes.append do
91
+ post "/__ruact/fn/:name",
92
+ to: "ruact/server_functions/endpoint#dispatch_action",
93
+ as: :ruact_server_function_csrf_spec,
94
+ constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
95
+ get "/_csrf_token",
96
+ to: "csrf_request_spec_support/token_emitter#emit",
97
+ as: :csrf_token_emit
98
+ end
99
+ @routes_appended = true
100
+ end
101
+ end
102
+ end
103
+
104
+ # Route append must happen at file load, BEFORE any boot! call from
105
+ # either spec, because Rails routes are finalised at initialize!. The
106
+ # `dispatch_request_spec.rb` file already follows this convention.
107
+ CsrfRequestSpecSupport.append_routes!
108
+
109
+ # Story 8.2 review patch R19 (2026-05-17) — wraps requests in a small
110
+ # rescue middleware that mirrors what `ActionDispatch::ShowExceptions`
111
+ # does in production: catch `ActionController::InvalidAuthenticityToken`
112
+ # and render a 422 response. The spec app sets
113
+ # `show_exceptions: :none` so the exception otherwise bubbles to Rack
114
+ # and the test process; that proves the host's CSRF callback fired (R17)
115
+ # but does NOT observe the HTTP 422 + body shape AC5 specifies for the
116
+ # client-facing contract. This middleware lets the spec assert both —
117
+ # rejection at the host (proven by reaching the rescue branch) AND the
118
+ # downstream 422 response shape (the surface `RuactActionError.body`
119
+ # parses on the client).
120
+ module CsrfRequestSpecSupport
121
+ class CSRFRescueMiddleware
122
+ def initialize(app)
123
+ @app = app
124
+ end
125
+
126
+ def call(env)
127
+ @app.call(env)
128
+ rescue ActionController::InvalidAuthenticityToken => e
129
+ body = JSON.dump(
130
+ error: "ActionController::InvalidAuthenticityToken",
131
+ message: e.message
132
+ )
133
+ [422, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
134
+ end
135
+ end
136
+ end
137
+
138
+ RSpec.describe "Story 8.2 — CSRF matrix (`<form action={fn}>` end-to-end)",
139
+ :story_8_2 do
140
+ include Rack::Test::Methods
141
+
142
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
143
+ # R19: wrap the booted Rails app in the rescue middleware so the spec
144
+ # can observe `ActionController::InvalidAuthenticityToken` as the
145
+ # HTTP 422 response Rails' production middleware (ShowExceptions)
146
+ # would produce — without changing the spec app's `show_exceptions`
147
+ # config (which other specs depend on).
148
+ let(:app) { CsrfRequestSpecSupport::CSRFRescueMiddleware.new(app_class.instance) }
149
+
150
+ before do
151
+ Rails.logger = Logger.new(IO::NULL)
152
+ ControllerRequestSpecSupport.boot!
153
+ CsrfRequestSpecSupport::CSRFTestController.register_ruact_actions!
154
+ end
155
+
156
+ # R17 (2026-05-17): the AC1/AC5 wire shape is `multipart/form-data`
157
+ # (React 19's `<form action={fn}>` invokes the function with a
158
+ # `FormData` instance; the runtime POSTs as multipart). Hand-build a
159
+ # multipart body so the spec exercises the same path the runtime
160
+ # produces. Mirrors the helper in `dispatch_request_spec.rb`.
161
+ def multipart_post(path, fields, extra_headers = {})
162
+ boundary = "----RuactCsrfSpec#{SecureRandom.hex(8)}"
163
+ body = +""
164
+ fields.each do |key, value|
165
+ body << "--#{boundary}\r\n"
166
+ body << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
167
+ body << value.to_s
168
+ body << "\r\n"
169
+ end
170
+ body << "--#{boundary}--\r\n"
171
+ post path, body,
172
+ {
173
+ "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}",
174
+ "CONTENT_LENGTH" => body.bytesize.to_s
175
+ }.merge(extra_headers)
176
+ end
177
+
178
+ describe "AC5 — host has `protect_from_forgery with: :exception` enabled" do
179
+ it "REJECTS a multipart request WITHOUT an X-CSRF-Token header — HTTP 403 + structured body " \
180
+ "(Story 8.4: status code changed from 422 → 403 and the gem now renders the structured payload " \
181
+ "directly via EndpointController's `rescue_from ActionController::InvalidAuthenticityToken`; " \
182
+ "the test's CSRFRescueMiddleware is now a dead-code fallback that never fires)" do
183
+ multipart_post "/__ruact/fn/csrf_demo", { "title" => "no token" }
184
+ expect(last_response.status).to eq(403)
185
+ expect(last_response.headers["Content-Type"]).to include("application/json")
186
+ body = JSON.parse(last_response.body)
187
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
188
+ expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
189
+ expect(body.fetch("message")).to match(/CSRF token|authenticity/i)
190
+ end
191
+
192
+ it "REJECTS a multipart request whose X-CSRF-Token header carries an INVALID token — same 403 + structured body" do
193
+ multipart_post "/__ruact/fn/csrf_demo",
194
+ { "title" => "bad token" },
195
+ { "HTTP_X_CSRF_TOKEN" => "obviously-not-the-real-token" }
196
+ expect(last_response.status).to eq(403)
197
+ expect(last_response.headers["Content-Type"]).to include("application/json")
198
+ body = JSON.parse(last_response.body)
199
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
200
+ expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
201
+ end
202
+
203
+ it "the rejection exception class is the canonical Rails one — `RuactActionError.body` " \
204
+ "carries the JSON body the middleware rendered above (structural cross-check)" do
205
+ # The runtime's 4xx branch (see `index.test.mjs`
206
+ # "RuactActionError.body holds…") parses the response body
207
+ # verbatim. Asserting the body shape above is what proves the
208
+ # `RuactActionError.body` surface on the client side.
209
+ expect(ActionController::InvalidAuthenticityToken.new).to be_a(StandardError)
210
+ expect(ActionController::InvalidAuthenticityToken.ancestors).to include(StandardError)
211
+ end
212
+
213
+ it "ACCEPTS a multipart request that forwards the freshly-issued X-CSRF-Token (200) " \
214
+ "— R10 + R17 full end-to-end protected round-trip with the AC1 wire shape" do
215
+ # Fetch a fresh per-request token from the non-CSRF sibling
216
+ # route. The token is masked against the session's master CSRF
217
+ # token; Rack::Test carries cookies across requests, so the
218
+ # subsequent multipart POST lands in the SAME session and the
219
+ # token matches.
220
+ get "/_csrf_token"
221
+ expect(last_response.status).to eq(200)
222
+ token = JSON.parse(last_response.body).fetch("token")
223
+ expect(token).to be_a(String).and(satisfy { |t| !t.empty? })
224
+
225
+ multipart_post "/__ruact/fn/csrf_demo",
226
+ { "title" => "Hi", "body" => "From form" },
227
+ { "HTTP_X_CSRF_TOKEN" => token }
228
+ expect(last_response.status).to eq(200)
229
+ # R17: prove the multipart parsing actually surfaced the form
230
+ # fields into the action's `params` shadow — a regression that
231
+ # silently dropped the body would still pass a status-only check.
232
+ body = JSON.parse(last_response.body)
233
+ expect(body.fetch("ok")).to be(true)
234
+ expect(body.fetch("echoed")).to include("title" => "Hi", "body" => "From form")
235
+ expect(last_request.media_type).to eq("multipart/form-data")
236
+ end
237
+
238
+ it "the same callback chain that produced the 422 paths above is wired into the host " \
239
+ "(structural cross-check)" do
240
+ filters = CsrfRequestSpecSupport::CSRFTestController
241
+ ._process_action_callbacks
242
+ .map(&:filter)
243
+ expect(filters).to include(:verify_authenticity_token)
244
+ # Conversely, the gem's own EndpointController either carries no
245
+ # verify_authenticity_token at all (pre-Story-8.3) or carries one
246
+ # gated by `dispatching_standalone?` (Story 8.3+) — either way it
247
+ # does NOT fire on the controller-hosted dispatch path; the host
248
+ # remains the single source of CSRF truth for that branch.
249
+ gem_callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
250
+ verify_callback = gem_callbacks.find { |c| c.filter == :verify_authenticity_token }
251
+ if verify_callback
252
+ expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
253
+ else
254
+ expect(gem_callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
255
+ end
256
+ end
257
+ end
258
+
259
+ # Story 8.3 — AC5 CSRF matrix for the STANDALONE branch. The standalone
260
+ # path has no host controller, so the gem's `EndpointController`
261
+ # enforces CSRF itself via `protect_from_forgery with: :exception,
262
+ # if: :dispatching_standalone?`. The matrix mirrors the controller-
263
+ # hosted matrix above but exercises the gem's own callback chain.
264
+ #
265
+ # Story 8.3 review R2 — IMPORTANT: the gem does NOT guarantee a JSON
266
+ # response body for CSRF failures. `protect_from_forgery with: :exception`
267
+ # raises `ActionController::InvalidAuthenticityToken`; the response body
268
+ # the client sees is whatever the host app's exception middleware
269
+ # produces (Rails' default `ActionDispatch::ShowExceptions` serves
270
+ # `public/422.html` in non-API mode; a host with `rescue_from` or a
271
+ # custom error renderer overrides that). The `CSRFRescueMiddleware`
272
+ # in this spec is a TEST-ONLY synthesis (carried over from Story 8.2)
273
+ # that wraps the booted app to produce a stable JSON shape for
274
+ # assertions — it does NOT ship with the gem. Tests below assert
275
+ # what the GEM guarantees (status code 422, exception class is the
276
+ # canonical Rails one) and what the test middleware adds on top
277
+ # (the JSON body shape) — the in-line comments call out which is which.
278
+ describe "Story 8.3 — standalone-branch CSRF matrix", :story_8_3 do
279
+ module StandaloneCsrfSpecSupport
280
+ module DemoStandaloneHost
281
+ extend Ruact::ServerAction
282
+ end
283
+ end
284
+
285
+ around do |example|
286
+ # The standalone branch's `protect_from_forgery` lives on
287
+ # EndpointController and inherits Rails' `allow_forgery_protection`
288
+ # class attribute. Force it true for this describe block so the
289
+ # gem-level check fires; the after-hook restores prior state.
290
+ previous = Ruact::ServerFunctions::EndpointController.allow_forgery_protection
291
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = true
292
+ example.run
293
+ ensure
294
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = previous
295
+ end
296
+
297
+ before do
298
+ StandaloneCsrfSpecSupport::DemoStandaloneHost.module_eval do
299
+ ruact_action(:standalone_csrf_demo) { |params| { "ok" => true, "echoed" => params.to_unsafe_h } }
300
+ end
301
+ end
302
+
303
+ it "REJECTS a multipart request WITHOUT an X-CSRF-Token header — HTTP 403 + structured body " \
304
+ "(Story 8.4 update: status moved from 422 → 403; the gem now renders the structured payload " \
305
+ "directly via EndpointController's explicit `rescue_from InvalidAuthenticityToken` (Pitfall #1) " \
306
+ "so the body shape is part of the gem's guarantee, not test-middleware synthesis)" do
307
+ multipart_post "/__ruact/fn/standalone_csrf_demo", { "title" => "no token" }
308
+ expect(last_response.status).to eq(403)
309
+ body = JSON.parse(last_response.body)
310
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
311
+ expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
312
+ end
313
+
314
+ it "REJECTS a multipart request whose X-CSRF-Token header carries an INVALID token — same 403 + structured body" do
315
+ multipart_post "/__ruact/fn/standalone_csrf_demo",
316
+ { "title" => "bad" },
317
+ { "HTTP_X_CSRF_TOKEN" => "obviously-not-the-real-token" }
318
+ expect(last_response.status).to eq(403)
319
+ body = JSON.parse(last_response.body)
320
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
321
+ expect(body.fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
322
+ end
323
+
324
+ it "ACCEPTS a multipart request that forwards the freshly-issued X-CSRF-Token (200) — " \
325
+ "end-to-end standalone-branch CSRF round-trip" do
326
+ get "/_csrf_token"
327
+ token = JSON.parse(last_response.body).fetch("token")
328
+
329
+ multipart_post "/__ruact/fn/standalone_csrf_demo",
330
+ { "title" => "Hi", "body" => "From form" },
331
+ { "HTTP_X_CSRF_TOKEN" => token }
332
+ expect(last_response.status).to eq(200)
333
+ body = JSON.parse(last_response.body)
334
+ expect(body.fetch("ok")).to be(true)
335
+ expect(body.fetch("echoed")).to include("title" => "Hi", "body" => "From form")
336
+ end
337
+
338
+ it "API-mode (allow_forgery_protection = false) accepts a standalone POST WITHOUT a token — " \
339
+ "the global config wins, identical to the controller-action behavior in Story 8.1 AC8" do
340
+ previous = Ruact::ServerFunctions::EndpointController.allow_forgery_protection
341
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = false
342
+ multipart_post "/__ruact/fn/standalone_csrf_demo", { "title" => "api mode" }
343
+ expect(last_response.status).to eq(200)
344
+ ensure
345
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = previous
346
+ end
347
+
348
+ it "mixed-style host parity — controller-hosted and standalone-hosted actions report identical " \
349
+ "user-visible CSRF behavior under the same allow_forgery_protection setting" do
350
+ # The controller path's missing-token rejection is asserted in
351
+ # `AC5 — host has protect_from_forgery enabled` above (422 +
352
+ # ActionController::InvalidAuthenticityToken). The standalone path's
353
+ # missing-token rejection is asserted in this describe's first
354
+ # spec. Pinning the structural cross-check here documents that the
355
+ # two hosts converge on the same callback class and exception class.
356
+ callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
357
+ verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
358
+ expect(verify_callback).not_to be_nil
359
+ expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
360
+ end
361
+ end
362
+
363
+ describe "API-mode parity — host WITHOUT `protect_from_forgery` accepts anything" do
364
+ it "structural complement: the spec app's main dispatch tests run with " \
365
+ "allow_forgery_protection = false; absent-token requests succeed there" do
366
+ # The full API-mode round-trip is asserted in `dispatch_request_spec.rb`'s
367
+ # `AC5 — CSRF matrix` describe block; cross-reference recorded here so
368
+ # the matrix is discoverable from one place.
369
+ callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
370
+ verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
371
+ if verify_callback
372
+ # Story 8.3 — gated by dispatching_standalone?; controller-hosted
373
+ # dispatch never reaches it.
374
+ expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
375
+ else
376
+ expect(callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
377
+ end
378
+ end
379
+ end
380
+ end