ruact 0.0.10 → 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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +16 -6
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -1
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. 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