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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 9.1 — request-cycle + unit spec for the Story 8.5 upload guard
|
|
4
|
+
# RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
|
|
5
|
+
# `server_functions/endpoint_controller_upload_spec.rb` (removed in the same
|
|
6
|
+
# commit — AC5). Pins, against REAL host-controller routes:
|
|
7
|
+
#
|
|
8
|
+
# - oversized multipart → 413 + structured `upload_limit` payload through
|
|
9
|
+
# the concern's salvaged chain (inventory A7, A14, B7, B9, B11)
|
|
10
|
+
# - guard fires BEFORE CSRF verification on correctly ordered hosts —
|
|
11
|
+
# 413, not 403 (B6 / Pitfall #4)
|
|
12
|
+
# - the three carve-outs: nil limit (B2), content-type allowlist (B3),
|
|
13
|
+
# absent Content-Length (B4); off-by-one equal-passes (B5)
|
|
14
|
+
# - D1: the 413 renders structured for native form submits too (no
|
|
15
|
+
# function-call Accept header) — the documented UploadTooLargeError
|
|
16
|
+
# exception to the re-raise rule
|
|
17
|
+
# - D2: GET/HEAD requests skip the guard entirely (C5)
|
|
18
|
+
# - small uploads reach the action as ActionDispatch::Http::UploadedFile
|
|
19
|
+
# with metadata + mixed non-file fields intact (transplant sanity)
|
|
20
|
+
#
|
|
21
|
+
# Mounts on the shared Story-7.9 Rails app; deliberately independent of the
|
|
22
|
+
# v1 `server_functions/dispatch_request_spec.rb` (demolished in Story 9.9).
|
|
23
|
+
|
|
24
|
+
require "action_controller/railtie"
|
|
25
|
+
require "action_view/railtie"
|
|
26
|
+
|
|
27
|
+
require "spec_helper"
|
|
28
|
+
require "rack/test"
|
|
29
|
+
require "tempfile"
|
|
30
|
+
|
|
31
|
+
require "ruact/server"
|
|
32
|
+
|
|
33
|
+
require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
|
|
34
|
+
|
|
35
|
+
SERVER_UPLOAD_SPEC_PNG_PATH =
|
|
36
|
+
File.expand_path("../support/fixtures/pixel.png", __dir__).freeze
|
|
37
|
+
SERVER_UPLOAD_SPEC_PNG_BYTES = File.binread(SERVER_UPLOAD_SPEC_PNG_PATH).freeze
|
|
38
|
+
|
|
39
|
+
module ServerUploadSpecSupport
|
|
40
|
+
class UploadsServerController < ActionController::Base
|
|
41
|
+
include Ruact::Server
|
|
42
|
+
|
|
43
|
+
def create_upload
|
|
44
|
+
uploaded = params[:cover]
|
|
45
|
+
render json: {
|
|
46
|
+
"title" => params[:title].to_s,
|
|
47
|
+
"uploaded_class" => uploaded.class.name,
|
|
48
|
+
"original_filename" => uploaded.respond_to?(:original_filename) ? uploaded.original_filename : nil,
|
|
49
|
+
"byte_size" => uploaded.respond_to?(:read) ? uploaded.read.bytesize : nil
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# CSRF-enforcing host for the Pitfall #4 ordering proof (B6) — forgery is
|
|
55
|
+
# flipped on per-example via the class-level `allow_forgery_protection`.
|
|
56
|
+
class ForgeryUploadsServerController < ActionController::Base
|
|
57
|
+
include Ruact::Server
|
|
58
|
+
|
|
59
|
+
protect_from_forgery with: :exception
|
|
60
|
+
|
|
61
|
+
def create_protected_upload
|
|
62
|
+
render json: { "ok" => true }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if defined?(ControllerRequestSpecSupport) &&
|
|
68
|
+
!ControllerRequestSpecSupport.instance_variable_get(:@__ruact_server_upload_routes_appended)
|
|
69
|
+
ControllerRequestSpecSupport.instance_variable_set(:@__ruact_server_upload_routes_appended, true)
|
|
70
|
+
ControllerRequestSpecSupport.app_class.routes.append do
|
|
71
|
+
post "/server_upload", to: "server_upload_spec_support/uploads_server#create_upload"
|
|
72
|
+
post "/server_upload/protected", to: "server_upload_spec_support/forgery_uploads_server#create_protected_upload"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
RSpec.describe "Story 9.1: Ruact::Server concern — salvaged upload guard", :story_9_1 do
|
|
77
|
+
include Rack::Test::Methods
|
|
78
|
+
|
|
79
|
+
let(:app_class) { ControllerRequestSpecSupport.app_class }
|
|
80
|
+
let(:app) { app_class.instance }
|
|
81
|
+
|
|
82
|
+
let(:function_call_accept) { { "HTTP_ACCEPT" => "application/json" } }
|
|
83
|
+
|
|
84
|
+
before do
|
|
85
|
+
Rails.logger = Logger.new(IO::NULL)
|
|
86
|
+
ControllerRequestSpecSupport.boot!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_oversized_tempfile
|
|
90
|
+
large = Tempfile.new(["big", ".bin"])
|
|
91
|
+
large.binmode
|
|
92
|
+
large.write("x" * 4096) # 4 KB > the 1 KB caps below
|
|
93
|
+
large.rewind
|
|
94
|
+
yield large
|
|
95
|
+
ensure
|
|
96
|
+
large.close
|
|
97
|
+
large.unlink
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def cap_max_upload_bytes(value)
|
|
101
|
+
# spec_helper's global before-hook resets @config; re-prime AFTER it so
|
|
102
|
+
# the tight cap sticks for the example body (same dance as the v1 spec).
|
|
103
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
104
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
105
|
+
Ruact.configure { |c| c.max_upload_bytes = value }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe "transplant sanity — small multipart upload reaches the action" do
|
|
109
|
+
it "params[:cover] arrives as ActionDispatch::Http::UploadedFile with metadata; mixed fields intact" do
|
|
110
|
+
post "/server_upload",
|
|
111
|
+
{
|
|
112
|
+
"title" => "My Post",
|
|
113
|
+
"cover" => Rack::Test::UploadedFile.new(SERVER_UPLOAD_SPEC_PNG_PATH, "image/png")
|
|
114
|
+
},
|
|
115
|
+
function_call_accept
|
|
116
|
+
expect(last_response.status).to eq(200)
|
|
117
|
+
body = JSON.parse(last_response.body)
|
|
118
|
+
expect(body.fetch("uploaded_class")).to eq("ActionDispatch::Http::UploadedFile")
|
|
119
|
+
expect(body.fetch("original_filename")).to eq("pixel.png")
|
|
120
|
+
expect(body.fetch("byte_size")).to eq(SERVER_UPLOAD_SPEC_PNG_BYTES.bytesize)
|
|
121
|
+
expect(body.fetch("title")).to eq("My Post")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe "oversized multipart → 413 + structured upload_limit payload (A7/A14/B7/B9/B11)" do
|
|
126
|
+
before { cap_max_upload_bytes(1024) }
|
|
127
|
+
|
|
128
|
+
it "function-call request: 413 with discriminator, real action_name, and the dev-only upload_limit block" do
|
|
129
|
+
with_oversized_tempfile do |large|
|
|
130
|
+
post "/server_upload",
|
|
131
|
+
{
|
|
132
|
+
"title" => "Too big",
|
|
133
|
+
"cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
|
|
134
|
+
},
|
|
135
|
+
function_call_accept
|
|
136
|
+
expect(last_response.status).to eq(413)
|
|
137
|
+
body = JSON.parse(last_response.body)
|
|
138
|
+
expect(body).to include(
|
|
139
|
+
"_ruact_server_action_error" => true,
|
|
140
|
+
"error_class" => "Ruact::UploadTooLargeError",
|
|
141
|
+
# A14 — the controller's REAL action name, no registry-symbol
|
|
142
|
+
# fallback dance (the v1 path_parameters[:name] fallback is gone).
|
|
143
|
+
"action_name" => "create_upload"
|
|
144
|
+
)
|
|
145
|
+
expect(body.fetch("upload_limit")).to include("limit_bytes" => 1024)
|
|
146
|
+
# B7 — received_bytes is the WIRE Content-Length: file bytes PLUS
|
|
147
|
+
# multipart boundary + field overhead.
|
|
148
|
+
expect(body.fetch("upload_limit").fetch("received_bytes")).to be > 4096
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "D1 — a native form submit (no function-call Accept) ALSO gets the structured 413" do
|
|
153
|
+
# UploadTooLargeError is the documented exception to the re-raise rule:
|
|
154
|
+
# the guard only exists on requests that opted into the concern, and a
|
|
155
|
+
# meaningful 413 beats a re-raised 500 for every caller shape.
|
|
156
|
+
with_oversized_tempfile do |large|
|
|
157
|
+
post "/server_upload",
|
|
158
|
+
{
|
|
159
|
+
"title" => "Too big, browser shape",
|
|
160
|
+
"cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
|
|
161
|
+
}
|
|
162
|
+
expect(last_response.status).to eq(413)
|
|
163
|
+
body = JSON.parse(last_response.body)
|
|
164
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
165
|
+
expect(body.fetch("error_class")).to eq("Ruact::UploadTooLargeError")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
describe "guard fires BEFORE CSRF verification (B6 / Pitfall #4)" do
|
|
171
|
+
around do |example|
|
|
172
|
+
previous = ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection
|
|
173
|
+
ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection = true
|
|
174
|
+
example.run
|
|
175
|
+
ensure
|
|
176
|
+
ServerUploadSpecSupport::ForgeryUploadsServerController.allow_forgery_protection = previous
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
before { cap_max_upload_bytes(1024) }
|
|
180
|
+
|
|
181
|
+
it "oversized request WITHOUT a CSRF token returns 413, not 403" do
|
|
182
|
+
with_oversized_tempfile do |large|
|
|
183
|
+
post "/server_upload/protected",
|
|
184
|
+
{
|
|
185
|
+
"title" => "Too big, no token",
|
|
186
|
+
"cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
|
|
187
|
+
},
|
|
188
|
+
function_call_accept
|
|
189
|
+
expect(last_response.status).to eq(413)
|
|
190
|
+
expect(JSON.parse(last_response.body).fetch("error_class")).to eq("Ruact::UploadTooLargeError")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
describe "carve-out — max_upload_bytes = nil disables the gem-side guard (B2)" do
|
|
196
|
+
before { cap_max_upload_bytes(nil) }
|
|
197
|
+
|
|
198
|
+
it "a body of any size flows through to the action" do
|
|
199
|
+
with_oversized_tempfile do |large|
|
|
200
|
+
post "/server_upload",
|
|
201
|
+
{
|
|
202
|
+
"title" => "no cap",
|
|
203
|
+
"cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream")
|
|
204
|
+
},
|
|
205
|
+
function_call_accept
|
|
206
|
+
expect(last_response.status).to eq(200)
|
|
207
|
+
expect(JSON.parse(last_response.body).fetch("uploaded_class")).to eq("ActionDispatch::Http::UploadedFile")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
describe "carve-out — application/json bypasses the guard (B3, request-cycle)" do
|
|
213
|
+
before { cap_max_upload_bytes(1024) }
|
|
214
|
+
|
|
215
|
+
it "a 4 KB JSON body passes the guard and reaches the action" do
|
|
216
|
+
payload = { "title" => "big json", "blob" => "x" * 4096 }
|
|
217
|
+
post "/server_upload", payload.to_json,
|
|
218
|
+
{ "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" }
|
|
219
|
+
expect(last_response.status).to eq(200)
|
|
220
|
+
# No file in a JSON body — the action reports the params leaf class.
|
|
221
|
+
expect(JSON.parse(last_response.body).fetch("uploaded_class")).to eq("NilClass")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Unit-level coverage of the short-circuit branches — directly against a
|
|
226
|
+
# host controller instance, mirroring the v1 unit block but with the
|
|
227
|
+
# concern's D2 verb gate in play (request.get? / request.head?).
|
|
228
|
+
describe "Unit — __ruact_enforce_upload_limit! short-circuits (B2–B5, C5/D2)" do
|
|
229
|
+
let(:controller) { ServerUploadSpecSupport::UploadsServerController.new }
|
|
230
|
+
|
|
231
|
+
def with_max_upload_bytes(value)
|
|
232
|
+
cap_max_upload_bytes(value)
|
|
233
|
+
yield
|
|
234
|
+
ensure
|
|
235
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
236
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def stub_request(content_type:, content_length:, http_method: "POST")
|
|
240
|
+
request = instance_double(
|
|
241
|
+
ActionDispatch::Request,
|
|
242
|
+
content_length: content_length,
|
|
243
|
+
get?: http_method == "GET",
|
|
244
|
+
head?: http_method == "HEAD"
|
|
245
|
+
)
|
|
246
|
+
allow(request).to receive(:content_mime_type)
|
|
247
|
+
.and_return(content_type ? Mime::Type.lookup(content_type) : nil)
|
|
248
|
+
allow(controller).to receive(:request).and_return(request)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "B4 — nil Content-Length (chunked transfer) bypasses the guard" do
|
|
252
|
+
with_max_upload_bytes(1024) do
|
|
253
|
+
stub_request(content_type: "multipart/form-data", content_length: nil)
|
|
254
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "B2 — max_upload_bytes = nil bypasses the guard even with oversized Content-Length" do
|
|
259
|
+
with_max_upload_bytes(nil) do
|
|
260
|
+
stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024)
|
|
261
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "B3 — application/json content type bypasses the guard" do
|
|
266
|
+
with_max_upload_bytes(1024) do
|
|
267
|
+
stub_request(content_type: "application/json", content_length: 10 * 1024 * 1024)
|
|
268
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "B3 — missing content type (nil) bypasses the guard" do
|
|
273
|
+
with_max_upload_bytes(1024) do
|
|
274
|
+
stub_request(content_type: nil, content_length: 10_000)
|
|
275
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "B3/B8 — application/x-www-form-urlencoded is subject to the guard" do
|
|
280
|
+
with_max_upload_bytes(1024) do
|
|
281
|
+
stub_request(content_type: "application/x-www-form-urlencoded", content_length: 2048)
|
|
282
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }
|
|
283
|
+
.to raise_error(Ruact::UploadTooLargeError) do |error|
|
|
284
|
+
expect(error.received_bytes).to eq(2048)
|
|
285
|
+
expect(error.limit_bytes).to eq(1024)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "B5 — Content-Length exactly equal to the limit passes the guard (off-by-one)" do
|
|
291
|
+
with_max_upload_bytes(1024) do
|
|
292
|
+
stub_request(content_type: "multipart/form-data", content_length: 1024)
|
|
293
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it "C5/D2 — a GET request skips the guard even with an oversized multipart body" do
|
|
298
|
+
with_max_upload_bytes(1024) do
|
|
299
|
+
stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024, http_method: "GET")
|
|
300
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it "C5/D2 — a HEAD request skips the guard" do
|
|
305
|
+
with_max_upload_bytes(1024) do
|
|
306
|
+
stub_request(content_type: "multipart/form-data", content_length: 10 * 1024 * 1024, http_method: "HEAD")
|
|
307
|
+
expect { controller.send(:__ruact_enforce_upload_limit!) }.not_to raise_error
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -5,42 +5,48 @@ require "active_support/core_ext/string/output_safety"
|
|
|
5
5
|
|
|
6
6
|
module Ruact
|
|
7
7
|
RSpec.describe ViewHelper do
|
|
8
|
+
let(:render_context) { RenderContext.new }
|
|
8
9
|
let(:helper_obj) do
|
|
9
10
|
obj = Object.new
|
|
10
11
|
obj.extend(described_class)
|
|
12
|
+
obj.instance_variable_set(:@ruact_render_context, render_context)
|
|
11
13
|
obj
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
expect(
|
|
21
|
-
expect(ComponentRegistry.components.length).to eq(1)
|
|
22
|
-
expect(ComponentRegistry.components.first[:name]).to eq("NavBar")
|
|
23
|
-
expect(ComponentRegistry.components.first[:props]).to eq({ "currentUser" => 1 })
|
|
16
|
+
describe "#__ruact_component__" do
|
|
17
|
+
it "registers the component in the render context and returns an HTML comment" do
|
|
18
|
+
result = helper_obj.__ruact_component__("NavBar", { "currentUser" => 1 })
|
|
19
|
+
expect(result).to match(/<!-- __RUACT_\d+__ -->/)
|
|
20
|
+
expect(render_context.components.length).to eq(1)
|
|
21
|
+
expect(render_context.components.first[:name]).to eq("NavBar")
|
|
22
|
+
expect(render_context.components.first[:props]).to eq({ "currentUser" => 1 })
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
it "returns an html_safe string so ActionView does not escape the comment" do
|
|
27
|
-
result = helper_obj.
|
|
26
|
+
result = helper_obj.__ruact_component__("Button", {})
|
|
28
27
|
expect(result).to be_html_safe
|
|
29
28
|
end
|
|
30
29
|
|
|
31
30
|
it "uses incrementing token numbers for successive registrations" do
|
|
32
|
-
token0 = helper_obj.
|
|
33
|
-
token1 = helper_obj.
|
|
34
|
-
expect(token0).to include("
|
|
35
|
-
expect(token1).to include("
|
|
31
|
+
token0 = helper_obj.__ruact_component__("Foo", {})
|
|
32
|
+
token1 = helper_obj.__ruact_component__("Bar", {})
|
|
33
|
+
expect(token0).to include("__RUACT_0__")
|
|
34
|
+
expect(token1).to include("__RUACT_1__")
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
it "passes props through to the registry entry" do
|
|
39
|
-
helper_obj.
|
|
40
|
-
entry =
|
|
38
|
+
helper_obj.__ruact_component__("LikeButton", { "postId" => 42, "label" => "Like" })
|
|
39
|
+
entry = render_context.components.first
|
|
41
40
|
expect(entry[:props]["postId"]).to eq(42)
|
|
42
41
|
expect(entry[:props]["label"]).to eq("Like")
|
|
43
42
|
end
|
|
43
|
+
|
|
44
|
+
it "raises a clear error when called outside a ruact_render flow" do
|
|
45
|
+
bare = Object.new
|
|
46
|
+
bare.extend(described_class)
|
|
47
|
+
expect { bare.__ruact_component__("NavBar", {}) }
|
|
48
|
+
.to raise_error(Ruact::Error, /__ruact_component__ called outside a ruact_render flow/)
|
|
49
|
+
end
|
|
44
50
|
end
|
|
45
51
|
end
|
|
46
52
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "simplecov"
|
|
4
|
+
require "simplecov-lcov"
|
|
5
|
+
|
|
6
|
+
SimpleCov::Formatter::LcovFormatter.config do |c|
|
|
7
|
+
c.report_with_single_file = true
|
|
8
|
+
c.single_report_path = "coverage/lcov.info"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
SimpleCov.start do
|
|
12
|
+
enable_coverage :branch
|
|
13
|
+
primary_coverage :line
|
|
14
|
+
formatter SimpleCov::Formatter::MultiFormatter.new(
|
|
15
|
+
[
|
|
16
|
+
SimpleCov::Formatter::HTMLFormatter,
|
|
17
|
+
SimpleCov::Formatter::LcovFormatter
|
|
18
|
+
]
|
|
19
|
+
)
|
|
20
|
+
add_filter %r{^/spec/}
|
|
21
|
+
add_filter %r{^/bin/}
|
|
22
|
+
add_filter %r{lib/generators/.+/templates/}
|
|
23
|
+
add_filter "lib/ruact/version.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
3
26
|
require "logger"
|
|
4
27
|
require "ruact"
|
|
5
28
|
|
|
6
|
-
Dir[File.join(__dir__, "support", "**", "*.rb")].each
|
|
29
|
+
Dir[File.join(__dir__, "support", "**", "*.rb")].each do |f|
|
|
30
|
+
next if f.end_with?("_spec.rb") # spec files are loaded by the RSpec runner; avoid double registration
|
|
31
|
+
|
|
32
|
+
require f
|
|
33
|
+
end
|
|
7
34
|
|
|
8
35
|
RSpec.configure do |config|
|
|
9
36
|
config.order = :random
|
|
@@ -13,4 +40,28 @@ RSpec.configure do |config|
|
|
|
13
40
|
config.mock_with :rspec do |mocks|
|
|
14
41
|
mocks.verify_partial_doubles = true
|
|
15
42
|
end
|
|
43
|
+
|
|
44
|
+
# Story 7.3: Ruact.config is a frozen singleton with a boot-state flag for the
|
|
45
|
+
# re-configuration warning. Reset both before every example so the boot flag
|
|
46
|
+
# cannot leak between specs — otherwise the AC3 warning may fire into a
|
|
47
|
+
# Rails.logger that has become a per-example RSpec double from an earlier
|
|
48
|
+
# spec, producing "originally created in one example but has leaked" failures
|
|
49
|
+
# under random order.
|
|
50
|
+
config.before do
|
|
51
|
+
Ruact.instance_variable_set(:@config, nil)
|
|
52
|
+
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
53
|
+
# Story 8.0a: both server-function registries are lazy-initialized module
|
|
54
|
+
# singletons. Wipe them between examples so register/clear specs in one file
|
|
55
|
+
# cannot bleed entries into another under random order.
|
|
56
|
+
Ruact.instance_variable_set(:@action_registry, nil)
|
|
57
|
+
Ruact.instance_variable_set(:@query_registry, nil)
|
|
58
|
+
# Story 9.3: specs that set `Rails.logger = instance_double(Logger)` (e.g.
|
|
59
|
+
# railtie_spec, configuration_spec) leave a per-example double on the global
|
|
60
|
+
# after they finish; rspec then refuses to call it from the NEXT example.
|
|
61
|
+
# That was harmless until the codegen/rake paths began reading `Rails.logger`
|
|
62
|
+
# (the `[ruact] codegen: exposing …` line). Reset it to a real, silent logger
|
|
63
|
+
# before every example so no leaked double survives — examples that need a
|
|
64
|
+
# specific logger set their own in their own `before` (which runs after this).
|
|
65
|
+
Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
|
|
66
|
+
end
|
|
16
67
|
end
|
|
Binary file
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Test-support utilities. Code under `Ruact::Spec` is consumed only by the
|
|
8
|
+
# gem's own RSpec suite; it is not part of the public API and may change
|
|
9
|
+
# shape across stories without a deprecation cycle.
|
|
10
|
+
module Spec
|
|
11
|
+
# Raised when {FlightWireParser.parse} encounters input it cannot decode.
|
|
12
|
+
# The message names the byte offset of the unparseable row so the spec
|
|
13
|
+
# author can locate the problem in a printed wire string.
|
|
14
|
+
class FlightWireParseError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Parses a Flight wire byte string into an ordered array of row records.
|
|
17
|
+
#
|
|
18
|
+
# Used by the structural Flight RSpec matchers
|
|
19
|
+
# (`match_flight_structure` / `include_flight_row`) to assert on parsed
|
|
20
|
+
# semantics rather than literal bytes. Pure function — no I/O, no global
|
|
21
|
+
# state, no `Thread.current`.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
|
|
25
|
+
# Ruact::Spec::FlightWireParser.parse(wire)
|
|
26
|
+
# # => [
|
|
27
|
+
# # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
|
|
28
|
+
# # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
|
|
29
|
+
# # ]
|
|
30
|
+
class FlightWireParser
|
|
31
|
+
# Parse a complete Flight wire byte string.
|
|
32
|
+
#
|
|
33
|
+
# @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
|
|
34
|
+
# @return [Array<Hash>] one hash per row, in wire order. See class docs
|
|
35
|
+
# for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
|
|
36
|
+
# @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
|
|
37
|
+
def self.parse(wire)
|
|
38
|
+
rows = []
|
|
39
|
+
scanner = StringScanner.new(wire)
|
|
40
|
+
|
|
41
|
+
until scanner.eos?
|
|
42
|
+
start_offset = scanner.pos
|
|
43
|
+
rows << parse_row(scanner, start_offset)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rows
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse_row(scanner, start_offset)
|
|
50
|
+
# Hint rows have no ID: ":H<code><json>\n"
|
|
51
|
+
if scanner.peek(2) == ":H"
|
|
52
|
+
scanner.pos += 2
|
|
53
|
+
code = scanner.getch
|
|
54
|
+
raise_parse_error(start_offset, "missing hint code char") if code.nil?
|
|
55
|
+
|
|
56
|
+
json = read_to_newline(scanner, start_offset)
|
|
57
|
+
return {
|
|
58
|
+
id: nil,
|
|
59
|
+
class: :hint,
|
|
60
|
+
payload: [code, parse_json(json, start_offset)],
|
|
61
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
|
|
66
|
+
scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
|
|
67
|
+
id = hex.to_i(16)
|
|
68
|
+
|
|
69
|
+
case scanner.peek(1)
|
|
70
|
+
when "I" then parse_tagged(:import, scanner, id, start_offset)
|
|
71
|
+
when "T" then parse_text_row(scanner, id, start_offset)
|
|
72
|
+
when "E" then parse_tagged(:error, scanner, id, start_offset)
|
|
73
|
+
else parse_model_row(scanner, id, start_offset)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.parse_tagged(klass, scanner, id, start_offset)
|
|
78
|
+
scanner.getch # consume the tag byte (I or E)
|
|
79
|
+
json = read_to_newline(scanner, start_offset)
|
|
80
|
+
{
|
|
81
|
+
id: id,
|
|
82
|
+
class: klass,
|
|
83
|
+
payload: parse_json(json, start_offset),
|
|
84
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.parse_model_row(scanner, id, start_offset)
|
|
89
|
+
json = read_to_newline(scanner, start_offset)
|
|
90
|
+
{
|
|
91
|
+
id: id,
|
|
92
|
+
class: :model,
|
|
93
|
+
payload: parse_json(json, start_offset),
|
|
94
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.parse_text_row(scanner, id, start_offset)
|
|
99
|
+
scanner.getch # consume "T"
|
|
100
|
+
len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
|
|
101
|
+
scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
|
|
102
|
+
len = len_hex.to_i(16)
|
|
103
|
+
|
|
104
|
+
text = scanner.peek(len)
|
|
105
|
+
raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
|
|
106
|
+
|
|
107
|
+
scanner.pos += len
|
|
108
|
+
{
|
|
109
|
+
id: id,
|
|
110
|
+
class: :text,
|
|
111
|
+
payload: text,
|
|
112
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.read_to_newline(scanner, start_offset)
|
|
117
|
+
line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
|
|
118
|
+
line.chomp
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.parse_json(str, offset)
|
|
122
|
+
JSON.parse(str)
|
|
123
|
+
rescue JSON::ParserError => e
|
|
124
|
+
raise_parse_error(offset, "invalid JSON: #{e.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.raise_parse_error(offset, reason)
|
|
128
|
+
raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
|
|
132
|
+
:read_to_newline, :parse_json, :raise_parse_error
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "flight_wire_parser"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module Spec
|
|
8
|
+
RSpec.describe FlightWireParser do
|
|
9
|
+
describe ".parse" do
|
|
10
|
+
it "parses a single model row" do
|
|
11
|
+
rows = described_class.parse(%(0:{"className":"box"}\n))
|
|
12
|
+
|
|
13
|
+
expect(rows).to eq([
|
|
14
|
+
{
|
|
15
|
+
id: 0,
|
|
16
|
+
class: :model,
|
|
17
|
+
payload: { "className" => "box" },
|
|
18
|
+
raw: %(0:{"className":"box"}\n)
|
|
19
|
+
}
|
|
20
|
+
])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "parses a single import row and decodes the hex id" do
|
|
24
|
+
rows = described_class.parse(%(a:I["/L.jsx","L",["/L.jsx"]]\n))
|
|
25
|
+
|
|
26
|
+
expect(rows.length).to eq(1)
|
|
27
|
+
expect(rows.first).to include(
|
|
28
|
+
id: 10,
|
|
29
|
+
class: :import,
|
|
30
|
+
payload: ["/L.jsx", "L", ["/L.jsx"]]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "parses a single error row" do
|
|
35
|
+
rows = described_class.parse(%(2:E{"message":"boom"}\n))
|
|
36
|
+
|
|
37
|
+
expect(rows.first).to include(
|
|
38
|
+
id: 2,
|
|
39
|
+
class: :error,
|
|
40
|
+
payload: { "message" => "boom" }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "parses a hint row with no id" do
|
|
45
|
+
rows = described_class.parse(%(:HL"/preload.js"\n))
|
|
46
|
+
|
|
47
|
+
expect(rows.first).to include(
|
|
48
|
+
id: nil,
|
|
49
|
+
class: :hint,
|
|
50
|
+
payload: ["L", "/preload.js"]
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "parses a T row of exactly LARGE_TEXT_THRESHOLD bytes followed by a model row that references it" do
|
|
55
|
+
large_text = "a" * 1024
|
|
56
|
+
wire = "1:T#{1024.to_s(16)},#{large_text}0:\"$T1\"\n"
|
|
57
|
+
|
|
58
|
+
rows = described_class.parse(wire)
|
|
59
|
+
|
|
60
|
+
expect(rows.length).to eq(2)
|
|
61
|
+
expect(rows[0]).to include(id: 1, class: :text, payload: large_text)
|
|
62
|
+
expect(rows[1]).to include(id: 0, class: :model, payload: "$T1")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "parses a mixed-row sequence preserving wire order" do
|
|
66
|
+
wire = %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n)
|
|
67
|
+
|
|
68
|
+
rows = described_class.parse(wire)
|
|
69
|
+
|
|
70
|
+
expect(rows.map { |r| [r[:id], r[:class]] }).to eq([[1, :import], [0, :model]])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "parses empty input as an empty array" do
|
|
74
|
+
expect(described_class.parse("")).to eq([])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "raises FlightWireParseError naming the byte offset on malformed JSON" do
|
|
78
|
+
wire = %(0:{not-json}\n)
|
|
79
|
+
|
|
80
|
+
expect { described_class.parse(wire) }
|
|
81
|
+
.to raise_error(FlightWireParseError, /cannot parse row at offset \d+/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "raises FlightWireParseError when a T row is truncated" do
|
|
85
|
+
wire = "0:T#{10.to_s(16)},abc" # claims 16 bytes but provides 3
|
|
86
|
+
|
|
87
|
+
expect { described_class.parse(wire) }
|
|
88
|
+
.to raise_error(FlightWireParseError, /cannot parse row at offset 0: T row truncated/)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|