ruact 0.0.2 → 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 (128) 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 +164 -0
  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 +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
@@ -16,37 +16,37 @@ module Ruact
16
16
  @secret = "top-secret"
17
17
  end
18
18
 
19
- rsc_props :id, :title
19
+ ruact_props :id, :title
20
20
  end
21
21
  end
22
22
 
23
- describe "rsc_props (AC#2)" do
23
+ describe "ruact_props (AC#2)" do
24
24
  it "raises ArgumentError for undefined method at class load time" do
25
25
  expect do
26
26
  Class.new do
27
27
  include Ruact::Serializable
28
28
 
29
- rsc_props :nonexistent
29
+ ruact_props :nonexistent
30
30
  end
31
31
  end.to raise_error(ArgumentError, /nonexistent/)
32
32
  end
33
33
  end
34
34
 
35
- describe "rsc_serialize (AC#1)" do
35
+ describe "ruact_serialize (AC#1)" do
36
36
  it "returns only declared props" do
37
37
  obj = serializable_class.new
38
- expect(obj.rsc_serialize).to eq({ "id" => 1, "title" => "Hello" })
38
+ expect(obj.ruact_serialize).to eq({ "id" => 1, "title" => "Hello" })
39
39
  end
40
40
 
41
41
  it "excludes undeclared attributes" do
42
42
  obj = serializable_class.new
43
- expect(obj.rsc_serialize.keys).not_to include("secret")
43
+ expect(obj.ruact_serialize.keys).not_to include("secret")
44
44
  end
45
45
  end
46
46
 
47
- describe "rsc_props_list" do
47
+ describe "ruact_props_list" do
48
48
  it "returns the declared prop names as symbols" do
49
- expect(serializable_class.rsc_props_list).to eq(%i[id title])
49
+ expect(serializable_class.ruact_props_list).to eq(%i[id title])
50
50
  end
51
51
  end
52
52
  end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.2 — request-cycle spec for dual-bucket response negotiation on the
