ruact 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 9.1 — unit surface of the `Ruact::Server` concern (route-driven
|
|
4
|
+
# redesign, Phase A). Pins:
|
|
5
|
+
#
|
|
6
|
+
# - AC1 "and nothing else": including the concern installs EXACTLY two
|
|
7
|
+
# `rescue_from` handlers + one prepended before_action, adds no public
|
|
8
|
+
# instance methods, and registers nothing in the v1 registries (codegen
|
|
9
|
+
# exposure is Story 9.3's job — the concern is a pure marker + salvage
|
|
10
|
+
# host until then).
|
|
11
|
+
# - AC2 / D3: the `__ruact_function_call?` predicate matrix — the single
|
|
12
|
+
# named discrimination point Story 9.2 reuses. Keyed on the raw `Accept`
|
|
13
|
+
# header containing `application/json` (what the 8.1 runtime sends on
|
|
14
|
+
# every `_makeRef` fetch); deliberately NOT `request.format`, which is
|
|
15
|
+
# influenced by path extensions and `params[:format]`.
|
|
16
|
+
#
|
|
17
|
+
# Request-cycle behavior (error chain, upload guard) is pinned by
|
|
18
|
+
# `server_rescue_request_spec.rb` / `server_upload_request_spec.rb`.
|
|
19
|
+
|
|
20
|
+
require "action_controller/railtie"
|
|
21
|
+
|
|
22
|
+
require "spec_helper"
|
|
23
|
+
require "open3"
|
|
24
|
+
|
|
25
|
+
require "ruact/server"
|
|
26
|
+
|
|
27
|
+
module ServerConcernUnitSupport
|
|
28
|
+
# Baseline for "nothing else" comparisons.
|
|
29
|
+
class PlainController < ActionController::Base
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class ConcernController < ActionController::Base
|
|
33
|
+
include Ruact::Server
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RSpec.describe Ruact::Server, :story_9_1 do
|
|
38
|
+
describe "Story 9.1 — installation surface (AC1: the salvaged chains and nothing else)" do
|
|
39
|
+
it "installs exactly the two salvaged rescue_from handlers, in the v1 registration order" do
|
|
40
|
+
# Pitfall #1 parity: StandardError first, explicit
|
|
41
|
+
# InvalidAuthenticityToken second — the later registration wins the
|
|
42
|
+
# most-recently-registered walk, preempting Rails' default
|
|
43
|
+
# handle_unverified_request for CSRF failures.
|
|
44
|
+
expect(ServerConcernUnitSupport::PlainController.rescue_handlers).to eq([])
|
|
45
|
+
expect(ServerConcernUnitSupport::ConcernController.rescue_handlers.map(&:first)).to eq(
|
|
46
|
+
["StandardError", "ActionController::InvalidAuthenticityToken"]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "prepends the upload guard as the FIRST before_action (Pitfall #4 ordering)" do
|
|
51
|
+
before_filters = ServerConcernUnitSupport::ConcernController
|
|
52
|
+
._process_action_callbacks
|
|
53
|
+
.select { |callback| callback.kind == :before }
|
|
54
|
+
.map(&:filter)
|
|
55
|
+
expect(before_filters.first).to eq(:__ruact_enforce_upload_limit!)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "adds NO public instance methods to the host (predicate + handlers are private)" do
|
|
59
|
+
added = ServerConcernUnitSupport::ConcernController.public_instance_methods -
|
|
60
|
+
ServerConcernUnitSupport::PlainController.public_instance_methods
|
|
61
|
+
expect(added).to eq([])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "registers nothing in the v1 registries (codegen exposure is Story 9.3, not 9.1)" do
|
|
65
|
+
expect(Ruact.action_registry.entries).to be_empty
|
|
66
|
+
expect(Ruact.query_registry.entries).to be_empty
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "keeps INHERITED host rescue_from handlers more recent than its own (review patch)",
|
|
70
|
+
:aggregate_failures do
|
|
71
|
+
# Rails resolves handlers by walking `rescue_handlers` from the most
|
|
72
|
+
# recently registered entry backwards. The concern therefore places its
|
|
73
|
+
# entries at the FRONT of the array, so every host handler — inherited
|
|
74
|
+
# from a parent class or declared after the include — stays more recent
|
|
75
|
+
# and keeps precedence.
|
|
76
|
+
parent = Class.new(ActionController::Base) do
|
|
77
|
+
rescue_from ArgumentError, with: :host_handler
|
|
78
|
+
end
|
|
79
|
+
child = Class.new(parent) { include Ruact::Server }
|
|
80
|
+
expect(child.rescue_handlers.map(&:first)).to eq(
|
|
81
|
+
["StandardError", "ActionController::InvalidAuthenticityToken", "ArgumentError"]
|
|
82
|
+
)
|
|
83
|
+
# The parent's own registry is untouched (class_attribute write lands
|
|
84
|
+
# on the child only).
|
|
85
|
+
expect(parent.rescue_handlers.map(&:first)).to eq(["ArgumentError"])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe "Story 9.1 — standalone load path (review patch)" do
|
|
90
|
+
it "a direct require \"ruact/server\" resolves Ruact.config and the error constants" do
|
|
91
|
+
lib = File.expand_path("../../lib", __dir__)
|
|
92
|
+
script = <<~RUBY
|
|
93
|
+
require "ruact/server"
|
|
94
|
+
exit 1 unless defined?(Ruact::Server)
|
|
95
|
+
exit 2 unless defined?(Ruact::UploadTooLargeError)
|
|
96
|
+
exit 3 unless Ruact.config.respond_to?(:max_upload_bytes)
|
|
97
|
+
exit 4 unless defined?(Ruact::ServerFunctions::ErrorRendering)
|
|
98
|
+
RUBY
|
|
99
|
+
_stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", lib, "-e", script)
|
|
100
|
+
expect(status).to be_success, "standalone require failed (exit #{status.exitstatus}): #{stderr}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Simplified Story 9.1 contract — the runtime sends the exact
|
|
105
|
+
# `Accept: application/json` shape, and that exact header is the only
|
|
106
|
+
# JSON-Accept signal this concern recognizes.
|
|
107
|
+
describe "Story 9.1 — __ruact_json_accept? exact-header matrix" do
|
|
108
|
+
let(:controller) { ServerConcernUnitSupport::ConcernController.new }
|
|
109
|
+
|
|
110
|
+
def stub_accept_header(value)
|
|
111
|
+
request = instance_double(ActionDispatch::Request)
|
|
112
|
+
allow(request).to receive(:headers).and_return({ "Accept" => value })
|
|
113
|
+
allow(controller).to receive(:request).and_return(request)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "is true for the runtime's exact shape (Accept: application/json)" do
|
|
117
|
+
stub_accept_header("application/json")
|
|
118
|
+
expect(controller.send(:__ruact_json_accept?)).to be(true)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "is false for a composite Accept header" do
|
|
122
|
+
stub_accept_header("application/json, text/plain, */*")
|
|
123
|
+
expect(controller.send(:__ruact_json_accept?)).to be(false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "is false for browser navigation Accept headers" do
|
|
127
|
+
stub_accept_header("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
128
|
+
expect(controller.send(:__ruact_json_accept?)).to be(false)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "is false for Flight requests (Accept: text/x-component)" do
|
|
132
|
+
stub_accept_header("text/x-component")
|
|
133
|
+
expect(controller.send(:__ruact_json_accept?)).to be(false)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "is false when the Accept header is absent (strict boolean, not nil)" do
|
|
137
|
+
stub_accept_header(nil)
|
|
138
|
+
expect(controller.send(:__ruact_json_accept?)).to be(false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Review patch (2026-06-08) — `__ruact_function_call?` is now the SEMANTIC
|
|
143
|
+
# predicate Story 9.2 reuses: a JSON-Accept request that is ALSO non-GET/HEAD
|
|
144
|
+
# (function calls are non-GET by the verb rule, epic contract decision #1).
|
|
145
|
+
# The verb gate moved off the error-renderer and into the predicate itself,
|
|
146
|
+
# so 9.2 inherits the correct contract from one place.
|
|
147
|
+
describe "Story 9.1 — __ruact_function_call? semantic predicate (verb-gated, review patch)" do
|
|
148
|
+
let(:controller) { ServerConcernUnitSupport::ConcernController.new }
|
|
149
|
+
|
|
150
|
+
def stub_request(accept:, verb: "POST")
|
|
151
|
+
request = instance_double(
|
|
152
|
+
ActionDispatch::Request,
|
|
153
|
+
headers: { "Accept" => accept },
|
|
154
|
+
get?: verb == "GET",
|
|
155
|
+
head?: verb == "HEAD"
|
|
156
|
+
)
|
|
157
|
+
allow(controller).to receive(:request).and_return(request)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "is true for a non-GET JSON request (THE Story 9.2 discrimination point)" do
|
|
161
|
+
stub_request(accept: "application/json", verb: "POST")
|
|
162
|
+
expect(controller.send(:__ruact_function_call?)).to be(true)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "is false for a GET carrying Accept: application/json (verb rule — not a function call)" do
|
|
166
|
+
stub_request(accept: "application/json", verb: "GET")
|
|
167
|
+
expect(controller.send(:__ruact_function_call?)).to be(false)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "is false for a HEAD carrying Accept: application/json" do
|
|
171
|
+
stub_request(accept: "application/json", verb: "HEAD")
|
|
172
|
+
expect(controller.send(:__ruact_function_call?)).to be(false)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "is false for a non-GET request without a JSON Accept (Bucket-1 form submit)" do
|
|
176
|
+
stub_request(accept: "text/html", verb: "POST")
|
|
177
|
+
expect(controller.send(:__ruact_function_call?)).to be(false)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -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
|