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.
- 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 +86 -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 +1680 -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 +89 -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 +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -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 +75 -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 +446 -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 +429 -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 +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -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 +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- 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 +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -16,37 +16,37 @@ module Ruact
|
|
|
16
16
|
@secret = "top-secret"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
ruact_props :id, :title
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
describe "
|
|
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
|
-
|
|
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 "
|
|
35
|
+
describe "ruact_serialize (AC#1)" do
|
|
36
36
|
it "returns only declared props" do
|
|
37
37
|
obj = serializable_class.new
|
|
38
|
-
expect(obj.
|
|
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.
|
|
43
|
+
expect(obj.ruact_serialize.keys).not_to include("secret")
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
describe "
|
|
47
|
+
describe "ruact_props_list" do
|
|
48
48
|
it "returns the declared prop names as symbols" do
|
|
49
|
-
expect(serializable_class.
|
|
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
|