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,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
|