spamtrap 0.3.4 → 0.4.2

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.
@@ -1,473 +0,0 @@
1
- require File.join(File.dirname(__FILE__), 'test_helper')
2
-
3
- # Test controllers — render a body so we can distinguish a real response
4
- # from a spamtrap-blocked one (which uses head 200 and has an empty body).
5
-
6
- class HoneypotController < ActionController::Base
7
- spamtrap :trap_field, only: :create
8
-
9
- def create
10
- render plain: 'success'
11
- end
12
- end
13
-
14
- class NonceController < ActionController::Base
15
- spamtrap :trap_field, nonce: true, only: :create
16
-
17
- def create
18
- render plain: 'success'
19
- end
20
- end
21
-
22
- class NonceTimeoutController < ActionController::Base
23
- spamtrap :trap_field, nonce: true, nonce_timeout: 60, only: :create
24
-
25
- def create
26
- render plain: 'success'
27
- end
28
- end
29
-
30
- class MutationController < ActionController::Base
31
- spamtrap :trap_field, mutate: true, only: :create
32
-
33
- def create
34
- # Render the remapped comment param keys so tests can assert on them
35
- render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
36
- end
37
- end
38
-
39
- class NestedMutationController < ActionController::Base
40
- spamtrap :trap_field, mutate: true, only: :create
41
-
42
- def create
43
- # Render top-level and nested address keys to verify salt propagation
44
- comment_keys = params[:comment].to_unsafe_h.except('address').keys.sort
45
- address_keys = params.dig(:comment, :address).to_unsafe_h.keys.sort rescue []
46
- render plain: "#{comment_keys.join(',')};#{address_keys.join(',')}"
47
- end
48
- end
49
-
50
- module NonceTestHelper
51
- REMOTE_IP = '0.0.0.0'
52
-
53
- def generate_nonce(timestamp, ip = REMOTE_IP)
54
- secret = Rails.application.secret_key_base
55
- OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{ip}")
56
- end
57
- end
58
-
59
- module MutationTestHelper
60
- include Spamtrap::Crypto
61
-
62
- MUTATION_SALT = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
63
- MUTATION_SALT_HEX = MUTATION_SALT.unpack1('H*')
64
- end
65
-
66
- class HoneypotControllerTest < ActionController::TestCase
67
- tests HoneypotController
68
-
69
- def test_empty_honeypot_allows_request
70
- post :create, params: { trap_field: '' }
71
- assert_response :ok
72
- assert_equal 'success', response.body
73
- end
74
-
75
- def test_filled_honeypot_silently_discards
76
- post :create, params: { trap_field: 'buy cheap meds' }
77
- assert_response :ok
78
- assert_empty response.body
79
- end
80
- end
81
-
82
- class NonceControllerTest < ActionController::TestCase
83
- include NonceTestHelper
84
- tests NonceController
85
-
86
- def test_valid_nonce_allows_request
87
- timestamp = Time.now.to_i
88
- post :create, params: {
89
- trap_field: '',
90
- spamtrap_timestamp: timestamp,
91
- spamtrap_nonce: generate_nonce(timestamp)
92
- }
93
- assert_response :ok
94
- assert_equal 'success', response.body
95
- end
96
-
97
- def test_tampered_nonce_is_rejected
98
- timestamp = Time.now.to_i
99
- post :create, params: {
100
- trap_field: '',
101
- spamtrap_timestamp: timestamp,
102
- spamtrap_nonce: 'deadbeef'
103
- }
104
- assert_response :ok
105
- assert_empty response.body
106
- end
107
-
108
- def test_expired_nonce_is_rejected
109
- timestamp = Time.now.to_i - 7200
110
- post :create, params: {
111
- trap_field: '',
112
- spamtrap_timestamp: timestamp,
113
- spamtrap_nonce: generate_nonce(timestamp)
114
- }
115
- assert_response :ok
116
- assert_empty response.body
117
- end
118
-
119
- def test_missing_nonce_fields_are_rejected
120
- post :create, params: { trap_field: '' }
121
- assert_response :ok
122
- assert_empty response.body
123
- end
124
-
125
- def test_filled_honeypot_takes_priority_over_valid_nonce
126
- timestamp = Time.now.to_i
127
- post :create, params: {
128
- trap_field: 'spam',
129
- spamtrap_timestamp: timestamp,
130
- spamtrap_nonce: generate_nonce(timestamp)
131
- }
132
- assert_response :ok
133
- assert_empty response.body
134
- end
135
- end
136
-
137
- class NonceTimeoutControllerTest < ActionController::TestCase
138
- include NonceTestHelper
139
- tests NonceTimeoutController
140
-
141
- def test_nonce_within_custom_timeout_passes
142
- timestamp = Time.now.to_i - 30
143
- post :create, params: {
144
- trap_field: '',
145
- spamtrap_timestamp: timestamp,
146
- spamtrap_nonce: generate_nonce(timestamp)
147
- }
148
- assert_response :ok
149
- assert_equal 'success', response.body
150
- end
151
-
152
- def test_nonce_beyond_custom_timeout_is_rejected
153
- timestamp = Time.now.to_i - 120
154
- post :create, params: {
155
- trap_field: '',
156
- spamtrap_timestamp: timestamp,
157
- spamtrap_nonce: generate_nonce(timestamp)
158
- }
159
- assert_response :ok
160
- assert_empty response.body
161
- end
162
- end
163
-
164
- class MutationControllerTest < ActionController::TestCase
165
- include MutationTestHelper
166
- tests MutationController
167
-
168
- def test_encrypted_field_names_are_remapped_to_real_names
169
- body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
170
- email_token = spamtrap_encrypt_field('email', MUTATION_SALT)
171
-
172
- post :create, params: {
173
- trap_field: '',
174
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
175
- comment: { body_token => 'Hello world', email_token => 'test@example.com' }
176
- }
177
-
178
- assert_response :ok
179
- assert_equal 'body,email', response.body
180
- end
181
-
182
- def test_unencrypted_field_names_pass_through_unchanged
183
- post :create, params: {
184
- trap_field: '',
185
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
186
- comment: { body: 'Hello', email: 'test@example.com' }
187
- }
188
-
189
- assert_response :ok
190
- assert_equal 'body,email', response.body
191
- end
192
-
193
- def test_no_salt_leaves_encrypted_params_unmapped
194
- body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
195
-
196
- post :create, params: {
197
- trap_field: '',
198
- comment: { body_token => 'Hello' }
199
- }
200
-
201
- assert_response :ok
202
- refute_equal 'body', response.body
203
- end
204
-
205
- def test_wrong_salt_fails_to_decrypt
206
- other_salt = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
207
- body_token = spamtrap_encrypt_field('body', other_salt)
208
-
209
- post :create, params: {
210
- trap_field: '',
211
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
212
- comment: { body_token => 'Hello' }
213
- }
214
-
215
- assert_response :ok
216
- refute_equal 'body', response.body
217
- end
218
- end
219
-
220
- class NestedMutationControllerTest < ActionController::TestCase
221
- include MutationTestHelper
222
- tests NestedMutationController
223
-
224
- def test_salt_propagates_to_fields_for_child_builder
225
- body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
226
- street_token = spamtrap_encrypt_field('street', MUTATION_SALT)
227
- city_token = spamtrap_encrypt_field('city', MUTATION_SALT)
228
-
229
- post :create, params: {
230
- trap_field: '',
231
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
232
- comment: {
233
- body_token => 'Hello',
234
- address: {
235
- street_token => '123 Main St',
236
- city_token => 'Springfield'
237
- }
238
- }
239
- }
240
-
241
- assert_response :ok
242
- assert_equal 'body;city,street', response.body
243
- end
244
- end
245
-
246
- # Controller that relies entirely on global defaults (no per-call options).
247
- class GlobalDefaultsController < ActionController::Base
248
- spamtrap :trap_field, only: :create
249
-
250
- def create
251
- render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
252
- end
253
- end
254
-
255
- # Controller that explicitly overrides global defaults with false.
256
- class GlobalOverrideController < ActionController::Base
257
- spamtrap :trap_field, nonce: false, mutate: false, only: :create
258
-
259
- def create
260
- render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
261
- end
262
- end
263
-
264
- class GlobalDefaultsControllerTest < ActionController::TestCase
265
- include MutationTestHelper
266
- include NonceTestHelper
267
- tests GlobalDefaultsController
268
-
269
- setup do
270
- Spamtrap.nonce = true
271
- Spamtrap.mutate = true
272
- end
273
-
274
- teardown do
275
- Spamtrap.nonce = false
276
- Spamtrap.mutate = false
277
- end
278
-
279
- def test_global_nonce_default_rejects_missing_nonce
280
- post :create, params: { trap_field: '', comment: { body: 'Hello' } }
281
- assert_response :ok
282
- assert_empty response.body
283
- end
284
-
285
- def test_global_nonce_default_accepts_valid_nonce
286
- timestamp = Time.now.to_i
287
- post :create, params: {
288
- trap_field: '',
289
- spamtrap_timestamp: timestamp,
290
- spamtrap_nonce: generate_nonce(timestamp),
291
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
292
- comment: { spamtrap_encrypt_field('body', MUTATION_SALT) => 'Hello' }
293
- }
294
- assert_response :ok
295
- assert_equal 'body', response.body
296
- end
297
-
298
- def test_global_mutate_default_remaps_encrypted_fields
299
- body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
300
- timestamp = Time.now.to_i
301
- post :create, params: {
302
- trap_field: '',
303
- spamtrap_timestamp: timestamp,
304
- spamtrap_nonce: generate_nonce(timestamp),
305
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
306
- comment: { body_token => 'Hello' }
307
- }
308
- assert_response :ok
309
- assert_equal 'body', response.body
310
- end
311
- end
312
-
313
- class GlobalOverrideControllerTest < ActionController::TestCase
314
- include MutationTestHelper
315
- tests GlobalOverrideController
316
-
317
- setup do
318
- Spamtrap.nonce = true
319
- Spamtrap.mutate = true
320
- end
321
-
322
- teardown do
323
- Spamtrap.nonce = false
324
- Spamtrap.mutate = false
325
- end
326
-
327
- def test_per_call_false_overrides_global_nonce
328
- # No nonce params — would fail if global nonce: true were in effect
329
- post :create, params: {
330
- trap_field: '',
331
- comment: { body: 'Hello' }
332
- }
333
- assert_response :ok
334
- assert_equal 'body', response.body
335
- end
336
-
337
- def test_per_call_false_overrides_global_mutate
338
- # Plain field name — would be remapped if global mutate: true were in effect
339
- post :create, params: {
340
- trap_field: '',
341
- spamtrap_mutation_salt: MUTATION_SALT_HEX,
342
- comment: { body: 'Hello' }
343
- }
344
- assert_response :ok
345
- assert_equal 'body', response.body
346
- end
347
- end
348
-
349
- # Controllers for on_trap callback tests.
350
- class OnTrapGlobalCallbackController < ActionController::Base
351
- spamtrap :trap_field, only: :create
352
-
353
- def create
354
- render plain: 'success'
355
- end
356
- end
357
-
358
- class OnTrapGlobalNonceCallbackController < ActionController::Base
359
- spamtrap :trap_field, nonce: true, only: :create
360
-
361
- def create
362
- render plain: 'success'
363
- end
364
- end
365
-
366
- class OnTrapPerDeclarationController < ActionController::Base
367
- spamtrap :trap_field, only: :create,
368
- on_trap: ->(reason:, request:) { Thread.current[:per_decl_calls] << { reason: reason, ip: request.remote_ip } }
369
-
370
- def create
371
- render plain: 'success'
372
- end
373
- end
374
-
375
- class OnTrapCallbackTest < ActionController::TestCase
376
- tests OnTrapGlobalCallbackController
377
-
378
- setup do
379
- @calls = []
380
- Spamtrap.on_trap = ->(reason:, request:) { @calls << { reason: reason, ip: request.remote_ip } }
381
- end
382
-
383
- teardown do
384
- Spamtrap.on_trap = nil
385
- end
386
-
387
- def test_global_callback_invoked_on_honeypot_trap
388
- post :create, params: { trap_field: 'spam' }
389
- assert_response :ok
390
- assert_empty response.body
391
- assert_equal 1, @calls.size
392
- assert_equal :honeypot, @calls.first[:reason]
393
- end
394
-
395
- def test_global_callback_not_invoked_on_legitimate_request
396
- post :create, params: { trap_field: '' }
397
- assert_response :ok
398
- assert_equal 'success', response.body
399
- assert_empty @calls
400
- end
401
-
402
- def test_no_callback_when_on_trap_is_nil
403
- Spamtrap.on_trap = nil
404
- post :create, params: { trap_field: 'spam' }
405
- assert_response :ok
406
- assert_empty response.body
407
- # no error raised — test simply passes
408
- end
409
- end
410
-
411
- class OnTrapNonceCallbackTest < ActionController::TestCase
412
- include NonceTestHelper
413
- tests OnTrapGlobalNonceCallbackController
414
-
415
- setup do
416
- @calls = []
417
- Spamtrap.on_trap = ->(reason:, request:) { @calls << { reason: reason, ip: request.remote_ip } }
418
- end
419
-
420
- teardown do
421
- Spamtrap.on_trap = nil
422
- end
423
-
424
- def test_global_callback_invoked_on_nonce_trap
425
- post :create, params: { trap_field: '' }
426
- assert_response :ok
427
- assert_empty response.body
428
- assert_equal 1, @calls.size
429
- assert_equal :nonce, @calls.first[:reason]
430
- end
431
- end
432
-
433
- class OnTrapPerDeclarationCallbackTest < ActionController::TestCase
434
- tests OnTrapPerDeclarationController
435
-
436
- setup do
437
- Thread.current[:per_decl_calls] = []
438
- @global_calls = []
439
- Spamtrap.on_trap = ->(reason:, request:) { @global_calls << reason }
440
- end
441
-
442
- teardown do
443
- Spamtrap.on_trap = nil
444
- Thread.current[:per_decl_calls] = nil
445
- end
446
-
447
- def test_per_declaration_callback_takes_precedence_over_global
448
- post :create, params: { trap_field: 'spam' }
449
- assert_response :ok
450
- assert_empty response.body
451
- assert_equal 1, Thread.current[:per_decl_calls].size
452
- assert_equal :honeypot, Thread.current[:per_decl_calls].first[:reason]
453
- assert_empty @global_calls
454
- end
455
- end
456
-
457
- class OnTrapCallbackErrorResilienceTest < ActionController::TestCase
458
- tests OnTrapGlobalCallbackController
459
-
460
- setup do
461
- Spamtrap.on_trap = ->(**) { raise 'callback exploded' }
462
- end
463
-
464
- teardown do
465
- Spamtrap.on_trap = nil
466
- end
467
-
468
- def test_broken_callback_does_not_prevent_head_200
469
- post :create, params: { trap_field: 'spam' }
470
- assert_response :ok
471
- assert_empty response.body
472
- end
473
- end
@@ -1,142 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require File.join(File.dirname(__FILE__), 'test_helper')
4
- require 'action_view'
5
- require 'action_view/test_case'
6
-
7
- # A simple model-like struct to back the form builder, mimicking an AR model.
8
- Message = Struct.new(:name, :email, :body, :active, :country)
9
-
10
- class FormBuilderMutationTest < ActionView::TestCase
11
- include Spamtrap::Crypto
12
-
13
- # Build a minimal form builder instance backed by a Message object.
14
- def build_form_builder(object)
15
- ActionView::Helpers::FormBuilder.new(:message, object, self, {})
16
- end
17
-
18
- # --- text-like inputs ---
19
-
20
- def test_text_field_does_not_raise_when_mutate_is_true
21
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
22
- f = build_form_builder(msg)
23
- f.spamtrap(:trap, mutate: true)
24
-
25
- assert_nothing_raised { f.text_field(:name) }
26
- assert_nothing_raised { f.email_field(:email) }
27
- assert_nothing_raised { f.text_area(:body) }
28
- end
29
-
30
- def test_model_value_is_preserved_in_encrypted_field
31
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
32
- f = build_form_builder(msg)
33
- f.spamtrap(:trap, mutate: true)
34
-
35
- assert_includes f.text_field(:name), 'Alice'
36
- assert_includes f.email_field(:email), 'alice@example.com'
37
- assert_includes f.text_area(:body), 'Hello'
38
- end
39
-
40
- def test_encrypted_field_name_is_used_in_html
41
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
42
- f = build_form_builder(msg)
43
- f.spamtrap(:trap, mutate: true)
44
-
45
- html = f.text_field(:name)
46
-
47
- # The original unencrypted name must NOT appear as the input name attribute
48
- refute_match(/name="message\[name\]"/, html)
49
- end
50
-
51
- def test_nil_model_value_does_not_raise
52
- msg = Message.new(nil, nil, nil)
53
- f = build_form_builder(msg)
54
- f.spamtrap(:trap, mutate: true)
55
-
56
- assert_nothing_raised { f.text_field(:name) }
57
- end
58
-
59
- def test_virtual_field_without_model_method_does_not_raise
60
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
61
- f = build_form_builder(msg)
62
- f.spamtrap(:trap, mutate: true)
63
-
64
- # :nonexistent is not a method on Message; should render with empty value, not crash
65
- assert_nothing_raised { f.text_field(:nonexistent) }
66
- end
67
-
68
- def test_explicit_value_option_is_preserved
69
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
70
- f = build_form_builder(msg)
71
- f.spamtrap(:trap, mutate: true)
72
-
73
- html = f.text_field(:name, value: 'Overridden')
74
-
75
- assert_includes html, 'Overridden'
76
- refute_includes html, 'Alice'
77
- end
78
-
79
- # --- no mutation ---
80
-
81
- def test_no_salt_leaves_field_names_unencrypted
82
- msg = Message.new('Alice', 'alice@example.com', 'Hello')
83
- f = build_form_builder(msg)
84
-
85
- html = f.text_field(:name)
86
-
87
- assert_match(/name="message\[name\]"/, html)
88
- end
89
-
90
- # --- check_box ---
91
-
92
- def test_check_box_does_not_raise_when_mutate_is_true
93
- msg = Message.new(nil, nil, nil, true)
94
- f = build_form_builder(msg)
95
- f.spamtrap(:trap, mutate: true)
96
-
97
- assert_nothing_raised { f.check_box(:active) }
98
- end
99
-
100
- def test_check_box_checked_when_model_value_is_true
101
- msg = Message.new(nil, nil, nil, true)
102
- f = build_form_builder(msg)
103
- f.spamtrap(:trap, mutate: true)
104
-
105
- assert_includes f.check_box(:active), 'checked'
106
- end
107
-
108
- def test_check_box_unchecked_when_model_value_is_false
109
- msg = Message.new(nil, nil, nil, false)
110
- f = build_form_builder(msg)
111
- f.spamtrap(:trap, mutate: true)
112
-
113
- refute_includes f.check_box(:active), 'checked'
114
- end
115
-
116
- # --- select ---
117
-
118
- def test_select_does_not_raise_when_mutate_is_true
119
- msg = Message.new(nil, nil, nil, nil, 'US')
120
- f = build_form_builder(msg)
121
- f.spamtrap(:trap, mutate: true)
122
-
123
- assert_nothing_raised { f.select(:country, %w[US CA GB]) }
124
- end
125
-
126
- def test_select_preselects_model_value
127
- msg = Message.new(nil, nil, nil, nil, 'CA')
128
- f = build_form_builder(msg)
129
- f.spamtrap(:trap, mutate: true)
130
-
131
- html = f.select(:country, %w[US CA GB])
132
-
133
- assert_match(/selected.*CA|CA.*selected/, html)
134
- end
135
-
136
- private
137
-
138
- # ActionView::TestCase helpers expect a #request method; provide a stub.
139
- def request
140
- @request ||= ActionDispatch::TestRequest.create
141
- end
142
- end
data/test/test_helper.rb DELETED
@@ -1,33 +0,0 @@
1
- require 'rubygems'
2
- require 'rails'
3
- require 'action_controller'
4
- require 'action_controller/test_case'
5
- require 'minitest/autorun'
6
- require 'spamtrap'
7
-
8
- class SpamtrapTestApp < Rails::Application
9
- config.secret_key_base = 'a' * 64
10
- config.eager_load = false
11
- config.logger = Logger.new(nil)
12
- end
13
-
14
- Rails.application.initialize!
15
-
16
- Rails.application.routes.draw do
17
- post 'honeypot/create', to: 'honeypot#create'
18
- post 'nonce/create', to: 'nonce#create'
19
- post 'nonce_timeout/create', to: 'nonce_timeout#create'
20
- post 'mutation/create', to: 'mutation#create'
21
- post 'nested_mutation/create', to: 'nested_mutation#create'
22
- post 'global_defaults/create', to: 'global_defaults#create'
23
- post 'global_override/create', to: 'global_override#create'
24
- post 'on_trap_global_callback/create', to: 'on_trap_global_callback#create'
25
- post 'on_trap_global_nonce_callback/create', to: 'on_trap_global_nonce_callback#create'
26
- post 'on_trap_per_declaration/create', to: 'on_trap_per_declaration#create'
27
- end
28
-
29
- class ActionController::TestCase
30
- setup do
31
- @routes = Rails.application.routes
32
- end
33
- end