4
+ # SAME controller action, on the `Ruact::Server` concern. Pins, against REAL
5
+ # host-controller routes:
6
+ #
7
+ # - Bucket 2 (Accept: application/json, non-GET) normal completion → JSON of
8
+ # all exposed ivars keyed by name, via ruact_props/Serializable (AC2)
9
+ # - Bucket 2 redirect_to → { "$redirect": "<path>" } (AC3)
10
+ # - Bucket 2 empty → 204, no body (AC4)
11
+ # - Bucket 2 serialization failure → 9.1 chain → 500 structured (AC5)
12
+ # - Vary: Accept on EVERY non-GET shape — 200 / 204 / $redirect / Flight (AC6)
13
+ # - CSRF on Bucket 2 — host protect_from_forgery (AC7)
14
+ # - Bucket 1 (text/x-component) redirect → Flight redirect row, unchanged (AC1)
15
+ #
16
+ # Mounts on the shared Story-7.9 Rails app; mirrors the 9.1 request-spec pattern.
17
+
18
+ require "action_controller/railtie"
19
+ require "action_view/railtie"
20
+
21
+ require "spec_helper"
22
+ require "rack/test"
23
+
24
+ require "ruact/controller"
25
+ require "ruact/server"
26
+
27
+ require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
28
+
29
+ module ServerBucketSpecSupport
30
+ # Serializable model — only :id/:title exposed; :secret must never leak.
31
+ class BucketPost
32
+ include Ruact::Serializable
33
+
34
+ attr_reader :id, :title, :secret
35
+
36
+ def initialize(id:, title:, secret:)
37
+ @id = id
38
+ @title = title
39
+ @secret = secret
40
+ end
41
+
42
+ ruact_props :id, :title
43
+ end
44
+
45
+ # Non-Serializable record with as_json — under strict_serialization it must
46
+ # raise Ruact::SerializationError (AC5).
47
+ class UnserializableRecord
48
+ def as_json(_opts = nil)
49
+ { "id" => 1, "leak" => "everything" }
50
+ end
51
+ end
52
+
53
+ # Bucket-2 host: includes ONLY Ruact::Server (Bucket-2 requests are handled
54
+ # by Server#default_render without delegating to Ruact::Controller).
55
+ class BucketServerController < ActionController::Base
56
+ include Ruact::Server
57
+
58
+ def with_ivars
59
+ @post = BucketPost.new(id: 1, title: "Hi", secret: "topsecret")
60
+ @count = 2
61
+ # no explicit render → default_render
62
+ end
63
+
64
+ def empty_action
65
+ # sets no exposed ivars, no redirect → 204 on Bucket 2
66
+ end
67
+
68
+ def redirecting
69
+ redirect_to "/posts/1"
70
+ end
71
+
72
+ # Review round 1 — cross-host redirect must hit Rails' open-redirect
73
+ # protection (raise) instead of silently emitting an external $redirect.
74
+ def redirect_external
75
+ redirect_to "https://evil.example.com/x"
76
+ end
77
+
78
+ def redirect_external_allowed
79
+ redirect_to "https://allowed.example.com/x", allow_other_host: true
80
+ end
81
+
82
+ # Review round 1 — a host that sets Vary itself must keep it; Accept is
83
+ # appended, not clobbered.
84
+ def vary_clobber
85
+ response.headers["Vary"] = "Cookie"
86
+ @ok = true
87
+ end
88
+
89
+ # Review round 2 — a host that sets Vary: * (uncacheable wildcard) must keep
90
+ # it as-is, not "*, Accept".
91
+ def vary_wildcard
92
+ response.headers["Vary"] = "*"
93
+ @ok = true
94
+ end
95
+
96
+ def unserializable
97
+ @rec = UnserializableRecord.new
98
+ end
99
+ end
100
+
101
+ # Review round 2 — an auth-style before_action that redirects on a Bucket-2
102
+ # call: the response is performed in the before_action, so after-callbacks are
103
+ # skipped — Vary must still be present (set by a before_action too).
104
+ class BucketBeforeRedirectController < ActionController::Base
105
+ include Ruact::Server
106
+
107
+ before_action :bounce
108
+
109
+ def never_runs
110
+ @ok = true
111
+ end
112
+
113
+ private
114
+
115
+ def bounce
116
+ redirect_to "/login"
117
+ end
118
+ end
119
+
120
+ # Bucket-1 regression host: BOTH concerns, so a non-function-call request
121
+ # delegates Server#redirect_to → super → Ruact::Controller's Flight row.
122
+ class BucketDualController < ActionController::Base
123
+ include Ruact::Controller
124
+ include Ruact::Server
125
+
126
+ def redirecting
127
+ redirect_to "/posts/1"
128
+ end
129
+ end
130
+
131
+ # CSRF-enforcing Bucket-2 host (AC7) — forgery flipped on per-example.
132
+ class BucketForgeryController < ActionController::Base
133
+ include Ruact::Server
134
+
135
+ protect_from_forgery with: :exception
136
+
137
+ def create_protected
138
+ @ok = true
139
+ end
140
+
141
+ # GET is exempt from CSRF verification — emits a per-request token masked
142
+ # against the session master, for the valid-token round-trip (AC7).
143
+ def csrf_token
144
+ render json: { token: form_authenticity_token }
145
+ end
146
+ end
147
+ end
148
+
149
+ if defined?(ControllerRequestSpecSupport) &&
150
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_bucket_routes_appended)
151
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_bucket_routes_appended, true)
152
+ ControllerRequestSpecSupport.app_class.routes.append do
153
+ post "/bucket/with_ivars", to: "server_bucket_spec_support/bucket_server#with_ivars"
154
+ post "/bucket/empty", to: "server_bucket_spec_support/bucket_server#empty_action"
155
+ post "/bucket/redirecting", to: "server_bucket_spec_support/bucket_server#redirecting"
156
+ post "/bucket/redirect_external", to: "server_bucket_spec_support/bucket_server#redirect_external"
157
+ post "/bucket/redirect_external_allowed", to: "server_bucket_spec_support/bucket_server#redirect_external_allowed"
158
+ post "/bucket/vary_clobber", to: "server_bucket_spec_support/bucket_server#vary_clobber"
159
+ post "/bucket/vary_wildcard", to: "server_bucket_spec_support/bucket_server#vary_wildcard"
160
+ post "/bucket/before_redirect", to: "server_bucket_spec_support/bucket_before_redirect#never_runs"
161
+ post "/bucket/unserializable", to: "server_bucket_spec_support/bucket_server#unserializable"
162
+ post "/bucket/dual_redirecting", to: "server_bucket_spec_support/bucket_dual#redirecting"
163
+ post "/bucket/protected", to: "server_bucket_spec_support/bucket_forgery#create_protected"
164
+ get "/bucket/csrf_token", to: "server_bucket_spec_support/bucket_forgery#csrf_token"
165
+ end
166
+ end
167
+
168
+ RSpec.describe "Story 9.2: Ruact::Server dual-bucket response negotiation", :story_9_2 do
169
+ include Rack::Test::Methods
170
+
171
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
172
+ let(:app) { app_class.instance }
173
+
174
+ let(:json_headers) { { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" } }
175
+ let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
176
+
177
+ before do
178
+ Rails.logger = Logger.new(IO::NULL)
179
+ ControllerRequestSpecSupport.boot!
180
+ end
181
+
182
+ def reset_config
183
+ Ruact.instance_variable_set(:@config, nil)
184
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
185
+ end
186
+
187
+ describe "Bucket 2 — normal completion serializes all exposed ivars (AC2)" do
188
+ it "renders 200 + a JSON object keyed by ivar name, ruact_props only (no secret)" do
189
+ post "/bucket/with_ivars", "{}", json_headers
190
+ expect(last_response.status).to eq(200)
191
+ expect(last_response.headers["Content-Type"]).to include("application/json")
192
+ body = JSON.parse(last_response.body)
193
+ expect(body).to eq("post" => { "id" => 1, "title" => "Hi" }, "count" => 2)
194
+ expect(body.fetch("post")).not_to have_key("secret")
195
+ end
196
+ end
197
+
198
+ describe "Bucket 2 — redirect_to surfaces as $redirect (AC3)" do
199
+ it "renders 200 + { \"$redirect\": \"<path>\" } instead of a 302 / Flight row" do
200
+ post "/bucket/redirecting", "{}", json_headers
201
+ expect(last_response.status).to eq(200)
202
+ expect(JSON.parse(last_response.body)).to eq("$redirect" => "/posts/1")
203
+ end
204
+ end
205
+
206
+ describe "Bucket 2 — redirect open-redirect protection (review round 1)" do
207
+ # Turn Rails' open-redirect protection ON for this app (the bare test app
208
+ # doesn't `load_defaults`, so it defaults to log-and-allow). `raise_on_open_redirects`
209
+ # forces the raise path on both Rails 7.0 and 8.x.
210
+ around do |example|
211
+ previous = ActionController::Base.raise_on_open_redirects
212
+ ActionController::Base.raise_on_open_redirects = true
213
+ example.run
214
+ ensure
215
+ ActionController::Base.raise_on_open_redirects = previous
216
+ end
217
+
218
+ it "a cross-host redirect_to raises (open-redirect protection) → 500 structured, matching Rails" do
219
+ post "/bucket/redirect_external", "{}", json_headers
220
+ expect(last_response.status).to eq(500)
221
+ expect(JSON.parse(last_response.body).fetch("error_class")).to match(/RedirectError/)
222
+ end
223
+
224
+ it "honors allow_other_host: true — emits the absolute $redirect" do
225
+ post "/bucket/redirect_external_allowed", "{}", json_headers
226
+ expect(last_response.status).to eq(200)
227
+ expect(JSON.parse(last_response.body)).to eq("$redirect" => "https://allowed.example.com/x")
228
+ end
229
+ end
230
+
231
+ describe "Vary preserves a host-set value (review round 1)" do
232
+ it "appends Accept to a Vary the action set, never clobbering it" do
233
+ post "/bucket/vary_clobber", "{}", json_headers
234
+ vary = last_response.headers["Vary"]
235
+ expect(vary).to match(/\bCookie\b/)
236
+ expect(vary).to match(/\bAccept\b/)
237
+ end
238
+ end
239
+
240
+ describe "Vary edge cases (review round 2)" do
241
+ it "is set on a $redirect performed by a before_action (after-callbacks skipped)" do
242
+ post "/bucket/before_redirect", "{}", json_headers
243
+ expect(JSON.parse(last_response.body)).to eq("$redirect" => "/login")
244
+ expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
245
+ end
246
+
247
+ it "preserves a host Vary: * wildcard as-is (does not produce '*, Accept')" do
248
+ post "/bucket/vary_wildcard", "{}", json_headers
249
+ expect(last_response.headers["Vary"]).to eq("*")
250
+ end
251
+ end
252
+
253
+ describe "Bucket 2 — empty action → 204 (AC4)" do
254
+ it "returns 204 No Content with an empty body" do
255
+ post "/bucket/empty", "{}", json_headers
256
+ expect(last_response.status).to eq(204)
257
+ expect(last_response.body).to eq("")
258
+ end
259
+ end
260
+
261
+ describe "Bucket 2 — serialization failure routes through the 9.1 chain as 500 (AC5/AC9)" do
262
+ before do
263
+ # Re-prime AFTER spec_helper's global before resets @config, so strict
264
+ # sticks for the example body (local before runs after global before).
265
+ reset_config
266
+ Ruact.configure { |c| c.strict_serialization = true }
267
+ end
268
+
269
+ it "raises Ruact::SerializationError → 500 structured payload", :aggregate_failures do
270
+ post "/bucket/unserializable", "{}", json_headers
271
+ expect(last_response.status).to eq(500)
272
+ body = JSON.parse(last_response.body)
273
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
274
+ expect(body.fetch("error_class")).to eq("Ruact::SerializationError")
275
+ expect(body.fetch("message")).to match(/Cannot serialize/)
276
+ end
277
+ end
278
+
279
+ describe "Vary: Accept on every non-GET shape (AC6)" do
280
+ it "is set on the 200 ivar-JSON response" do
281
+ post "/bucket/with_ivars", "{}", json_headers
282
+ expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
283
+ end
284
+
285
+ it "is set on the 204 response" do
286
+ post "/bucket/empty", "{}", json_headers
287
+ expect(last_response.status).to eq(204)
288
+ expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
289
+ end
290
+
291
+ it "is set on the $redirect response" do
292
+ post "/bucket/redirecting", "{}", json_headers
293
+ expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
294
+ end
295
+
296
+ it "is set on the Bucket-1 Flight response too" do
297
+ post "/bucket/dual_redirecting", "{}", flight_headers
298
+ expect(last_response.headers["Vary"]).to match(/\bAccept\b/)
299
+ end
300
+ end
301
+
302
+ describe "Bucket 1 — form/navigation Flight path unchanged (AC1)" do
303
+ it "a text/x-component redirect still emits the Flight redirect row (no $redirect JSON)" do
304
+ post "/bucket/dual_redirecting", "{}", flight_headers
305
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
306
+ expect(last_response.body).to eq("0:#{JSON.generate({ 'redirectUrl' => '/posts/1',
307
+ 'redirectType' => 'push' })}\n")
308
+ end
309
+ end
310
+
311
+ describe "CSRF on Bucket 2 — host protect_from_forgery (AC7)" do
312
+ around do |example|
313
+ previous = ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection
314
+ ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = true
315
+ example.run
316
+ ensure
317
+ ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = previous
318
+ end
319
+
320
+ it "valid X-CSRF-Token → 200 + serialized ivars (full round-trip)" do
321
+ get "/bucket/csrf_token"
322
+ token = JSON.parse(last_response.body).fetch("token")
323
+ post "/bucket/protected", "{}", json_headers.merge("HTTP_X_CSRF_TOKEN" => token)
324
+ expect(last_response.status).to eq(200)
325
+ expect(JSON.parse(last_response.body)).to eq("ok" => true)
326
+ end
327
+
328
+ it "missing X-CSRF-Token → 403 structured (via the 9.1 chain)" do
329
+ post "/bucket/protected", "{}", json_headers
330
+ expect(last_response.status).to eq(403)
331
+ expect(JSON.parse(last_response.body).fetch("error_class")).to eq("ActionController::InvalidAuthenticityToken")
332
+ end
333
+
334
+ it "invalid X-CSRF-Token → 403 structured" do
335
+ post "/bucket/protected", "{}", json_headers.merge("HTTP_X_CSRF_TOKEN" => "nope-not-valid")
336
+ expect(last_response.status).to eq(403)
337
+ end
338
+
339
+ context "with forgery protection OFF (API mode)" do
340
+ around do |example|
341
+ ServerBucketSpecSupport::BucketForgeryController.allow_forgery_protection = false
342
+ example.run
343
+ end
344
+
345
+ it "accepts a tokenless request and serializes ivars" do
346
+ post "/bucket/protected", "{}", json_headers
347
+ expect(last_response.status).to eq(200)
348
+ expect(JSON.parse(last_response.body)).to eq("ok" => true)
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/server"
5
+
6
+ # Story 9.3 AC4 — the `ruact_function_name` rename-override macro. Tested via
7
+ # the concern's ClassMethods in isolation (no ActionController boot needed) so
8
+ # the validation + storage contract is pinned independently of the request
9
+ # cycle. RouteSource consumes the resulting `__ruact_function_name_overrides`.
10
+ RSpec.describe "Ruact::Server.ruact_function_name", :story_9_3 do
11
+ subject(:host) { Class.new { extend Ruact::Server::ClassMethods } }
12
+
13
+ it "stores the override keyed by action name (string)" do
14
+ host.ruact_function_name(:publish_all, as: "publishEverything")
15
+ expect(host.__ruact_function_name_overrides).to eq("publish_all" => "publishEverything")
16
+ end
17
+
18
+ it "accepts a symbol target and stringifies it" do
19
+ host.ruact_function_name(:publish_all, as: :publishEverything)
20
+ expect(host.__ruact_function_name_overrides.fetch("publish_all")).to eq("publishEverything")
21
+ end
22
+
23
+ it "starts empty and is per-class (not shared)" do
24
+ other = Class.new { extend Ruact::Server::ClassMethods }
25
+ host.ruact_function_name(:create, as: "makePost")
26
+ expect(host.__ruact_function_name_overrides).to eq("create" => "makePost")
27
+ expect(other.__ruact_function_name_overrides).to eq({})
28
+ end
29
+
30
+ it "rejects an invalid JS identifier at class-load time" do
31
+ expect do
32
+ host.ruact_function_name(:create, as: "2bad name")
33
+ end.to raise_error(Ruact::ConfigurationError, /not a valid JS identifier/)
34
+ end
35
+
36
+ it "rejects a reserved JS word" do
37
+ expect do
38
+ host.ruact_function_name(:create, as: "class")
39
+ end.to raise_error(Ruact::ConfigurationError, /reserved/)
40
+ end
41
+
42
+ it "rejects a runtime-bound name (revalidate / _makeRef)" do
43
+ expect do
44
+ host.ruact_function_name(:create, as: "revalidate")
45
+ end.to raise_error(Ruact::ConfigurationError, /reserved|ruact runtime/)
46
+ end
47
+
48
+ it "rejects the v2 runtime accessor name (_makeServerFunction)" do
49
+ expect do
50
+ host.ruact_function_name(:create, as: "_makeServerFunction")
51
+ end.to raise_error(Ruact::ConfigurationError, /reserved|ruact runtime/)
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/server_functions/backtrace_cleaner"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ RSpec.describe BacktraceCleaner, :story_8_4 do
9
+ describe "Story 8.4 — .split classifies frames by Ruact.gem_path prefix" do
10
+ let(:gem_root) { "/tmp/ruact-test-gem" }
11
+
12
+ before { allow(Ruact).to receive(:gem_path).and_return(gem_root) }
13
+
14
+ it "puts non-gem frames into :app, preserving order" do
15
+ frames = [
16
+ "/Users/dev/host/app/controllers/posts_controller.rb:12:in `create'",
17
+ "/Users/dev/host/app/models/post.rb:7:in `save!'"
18
+ ]
19
+ expect(described_class.split(frames)).to eq(
20
+ app: frames,
21
+ gem: []
22
+ )
23
+ end
24
+
25
+ it "puts frames under Ruact.gem_path into :gem, preserving order" do
26
+ frames = [
27
+ "#{gem_root}/lib/ruact/server_functions/endpoint_controller.rb:111:in `dispatch'",
28
+ "#{gem_root}/lib/ruact/server_functions/standalone_dispatcher.rb:42:in `apply'"
29
+ ]
30
+ expect(described_class.split(frames)).to eq(
31
+ app: [],
32
+ gem: frames
33
+ )
34
+ end
35
+
36
+ it "interleaves app+gem frames into separate buckets while preserving relative order in each bucket" do
37
+ frames = [
38
+ "/Users/dev/host/app/controllers/posts_controller.rb:12:in `create'",
39
+ "#{gem_root}/lib/ruact/server_functions/endpoint_controller.rb:111:in `dispatch'",
40
+ "/Users/dev/host/app/models/post.rb:7:in `save!'",
41
+ "#{gem_root}/lib/ruact/server_functions/standalone_dispatcher.rb:42:in `apply'"
42
+ ]
43
+ result = described_class.split(frames)
44
+ expect(result[:app]).to eq([frames[0], frames[2]])
45
+ expect(result[:gem]).to eq([frames[1], frames[3]])
46
+ end
47
+
48
+ it "returns { app: [], gem: [] } for nil input" do
49
+ expect(described_class.split(nil)).to eq(app: [], gem: [])
50
+ end
51
+
52
+ it "returns { app: [], gem: [] } for an empty array" do
53
+ expect(described_class.split([])).to eq(app: [], gem: [])
54
+ end
55
+
56
+ it "classifies a frame whose path is exactly Ruact.gem_path as GEM (edge case)" do
57
+ frames = ["#{gem_root}:0:in `<top>'"]
58
+ expect(described_class.split(frames)).to eq(app: [], gem: frames)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end