spamtrap 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 619741f1ea048808a9235e1bc02a814c2d6606aed599e188c3ea99d464e2fc85
4
- data.tar.gz: e309645da54a5ac5c898b83f8e90b5132361001724469ceabe18bf7f3195fbac
3
+ metadata.gz: d3ff3c236a80418a4048beacd5cb3ff88c555a45d4736a8ea743a97a924d1283
4
+ data.tar.gz: 882c96f34aa70bfe8fab481704d96b97b8a9424c1d5b637140d7d60cb56abb30
5
5
  SHA512:
6
- metadata.gz: 9e8328c22c89ee565ef34c2cff0e2ecad4aae2b9e71a9627bb6162e57e1013577fa53f65a56a217928e8d0503017b4c2508cec435ecf0c911eb257da53087f59
7
- data.tar.gz: 49fa2e0f20b2489eb082c9bd4cd3fe1a09f4d4ddd03022676e12a91fc7779a0bab36e1a628205a0a1f29904da8292a984ef11a317bda486eed0c74f1a5d42909
6
+ metadata.gz: 2316d8928764a5071ff756e37717f3d52924f85f8e7fb34a6de9109bec0d103716e2cfe428456557d9664e0c117fb2c9c8b735d231c16440610ec391d77afb00
7
+ data.tar.gz: db528095a79dc96011fad7ee831f7164650c3d3cfb92004ea6edced38fec1aa39df5ce87d4cef793631c713ccc668dd040f61b9e56d70c6b8b174338732ed9ec
@@ -9,9 +9,10 @@ module Spamtrap::Controller
9
9
  def spamtrap(honeypot = 'spamtrap', options = {}, &block)
10
10
  # Capture explicit per-call values; use sentinel so globals are read
11
11
  # at request time rather than at class definition time.
12
- nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
13
- timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
14
- mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
12
+ nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
13
+ timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
14
+ mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
15
+ on_trap_opt = options.key?(:on_trap) ? options.delete(:on_trap) : :global
15
16
 
16
17
  before_action(options) do |controller|
17
18
  controller.instance_eval(&block) if block_given?
@@ -24,9 +25,11 @@ module Spamtrap::Controller
24
25
 
25
26
  if params[honeypot].present?
26
27
  Rails.logger.warn "Spamtrap triggered by #{request.remote_ip}."
28
+ spamtrap_invoke_on_trap(:honeypot, on_trap_opt)
27
29
  head 200
28
30
  elsif nonce_enabled && !spamtrap_valid_nonce?(nonce_timeout)
29
31
  Rails.logger.warn "Spamtrap nonce invalid from #{request.remote_ip}."
32
+ spamtrap_invoke_on_trap(:nonce, on_trap_opt)
30
33
  head 200
31
34
  end
32
35
  end
@@ -69,7 +72,15 @@ module Spamtrap::Controller
69
72
  end
70
73
  end
71
74
 
72
- private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash
75
+ def spamtrap_invoke_on_trap(reason, on_trap_opt)
76
+ callback = on_trap_opt == :global ? Spamtrap.on_trap : on_trap_opt
77
+ return unless callback.respond_to?(:call)
78
+ callback.call(reason: reason, request: request)
79
+ rescue StandardError => e
80
+ Rails.logger.error "Spamtrap on_trap callback raised: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash, :spamtrap_invoke_on_trap
73
84
 
74
85
  end
75
86
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Spamtrap
2
4
  module FormBuilderMutation
3
5
  include Spamtrap::Crypto
@@ -11,8 +13,45 @@ module Spamtrap
11
13
 
12
14
  MUTABLE_FIELDS.each do |m|
13
15
  define_method(m) do |field, *args, &blk|
14
- field = spamtrap_encrypt_field(field.to_s, @spamtrap_salt) if @spamtrap_salt
15
- super(field, *args, &blk)
16
+ if @spamtrap_salt
17
+ encrypted_field = spamtrap_encrypt_field(field.to_s, @spamtrap_salt)
18
+
19
+ opts =
20
+ if m == :check_box
21
+ args.first.is_a?(Hash) ? args.shift.dup : {}
22
+ else
23
+ args.last.is_a?(Hash) ? args.pop.dup : {}
24
+ end
25
+
26
+ model_value = object.respond_to?(field) ? object.public_send(field) : nil
27
+
28
+ case m
29
+ when :check_box
30
+ # :checked controls the checked state; :value is the submitted value ("1" by default)
31
+ opts[:checked] = !!model_value unless opts.key?(:checked)
32
+ super(encrypted_field, opts, *args, &blk)
33
+ when :select, :collection_select, :grouped_collection_select
34
+ # :selected belongs in the inner options hash, not html_options (the last hash).
35
+ # After popping html_options into opts, args.last is the options hash (if present).
36
+ if args.last.is_a?(Hash)
37
+ sel_opts = args.pop.dup
38
+ sel_opts[:selected] = model_value unless sel_opts.key?(:selected)
39
+ args.push(sel_opts)
40
+ else
41
+ opts[:selected] = model_value unless opts.key?(:selected)
42
+ end
43
+
44
+ super(encrypted_field, *args, opts, &blk)
45
+ when :label
46
+ # label renders a <label> element and does not read a value from the model object
47
+ super(encrypted_field, *args, opts, &blk)
48
+ else
49
+ opts[:value] = model_value unless opts.key?(:value)
50
+ super(encrypted_field, *args, opts, &blk)
51
+ end
52
+ else
53
+ super(field, *args, &blk)
54
+ end
16
55
  end
17
56
  end
18
57
 
@@ -1,5 +1,5 @@
1
1
  module Spamtrap
2
2
 
3
- VERSION = '0.3.1'
3
+ VERSION = '0.3.3'
4
4
 
5
5
  end
data/lib/spamtrap.rb CHANGED
@@ -2,7 +2,8 @@ require 'openssl'
2
2
 
3
3
  module Spamtrap
4
4
  class << self
5
- attr_writer :nonce, :nonce_timeout, :mutate
5
+ attr_accessor :on_trap
6
+ attr_writer :nonce, :nonce_timeout, :mutate
6
7
 
7
8
  def nonce
8
9
  @nonce || false
@@ -345,3 +345,129 @@ class GlobalOverrideControllerTest < ActionController::TestCase
345
345
  assert_equal 'body', response.body
346
346
  end
347
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
@@ -0,0 +1,142 @@
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 CHANGED
@@ -21,6 +21,9 @@ Rails.application.routes.draw do
21
21
  post 'nested_mutation/create', to: 'nested_mutation#create'
22
22
  post 'global_defaults/create', to: 'global_defaults#create'
23
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'
24
27
  end
25
28
 
26
29
  class ActionController::TestCase
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spamtrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe
@@ -36,6 +36,7 @@ files:
36
36
  - lib/spamtrap/helper.rb
37
37
  - lib/spamtrap/version.rb
38
38
  - test/controller_test.rb
39
+ - test/form_builder_mutation_test.rb
39
40
  - test/test_helper.rb
40
41
  homepage: http://github.com/cedric/spamtrap/
41
42
  licenses:
@@ -60,4 +61,5 @@ specification_version: 4
60
61
  summary: Simple spamtrap for spambots.
61
62
  test_files:
62
63
  - test/controller_test.rb
64
+ - test/form_builder_mutation_test.rb
63
65
  - test/test_helper.rb