ruact 0.0.11 → 0.0.12
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/CHANGELOG.md +110 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +14 -4
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
|
@@ -1,311 +0,0 @@
|
|
|
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
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Story 13.2 (FR96) — unit specs for the signed-reference primitive:
|
|
4
|
-
# Ruact.signed_global_id (outbound) + Ruact.locate_signed (inbound). Covers the
|
|
5
|
-
# four epic AC cases (round-trip / tampered / expired / wrong-purpose) plus the
|
|
6
|
-
# loud-omission guards that keep an unscoped or silently-non-expiring token
|
|
7
|
-
# unconstructible.
|
|
8
|
-
|
|
9
|
-
require "spec_helper"
|
|
10
|
-
require "global_id"
|
|
11
|
-
require "active_support"
|
|
12
|
-
require "active_support/core_ext/integer/time"
|
|
13
|
-
require "active_support/core_ext/numeric/time"
|
|
14
|
-
require "active_support/message_verifier"
|
|
15
|
-
|
|
16
|
-
# A GlobalID-locatable test record: globalid keys off `class.name` + `#id` and
|
|
17
|
-
# resolves via `class.find(id)`. Top-level + constantizable so the locator can
|
|
18
|
-
# reach it (mirrors how query_request_spec defines its host classes top-level).
|
|
19
|
-
class SignedRefSpecPost
|
|
20
|
-
include GlobalID::Identification
|
|
21
|
-
|
|
22
|
-
attr_reader :id
|
|
23
|
-
|
|
24
|
-
def initialize(id)
|
|
25
|
-
@id = id.to_s
|
|
26
|
-
self.class.store[@id] = self
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.store
|
|
30
|
-
@store ||= {}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.find(id)
|
|
34
|
-
store.fetch(id.to_s)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
module Ruact # rubocop:disable Style/OneClassPerFile
|
|
39
|
-
RSpec.describe SignedReferences, :story_13_2 do
|
|
40
|
-
let(:record) { SignedRefSpecPost.new("7") }
|
|
41
|
-
|
|
42
|
-
before do
|
|
43
|
-
# globalid signing needs an app name + a verifier secret. Set both as
|
|
44
|
-
# test infrastructure (no other spec uses globalid, so the global state
|
|
45
|
-
# is inert elsewhere).
|
|
46
|
-
GlobalID.app = "ruact-test"
|
|
47
|
-
SignedGlobalID.verifier = ActiveSupport::MessageVerifier.new("a" * 64)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
describe ".signed_global_id + .locate_signed round-trip (AC: round-trip)" do
|
|
51
|
-
it "mints a token that resolves back to the same record for a matching purpose" do
|
|
52
|
-
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
|
|
53
|
-
|
|
54
|
-
expect(token).to be_a(String)
|
|
55
|
-
expect(token).not_to eq("7") # a signed token, not the raw id
|
|
56
|
-
expect(token).to include("--") # MessageVerifier signature separator
|
|
57
|
-
located = Ruact.locate_signed(token, for: :post_edit)
|
|
58
|
-
expect(located.id).to eq("7")
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
describe ".locate_signed rejection cases" do
|
|
63
|
-
let(:token) { Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour) }
|
|
64
|
-
|
|
65
|
-
it "rejects a tampered token (AC: tampered)" do
|
|
66
|
-
expect { Ruact.locate_signed("#{token}tamper", for: :post_edit) }
|
|
67
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
it "rejects an expired token (AC: expired)" do
|
|
71
|
-
expired = Ruact.signed_global_id(record, for: :post_edit, expires_in: -1.hour)
|
|
72
|
-
expect { Ruact.locate_signed(expired, for: :post_edit) }
|
|
73
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
it "rejects a wrong-purpose token (AC: wrong-for)" do
|
|
77
|
-
expect { Ruact.locate_signed(token, for: :post_delete) }
|
|
78
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
it "rejects a nil / garbage token" do
|
|
82
|
-
expect { Ruact.locate_signed(nil, for: :post_edit) }
|
|
83
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
84
|
-
expect { Ruact.locate_signed("not-a-token", for: :post_edit) }
|
|
85
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
it "does not echo the raw token in the rejection message (no leak)" do
|
|
89
|
-
Ruact.locate_signed("#{token}tamper", for: :post_edit)
|
|
90
|
-
rescue Ruact::InvalidSignedGlobalIDError => e
|
|
91
|
-
expect(e.message).not_to include(token)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
describe "loud-omission guards (never a silent insecure default)" do
|
|
96
|
-
it "raises when no purpose is given and none is configured" do
|
|
97
|
-
expect { Ruact.signed_global_id(record, expires_in: 1.hour) }
|
|
98
|
-
.to raise_error(Ruact::Error, /purpose/)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "raises on an explicit for: nil (an unscoped token is never allowed)" do
|
|
102
|
-
expect { Ruact.signed_global_id(record, for: nil, expires_in: 1.hour) }
|
|
103
|
-
.to raise_error(Ruact::Error, /purpose/)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
it "raises when expiry is omitted and none is configured" do
|
|
107
|
-
expect { Ruact.signed_global_id(record, for: :post_edit) }
|
|
108
|
-
.to raise_error(Ruact::Error, /expir/i)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
it "honors an explicit expires_in: nil as a deliberate non-expiring token" do
|
|
112
|
-
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: nil)
|
|
113
|
-
expect(Ruact.locate_signed(token, for: :post_edit).id).to eq("7")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
it "rejects expires_in: false (globalid would silently treat it as non-expiring)" do
|
|
117
|
-
expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: false) }
|
|
118
|
-
.to raise_error(Ruact::Error, /expiry must be/)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
it "rejects a bare Integer expires_in (globalid would crash on #from_now)" do
|
|
122
|
-
expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: 3600) }
|
|
123
|
-
.to raise_error(Ruact::Error, /expiry must be/)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
it "rejects a configured default of false (the second silent non-expiring path)" do
|
|
127
|
-
Ruact.configure { |c| c.signed_global_id_default_expires_in = false }
|
|
128
|
-
expect { Ruact.signed_global_id(record, for: :post_edit) }
|
|
129
|
-
.to raise_error(Ruact::Error, /expiry must be/)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
it "raises a clear error for a non-GlobalID-locatable value" do
|
|
133
|
-
expect { Ruact.signed_global_id("plain-string", for: :post_edit, expires_in: 1.hour) }
|
|
134
|
-
.to raise_error(Ruact::Error, /GlobalID-locatable/)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
it "does NOT convert a valid token to a deleted record into InvalidSignedGlobalIDError " \
|
|
138
|
-
"(contract: a gone record is the host's normal finder concern, not a forged token)" do
|
|
139
|
-
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
|
|
140
|
-
SignedRefSpecPost.store.delete(record.id) # the row is gone, the token is still valid
|
|
141
|
-
|
|
142
|
-
# the underlying finder raises (KeyError here; ActiveRecord::RecordNotFound in a real app),
|
|
143
|
-
# NOT Ruact::InvalidSignedGlobalIDError — the primitive only owns signature/expiry/purpose.
|
|
144
|
-
expect { Ruact.locate_signed(token, for: :post_edit) }
|
|
145
|
-
.to raise_error(KeyError)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
describe "configured defaults" do
|
|
150
|
-
it "falls back to signed_global_id_default_purpose / _expires_in when the call omits them" do
|
|
151
|
-
Ruact.configure do |c|
|
|
152
|
-
c.signed_global_id_default_purpose = :app_default
|
|
153
|
-
c.signed_global_id_default_expires_in = 1.hour
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
token = Ruact.signed_global_id(record)
|
|
157
|
-
expect(Ruact.locate_signed(token).id).to eq("7")
|
|
158
|
-
# a token minted under the default purpose must not resolve under another
|
|
159
|
-
expect { Ruact.locate_signed(token, for: :something_else) }
|
|
160
|
-
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
end
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
module Ruact
|
|
6
|
-
RSpec.describe StringDistance, :story_13_5 do
|
|
7
|
-
describe ".damerau_levenshtein" do
|
|
8
|
-
it "returns 0 for identical strings" do
|
|
9
|
-
expect(described_class.damerau_levenshtein("postId", "postId")).to eq(0)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
it "counts an adjacent transposition as a single edit" do
|
|
13
|
-
expect(described_class.damerau_levenshtein("postId", "postdI")).to eq(1)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
it "handles empty strings" do
|
|
17
|
-
expect(described_class.damerau_levenshtein("", "abc")).to eq(3)
|
|
18
|
-
expect(described_class.damerau_levenshtein("abc", "")).to eq(3)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
describe ".closest_match" do
|
|
23
|
-
let(:pool) { %w[postId initialCount title] }
|
|
24
|
-
|
|
25
|
-
it "finds the nearest candidate within the threshold (case-insensitive)" do
|
|
26
|
-
expect(described_class.closest_match("postID", pool)).to eq("postId")
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
it "returns nil when nothing is within distance" do
|
|
30
|
-
expect(described_class.closest_match("zzzzzzzz", pool)).to be_nil
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
it "prefers the smallest distance" do
|
|
34
|
-
expect(described_class.closest_match("titel", pool)).to eq("title")
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
require "rake"
|
|
5
|
-
require "json"
|
|
6
|
-
require "tmpdir"
|
|
7
|
-
require "action_controller"
|
|
8
|
-
require "ruact/server"
|
|
9
|
-
require "ruact/doctor"
|
|
10
|
-
require "ruact/server_functions"
|
|
11
|
-
|
|
12
|
-
# Story 15.3 (FR107) — end-to-end coverage of the `--json` Rake glue itself:
|
|
13
|
-
# the `-- --json` ARGV scan, JSON-only stdout (no human prose), and — for
|
|
14
|
-
# `ruact:doctor` — the preserved process exit code (0 pass/warn, 1 on fail).
|
|
15
|
-
# The `Doctor#as_json` / `Introspection.as_json` shapes are unit-tested
|
|
16
|
-
# elsewhere; THIS proves the task wiring around them (Codex R1, Acceptance
|
|
17
|
-
# Auditor). Mirrors the isolated-Rake harness in
|
|
18
|
-
# spec/ruact/server_functions/rake_spec.rb.
|
|
19
|
-
module Ruact
|
|
20
|
-
RSpec.describe "rake --json introspection tasks", :story_15_3 do
|
|
21
|
-
around do |example|
|
|
22
|
-
Dir.mktmpdir do |dir|
|
|
23
|
-
original_root = Rails.root
|
|
24
|
-
Rails.root = Pathname.new(dir)
|
|
25
|
-
|
|
26
|
-
prev = Rake.application
|
|
27
|
-
Rake.application = Rake::Application.new
|
|
28
|
-
Rake.application.define_task(Rake::Task, :environment)
|
|
29
|
-
load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
|
|
30
|
-
|
|
31
|
-
example.run
|
|
32
|
-
ensure
|
|
33
|
-
Rake.application = prev
|
|
34
|
-
Rails.root = original_root
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def invoke!(task_name)
|
|
39
|
-
Rake::Task[task_name].reenable
|
|
40
|
-
Rake::Task[task_name].invoke
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# ---- ruact:doctor -- --json (AC1: JSON-only stdout + preserved exit) ----
|
|
44
|
-
|
|
45
|
-
describe "ruact:doctor -- --json" do
|
|
46
|
-
before { stub_const("ARGV", %w[ruact:doctor -- --json]) }
|
|
47
|
-
|
|
48
|
-
def stub_doctor(status:)
|
|
49
|
-
report = {
|
|
50
|
-
"schema_version" => 0,
|
|
51
|
-
"status" => status,
|
|
52
|
-
"checks" => [{ "name" => "manifest", "status" => status, "message" => "m", "remediation" => nil }]
|
|
53
|
-
}
|
|
54
|
-
allow(Ruact::Doctor).to receive(:new).and_return(instance_double(Ruact::Doctor, as_json: report))
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it "prints ONLY the JSON document (no prose) and does not exit when all pass", :aggregate_failures do
|
|
58
|
-
stub_doctor(status: "pass")
|
|
59
|
-
|
|
60
|
-
out = nil
|
|
61
|
-
expect { out = capture_stdout_string { invoke!("ruact:doctor") } }.not_to raise_error
|
|
62
|
-
parsed = JSON.parse(out)
|
|
63
|
-
expect(parsed["schema_version"]).to eq(0)
|
|
64
|
-
expect(parsed["status"]).to eq("pass")
|
|
65
|
-
expect(out).not_to include("[ruact] Health check")
|
|
66
|
-
expect(out).not_to include("Run rails generate")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
it "exits 1 (SystemExit) when a check fails, still emitting the JSON", :aggregate_failures do
|
|
70
|
-
stub_doctor(status: "fail")
|
|
71
|
-
|
|
72
|
-
expect do
|
|
73
|
-
expect { invoke!("ruact:doctor") }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) }
|
|
74
|
-
end.to output(/"status": "fail"/).to_stdout
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
it "ruact:doctor WITHOUT --json runs the human path (no JSON), returns 0 when healthy" do
|
|
79
|
-
stub_const("ARGV", %w[ruact:doctor])
|
|
80
|
-
allow(Ruact::Doctor).to receive(:run).and_return(true)
|
|
81
|
-
|
|
82
|
-
expect { expect { invoke!("ruact:doctor") }.not_to raise_error }.not_to output(/schema_version/).to_stdout
|
|
83
|
-
expect(Ruact::Doctor).to have_received(:run)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# ---- ruact:routes (AC2: JSON via -- --json, human table bare) ----
|
|
87
|
-
|
|
88
|
-
describe "ruact:routes" do
|
|
89
|
-
before do
|
|
90
|
-
stub_const("RakeRoutesPostsController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
91
|
-
reloader = Object.new
|
|
92
|
-
def reloader.execute_unless_loaded; end
|
|
93
|
-
rs = ActionDispatch::Routing::RouteSet.new
|
|
94
|
-
rs.draw { resources :rake_routes_posts, only: %i[create] }
|
|
95
|
-
app = Object.new
|
|
96
|
-
app.define_singleton_method(:routes) { rs }
|
|
97
|
-
app.define_singleton_method(:routes_reloader) { reloader }
|
|
98
|
-
allow(Rails).to receive(:application).and_return(app)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "emits the EXPERIMENTAL JSON document under -- --json (end-to-end, real resolvers)", :aggregate_failures do
|
|
102
|
-
stub_const("ARGV", %w[ruact:routes -- --json])
|
|
103
|
-
|
|
104
|
-
out = capture_stdout_string { invoke!("ruact:routes") }
|
|
105
|
-
parsed = JSON.parse(out)
|
|
106
|
-
expect(parsed["schema_version"]).to eq(0)
|
|
107
|
-
create = parsed["accessors"].find { |a| a["accessor"] == "createRakeRoutesPost" }
|
|
108
|
-
expect(create).to include("kind" => "action", "verb" => "POST", "path" => "/rake_routes_posts")
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
it "prints a compact human table (no JSON) when invoked bare", :aggregate_failures do
|
|
112
|
-
stub_const("ARGV", %w[ruact:routes])
|
|
113
|
-
|
|
114
|
-
out = capture_stdout_string { invoke!("ruact:routes") }
|
|
115
|
-
expect(out).to include("server-function accessors:")
|
|
116
|
-
expect(out).to include("createRakeRoutesPost")
|
|
117
|
-
expect(out).not_to include("schema_version")
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
it "exits 1 with a `[ruact] error:` line on a naming collision" do
|
|
121
|
-
stub_const("ARGV", %w[ruact:routes -- --json])
|
|
122
|
-
allow(Ruact::ServerFunctions).to receive(:introspect)
|
|
123
|
-
.and_raise(Ruact::ConfigurationError, "server-function naming collision: A#x and B#x ...")
|
|
124
|
-
|
|
125
|
-
expect { invoke!("ruact:routes") }
|
|
126
|
-
.to raise_error(SystemExit)
|
|
127
|
-
.and output(/\[ruact\] error:.*naming collision/).to_stderr
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Captures $stdout into a String (the harness has no shared helper).
|
|
132
|
-
def capture_stdout_string
|
|
133
|
-
original = $stdout
|
|
134
|
-
$stdout = StringIO.new
|
|
135
|
-
yield
|
|
136
|
-
$stdout.string
|
|
137
|
-
ensure
|
|
138
|
-
$stdout = original
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|