bug_bunny 4.6.0 → 4.7.0
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/.claude/commands/pr.md +42 -0
- data/.claude/commands/release.md +41 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +42 -15
- data/CLAUDE.md +228 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +41 -18
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +29 -4
- data/lib/bug_bunny/exception.rb +4 -0
- data/lib/bug_bunny/observability.rb +24 -3
- data/lib/bug_bunny/resource.rb +31 -21
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +30 -3
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/mejoras.md +33 -0
- data/plan_test.txt +63 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +36 -3
- data/sig/bug_bunny.rbs +0 -4
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe BugBunny::ConsumerMiddleware::Stack do
|
|
6
|
+
subject(:stack) { described_class.new }
|
|
7
|
+
|
|
8
|
+
let(:delivery_info) { double('delivery_info') }
|
|
9
|
+
let(:properties) { double('properties') }
|
|
10
|
+
let(:body) { 'payload' }
|
|
11
|
+
|
|
12
|
+
# Construye un middleware de seguimiento que escribe en `log` (Array capturado por closure).
|
|
13
|
+
def tracking_middleware(log, label)
|
|
14
|
+
Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
15
|
+
define_method(:call) do |delivery, props, msg|
|
|
16
|
+
log << :"#{label}_before"
|
|
17
|
+
@app.call(delivery, props, msg)
|
|
18
|
+
log << :"#{label}_after"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#use' do
|
|
24
|
+
it 'registra un middleware y retorna self para encadenamiento' do
|
|
25
|
+
klass = Class.new(BugBunny::ConsumerMiddleware::Base)
|
|
26
|
+
result = stack.use(klass)
|
|
27
|
+
|
|
28
|
+
expect(result).to be(stack)
|
|
29
|
+
expect(stack.instance_variable_get(:@middlewares)).to eq([klass])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'permite encadenar múltiples calls' do
|
|
33
|
+
klass_a = Class.new(BugBunny::ConsumerMiddleware::Base)
|
|
34
|
+
klass_b = Class.new(BugBunny::ConsumerMiddleware::Base)
|
|
35
|
+
|
|
36
|
+
stack.use(klass_a).use(klass_b)
|
|
37
|
+
|
|
38
|
+
expect(stack.instance_variable_get(:@middlewares)).to eq([klass_a, klass_b])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'es thread-safe bajo registros concurrentes' do
|
|
42
|
+
classes = 20.times.map { Class.new(BugBunny::ConsumerMiddleware::Base) }
|
|
43
|
+
threads = classes.map { |klass| Thread.new { stack.use(klass) } }
|
|
44
|
+
threads.each(&:join)
|
|
45
|
+
|
|
46
|
+
expect(stack.instance_variable_get(:@middlewares).size).to eq(20)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#empty?' do
|
|
51
|
+
it 'retorna true cuando no hay middlewares' do
|
|
52
|
+
expect(stack.empty?).to be(true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'retorna false después de registrar un middleware' do
|
|
56
|
+
stack.use(Class.new(BugBunny::ConsumerMiddleware::Base))
|
|
57
|
+
expect(stack.empty?).to be(false)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '#call' do
|
|
62
|
+
it 'ejecuta el core directamente si no hay middlewares' do
|
|
63
|
+
core_called = false
|
|
64
|
+
stack.call(delivery_info, properties, body) { core_called = true }
|
|
65
|
+
expect(core_called).to be(true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'ejecuta los middlewares en orden FIFO — el primero registrado envuelve al segundo' do
|
|
69
|
+
log = []
|
|
70
|
+
stack.use(tracking_middleware(log, :a)).use(tracking_middleware(log, :b))
|
|
71
|
+
|
|
72
|
+
stack.call(delivery_info, properties, body) { log << :core }
|
|
73
|
+
|
|
74
|
+
expect(log).to eq(%i[a_before b_before core b_after a_after])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'pasa delivery_info, properties y body sin modificar al primer middleware' do
|
|
78
|
+
received = {}
|
|
79
|
+
spy = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
80
|
+
define_method(:call) do |delivery, props, msg|
|
|
81
|
+
received[:delivery] = delivery
|
|
82
|
+
received[:props] = props
|
|
83
|
+
received[:body] = msg
|
|
84
|
+
@app.call(delivery, props, msg)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
stack.use(spy)
|
|
89
|
+
stack.call(delivery_info, properties, body) {}
|
|
90
|
+
|
|
91
|
+
expect(received[:delivery]).to be(delivery_info)
|
|
92
|
+
expect(received[:props]).to be(properties)
|
|
93
|
+
expect(received[:body]).to eq(body)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'usa snapshot del array — un use() concurrente no altera la cadena en ejecución' do
|
|
97
|
+
barrier = Concurrent::CyclicBarrier.new(2)
|
|
98
|
+
intruder_ran = Concurrent::AtomicBoolean.new(false)
|
|
99
|
+
|
|
100
|
+
# Middleware lento que se sincroniza con el thread que registra
|
|
101
|
+
slow = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
102
|
+
define_method(:call) do |delivery, props, msg|
|
|
103
|
+
barrier.wait
|
|
104
|
+
@app.call(delivery, props, msg)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
intruder = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
109
|
+
define_method(:call) do |delivery, props, msg|
|
|
110
|
+
intruder_ran.make_true
|
|
111
|
+
@app.call(delivery, props, msg)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
stack.use(slow)
|
|
116
|
+
|
|
117
|
+
call_thread = Thread.new { stack.call(delivery_info, properties, body) {} }
|
|
118
|
+
|
|
119
|
+
# Registrar el intruder mientras call está bloqueado en barrier.wait
|
|
120
|
+
barrier.wait
|
|
121
|
+
stack.use(intruder)
|
|
122
|
+
|
|
123
|
+
call_thread.join
|
|
124
|
+
|
|
125
|
+
# El intruder se registró DESPUÉS del snapshot — no debe haber ejecutado
|
|
126
|
+
expect(intruder_ran.value).to be(false)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/bunny_mocks'
|
|
5
|
+
|
|
6
|
+
RSpec.describe BugBunny::Consumer do
|
|
7
|
+
include BunnyMocks
|
|
8
|
+
|
|
9
|
+
let(:channel) { BunnyMocks::FakeChannel.new(true) }
|
|
10
|
+
let(:connection) { BunnyMocks::FakeConnection.new(true, channel) }
|
|
11
|
+
let(:consumer) { described_class.new(connection) }
|
|
12
|
+
|
|
13
|
+
let(:fake_session) do
|
|
14
|
+
session = instance_double(BugBunny::Session)
|
|
15
|
+
allow(session).to receive(:exchange).and_return(double('exchange'))
|
|
16
|
+
allow(session).to receive(:queue).and_return(fake_queue)
|
|
17
|
+
allow(session).to receive(:close)
|
|
18
|
+
session
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
let(:fake_queue) do
|
|
22
|
+
q = double('queue')
|
|
23
|
+
allow(q).to receive(:bind)
|
|
24
|
+
allow(q).to receive(:subscribe)
|
|
25
|
+
q
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
before do
|
|
29
|
+
consumer.instance_variable_set(:@session, fake_session)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#shutdown' do
|
|
33
|
+
it 'detiene el health timer' do
|
|
34
|
+
timer = instance_double(Concurrent::TimerTask)
|
|
35
|
+
expect(timer).to receive(:shutdown)
|
|
36
|
+
|
|
37
|
+
consumer.instance_variable_set(:@health_timer, timer)
|
|
38
|
+
consumer.shutdown
|
|
39
|
+
|
|
40
|
+
expect(consumer.instance_variable_get(:@health_timer)).to be_nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'es idempotente si no hay timer activo' do
|
|
44
|
+
consumer.instance_variable_set(:@health_timer, nil)
|
|
45
|
+
expect { consumer.shutdown }.not_to raise_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'cierra la sesión' do
|
|
49
|
+
expect(fake_session).to receive(:close)
|
|
50
|
+
consumer.shutdown
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#subscribe' do
|
|
55
|
+
it 'llama a shutdown en el ensure al salir normalmente' do
|
|
56
|
+
shutdown_called = false
|
|
57
|
+
consumer.define_singleton_method(:shutdown) { shutdown_called = true }
|
|
58
|
+
|
|
59
|
+
consumer.subscribe(
|
|
60
|
+
queue_name: 'q',
|
|
61
|
+
exchange_name: 'x',
|
|
62
|
+
routing_key: '#',
|
|
63
|
+
block: false
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(shutdown_called).to be(true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'llama a shutdown aunque subscribe falle con max_reconnect_attempts=1' do
|
|
70
|
+
shutdown_called = false
|
|
71
|
+
consumer.define_singleton_method(:shutdown) { shutdown_called = true }
|
|
72
|
+
|
|
73
|
+
allow(fake_session).to receive(:exchange).and_raise(RuntimeError, 'boom')
|
|
74
|
+
BugBunny.configuration.max_reconnect_attempts = 1
|
|
75
|
+
|
|
76
|
+
expect do
|
|
77
|
+
consumer.subscribe(
|
|
78
|
+
queue_name: 'q',
|
|
79
|
+
exchange_name: 'x',
|
|
80
|
+
routing_key: '#',
|
|
81
|
+
block: false
|
|
82
|
+
)
|
|
83
|
+
end.to raise_error(RuntimeError)
|
|
84
|
+
|
|
85
|
+
expect(shutdown_called).to be(true)
|
|
86
|
+
ensure
|
|
87
|
+
BugBunny.configuration.max_reconnect_attempts = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe BugBunny::Controller, 'after_action' do
|
|
6
|
+
# Construye un controlador mínimo funcional y lo ejecuta directamente.
|
|
7
|
+
def call_controller(klass, action: :index, body: {})
|
|
8
|
+
klass.call(headers: { action: action.to_s }, body: body)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '.after_action' do
|
|
12
|
+
it 'registra el callback y retorna void' do
|
|
13
|
+
klass = Class.new(BugBunny::Controller)
|
|
14
|
+
klass.after_action :log_after
|
|
15
|
+
expect(klass.after_actions[:_all_actions]).to include(:log_after)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'soporta la opción only: para restringir la acción' do
|
|
19
|
+
klass = Class.new(BugBunny::Controller)
|
|
20
|
+
klass.after_action :log_after, only: [:show]
|
|
21
|
+
expect(klass.after_actions[:show]).to include(:log_after)
|
|
22
|
+
expect(klass.after_actions[:_all_actions]).to be_nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'no muta la clase padre al registrar en la subclase' do
|
|
26
|
+
parent = Class.new(BugBunny::Controller)
|
|
27
|
+
child = Class.new(parent)
|
|
28
|
+
child.after_action :child_callback
|
|
29
|
+
|
|
30
|
+
expect(parent.after_actions[:_all_actions]).to be_nil
|
|
31
|
+
expect(child.after_actions[:_all_actions]).to include(:child_callback)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe 'ejecución' do
|
|
36
|
+
it 'ejecuta el callback después de la acción' do
|
|
37
|
+
log = []
|
|
38
|
+
klass = Class.new(BugBunny::Controller) do
|
|
39
|
+
after_action :record_after
|
|
40
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
41
|
+
define_method(:record_after) { log << :after }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
call_controller(klass)
|
|
45
|
+
expect(log).to eq(%i[action after])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'ejecuta múltiples after_actions en orden FIFO' do
|
|
49
|
+
log = []
|
|
50
|
+
klass = Class.new(BugBunny::Controller) do
|
|
51
|
+
after_action :first_after
|
|
52
|
+
after_action :second_after
|
|
53
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
54
|
+
define_method(:first_after) { log << :first }
|
|
55
|
+
define_method(:second_after) { log << :second }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
call_controller(klass)
|
|
59
|
+
expect(log).to eq(%i[action first second])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'ejecuta after_action después de before_action y de la acción' do
|
|
63
|
+
log = []
|
|
64
|
+
klass = Class.new(BugBunny::Controller) do
|
|
65
|
+
before_action :record_before
|
|
66
|
+
after_action :record_after
|
|
67
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
68
|
+
define_method(:record_before) { log << :before }
|
|
69
|
+
define_method(:record_after) { log << :after }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
call_controller(klass)
|
|
73
|
+
expect(log).to eq(%i[before action after])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'ejecuta after_action dentro del yield de around_action' do
|
|
77
|
+
log = []
|
|
78
|
+
klass = Class.new(BugBunny::Controller) do
|
|
79
|
+
around_action :wrap
|
|
80
|
+
after_action :record_after
|
|
81
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
82
|
+
define_method(:record_after) { log << :after }
|
|
83
|
+
define_method(:wrap) { |&blk| log << :around_pre; blk.call; log << :around_post }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
call_controller(klass)
|
|
87
|
+
# after_action corre dentro del yield del around, antes de :around_post
|
|
88
|
+
expect(log).to eq(%i[around_pre action after around_post])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'NO ejecuta after_action si before_action interrumpió con render' do
|
|
92
|
+
log = []
|
|
93
|
+
klass = Class.new(BugBunny::Controller) do
|
|
94
|
+
before_action :halt_early
|
|
95
|
+
after_action :record_after
|
|
96
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
97
|
+
define_method(:halt_early) { render status: 403, json: { error: 'forbidden' } }
|
|
98
|
+
define_method(:record_after) { log << :after }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
call_controller(klass)
|
|
102
|
+
expect(log).to be_empty
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'NO ejecuta after_action si la acción lanzó una excepción' do
|
|
106
|
+
log = []
|
|
107
|
+
klass = Class.new(BugBunny::Controller) do
|
|
108
|
+
after_action :record_after
|
|
109
|
+
define_method(:index) { raise 'boom' }
|
|
110
|
+
define_method(:record_after) { log << :after }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
call_controller(klass) # rescue_from genérico devuelve 500
|
|
114
|
+
expect(log).to be_empty
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'respeta only: y no ejecuta el callback en otras acciones' do
|
|
118
|
+
log = []
|
|
119
|
+
klass = Class.new(BugBunny::Controller) do
|
|
120
|
+
after_action :record_after, only: [:show]
|
|
121
|
+
define_method(:index) { log << :index_ran; render status: 200, json: {} }
|
|
122
|
+
define_method(:show) { log << :show_ran; render status: 200, json: {} }
|
|
123
|
+
define_method(:record_after) { log << :after }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
call_controller(klass, action: :index)
|
|
127
|
+
expect(log).to eq(%i[index_ran])
|
|
128
|
+
|
|
129
|
+
log.clear
|
|
130
|
+
call_controller(klass, action: :show)
|
|
131
|
+
expect(log).to eq(%i[show_ran after])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'hereda after_actions del padre y agrega los propios sin mutarlo' do
|
|
135
|
+
log = []
|
|
136
|
+
parent = Class.new(BugBunny::Controller) do
|
|
137
|
+
after_action :parent_after
|
|
138
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
139
|
+
define_method(:parent_after) { log << :parent }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
child = Class.new(parent) do
|
|
143
|
+
after_action :child_after
|
|
144
|
+
define_method(:child_after) { log << :child }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
call_controller(child)
|
|
148
|
+
expect(log).to eq(%i[action parent child])
|
|
149
|
+
|
|
150
|
+
log.clear
|
|
151
|
+
call_controller(parent)
|
|
152
|
+
expect(log).to eq(%i[action parent])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
RSpec.describe BugBunny::Observability do
|
|
7
|
+
# Host class mínimo para ejercitar el mixin.
|
|
8
|
+
let(:host_class) do
|
|
9
|
+
Class.new do
|
|
10
|
+
include BugBunny::Observability
|
|
11
|
+
|
|
12
|
+
attr_writer :logger
|
|
13
|
+
|
|
14
|
+
def initialize(logger)
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Expone safe_log públicamente solo para tests.
|
|
19
|
+
public :safe_log
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:log_output) { StringIO.new }
|
|
24
|
+
let(:logger) { Logger.new(log_output) }
|
|
25
|
+
let(:host) { host_class.new(logger) }
|
|
26
|
+
|
|
27
|
+
# Extrae el mensaje del log (después del prefijo "D, [timestamp] DEBUG -- :")
|
|
28
|
+
def last_log_line
|
|
29
|
+
log_output.string.split("\n").last.to_s.sub(/\A.*?:\s*/, '')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '.sensitive_key? (módulo público)' do
|
|
33
|
+
subject(:sensitive?) { BugBunny::Observability.method(:sensitive_key?) }
|
|
34
|
+
|
|
35
|
+
context 'keys símbolo' do
|
|
36
|
+
it 'filtra :password' do
|
|
37
|
+
expect(BugBunny::Observability.sensitive_key?(:password)).to be(true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'filtra :token' do
|
|
41
|
+
expect(BugBunny::Observability.sensitive_key?(:token)).to be(true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'filtra :secret' do
|
|
45
|
+
expect(BugBunny::Observability.sensitive_key?(:secret)).to be(true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'filtra :api_key' do
|
|
49
|
+
expect(BugBunny::Observability.sensitive_key?(:api_key)).to be(true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'filtra :auth' do
|
|
53
|
+
expect(BugBunny::Observability.sensitive_key?(:auth)).to be(true)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'keys string' do
|
|
58
|
+
it 'filtra "password"' do
|
|
59
|
+
expect(BugBunny::Observability.sensitive_key?('password')).to be(true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'filtra "Authorization" (case-insensitive)' do
|
|
63
|
+
expect(BugBunny::Observability.sensitive_key?('Authorization')).to be(true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'filtra "X-Api-Key" (case-insensitive)' do
|
|
67
|
+
expect(BugBunny::Observability.sensitive_key?('X-Api-Key')).to be(true)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context 'partial matches' do
|
|
72
|
+
it 'filtra "user_password"' do
|
|
73
|
+
expect(BugBunny::Observability.sensitive_key?('user_password')).to be(true)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'filtra "access_token"' do
|
|
77
|
+
expect(BugBunny::Observability.sensitive_key?('access_token')).to be(true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'filtra "refresh_token"' do
|
|
81
|
+
expect(BugBunny::Observability.sensitive_key?('refresh_token')).to be(true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'filtra "accessToken" (camelCase)' do
|
|
85
|
+
expect(BugBunny::Observability.sensitive_key?('accessToken')).to be(true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'filtra "password2"' do
|
|
89
|
+
expect(BugBunny::Observability.sensitive_key?('password2')).to be(true)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'filtra "csrf_token"' do
|
|
93
|
+
expect(BugBunny::Observability.sensitive_key?('csrf_token')).to be(true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'filtra "csrftoken"' do
|
|
97
|
+
expect(BugBunny::Observability.sensitive_key?('csrftoken')).to be(true)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'filtra "db_credentials"' do
|
|
101
|
+
expect(BugBunny::Observability.sensitive_key?('db_credentials')).to be(true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'filtra "private_key"' do
|
|
105
|
+
expect(BugBunny::Observability.sensitive_key?('private_key')).to be(true)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'filtra "session_id"' do
|
|
109
|
+
expect(BugBunny::Observability.sensitive_key?('session_id')).to be(true)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
context 'sin falsos positivos' do
|
|
114
|
+
it 'no filtra "username"' do
|
|
115
|
+
expect(BugBunny::Observability.sensitive_key?('username')).to be(false)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'no filtra "user_email"' do
|
|
119
|
+
expect(BugBunny::Observability.sensitive_key?('user_email')).to be(false)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'no filtra "passport_number"' do
|
|
123
|
+
expect(BugBunny::Observability.sensitive_key?('passport_number')).to be(false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'no filtra "status"' do
|
|
127
|
+
expect(BugBunny::Observability.sensitive_key?('status')).to be(false)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'no filtra "duration_s"' do
|
|
131
|
+
expect(BugBunny::Observability.sensitive_key?('duration_s')).to be(false)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe '#safe_log — filtrado en el output' do
|
|
137
|
+
it 'reemplaza el valor de una key sensible con [FILTERED]' do
|
|
138
|
+
host.safe_log(:info, 'test.event', password: 'secret123')
|
|
139
|
+
expect(last_log_line).to include('password=[FILTERED]')
|
|
140
|
+
expect(last_log_line).not_to include('secret123')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'filtra keys string sensibles' do
|
|
144
|
+
host.safe_log(:info, 'test.event', 'Authorization' => 'Bearer xyz')
|
|
145
|
+
expect(last_log_line).to include('Authorization=[FILTERED]')
|
|
146
|
+
expect(last_log_line).not_to include('Bearer xyz')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'filtra partial match en key' do
|
|
150
|
+
host.safe_log(:info, 'test.event', user_password: 'hunter2')
|
|
151
|
+
expect(last_log_line).to include('user_password=[FILTERED]')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'no filtra keys no sensibles' do
|
|
155
|
+
host.safe_log(:info, 'test.event', username: 'gabriel')
|
|
156
|
+
expect(last_log_line).to include('username=gabriel')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'filtra múltiples keys sensibles en el mismo log' do
|
|
160
|
+
host.safe_log(:info, 'test.event', token: 'abc', status: 'ok', secret: 'xyz')
|
|
161
|
+
line = last_log_line
|
|
162
|
+
expect(line).to include('token=[FILTERED]')
|
|
163
|
+
expect(line).to include('secret=[FILTERED]')
|
|
164
|
+
expect(line).to include('status=ok')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
module ResourceAttributesSpec
|
|
6
|
+
class Product < BugBunny::Resource
|
|
7
|
+
attribute :name, :string
|
|
8
|
+
attribute :price, :decimal
|
|
9
|
+
attribute :active, :boolean
|
|
10
|
+
attribute :created_at, :datetime
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RSpec.describe BugBunny::Resource do
|
|
15
|
+
let(:product_class) { ResourceAttributesSpec::Product }
|
|
16
|
+
|
|
17
|
+
describe 'ActiveModel::Attributes integration' do
|
|
18
|
+
it 'realiza la coerción de tipos para atributos definidos' do
|
|
19
|
+
product = product_class.new(
|
|
20
|
+
name: 'Teclado',
|
|
21
|
+
price: '25.50',
|
|
22
|
+
active: '1',
|
|
23
|
+
created_at: '2026-04-01 12:00:00'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(product.name).to eq('Teclado')
|
|
27
|
+
expect(product.price).to be_a(BigDecimal)
|
|
28
|
+
expect(product.price).to eq(BigDecimal('25.50'))
|
|
29
|
+
expect(product.active).to be(true)
|
|
30
|
+
expect(product.created_at).to be_a(Time)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'permite atributos dinámicos que no están definidos' do
|
|
34
|
+
product = product_class.new(name: 'Teclado', category: 'Hardware')
|
|
35
|
+
|
|
36
|
+
expect(product.name).to eq('Teclado')
|
|
37
|
+
expect(product.category).to eq('Hardware')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'detecta cambios tanto en atributos definidos como dinámicos usando ActiveModel::Dirty' do
|
|
41
|
+
product = product_class.new(name: 'Teclado', price: 10.0)
|
|
42
|
+
product.persisted = true
|
|
43
|
+
product.clear_changes_information
|
|
44
|
+
|
|
45
|
+
# Cambio en atributo definido (tipado)
|
|
46
|
+
product.price = 15.0
|
|
47
|
+
# Cambio en atributo dinámico
|
|
48
|
+
product.sku = 'TK-123'
|
|
49
|
+
|
|
50
|
+
expect(product.changed?).to be(true)
|
|
51
|
+
expect(product.changed).to include('price', 'sku')
|
|
52
|
+
|
|
53
|
+
expect(product.changes_to_send).to eq({
|
|
54
|
+
'price' => 15.0,
|
|
55
|
+
'sku' => 'TK-123'
|
|
56
|
+
})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'devuelve el ID correctamente sin importar dónde esté almacenado (ID aliases)' do
|
|
60
|
+
p1 = product_class.new(id: '123')
|
|
61
|
+
p2 = product_class.new(ID: '456')
|
|
62
|
+
p3 = product_class.new(_id: '789')
|
|
63
|
+
|
|
64
|
+
expect(p1.id).to eq('123')
|
|
65
|
+
expect(p2.id).to eq('456')
|
|
66
|
+
expect(p3.id).to eq('789')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|