journaled 2.0.0.alpha1 → 2.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -25
  3. data/app/models/concerns/journaled/changes.rb +41 -0
  4. data/app/models/journaled/actor_uri_provider.rb +22 -0
  5. data/app/models/journaled/change_definition.rb +17 -1
  6. data/app/models/journaled/change_writer.rb +3 -22
  7. data/app/models/journaled/event.rb +0 -4
  8. data/config/initializers/change_protection.rb +3 -0
  9. data/lib/journaled.rb +9 -1
  10. data/lib/journaled/relation_change_protection.rb +27 -0
  11. data/lib/journaled/rspec.rb +18 -0
  12. data/lib/journaled/version.rb +1 -1
  13. data/spec/dummy/README.rdoc +28 -0
  14. data/spec/dummy/Rakefile +6 -0
  15. data/spec/dummy/bin/bundle +3 -0
  16. data/spec/dummy/bin/rails +4 -0
  17. data/spec/dummy/bin/rake +4 -0
  18. data/spec/dummy/config.ru +4 -0
  19. data/spec/dummy/config/application.rb +26 -0
  20. data/spec/dummy/config/boot.rb +5 -0
  21. data/spec/dummy/config/database.yml +21 -0
  22. data/spec/dummy/config/environment.rb +5 -0
  23. data/spec/dummy/config/environments/development.rb +37 -0
  24. data/spec/dummy/config/environments/production.rb +78 -0
  25. data/spec/dummy/config/environments/test.rb +39 -0
  26. data/spec/dummy/config/initializers/assets.rb +8 -0
  27. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  28. data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
  29. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  30. data/spec/dummy/config/initializers/inflections.rb +16 -0
  31. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  32. data/spec/dummy/config/initializers/session_store.rb +3 -0
  33. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  34. data/spec/dummy/config/locales/en.yml +23 -0
  35. data/spec/dummy/config/routes.rb +56 -0
  36. data/spec/dummy/config/secrets.yml +22 -0
  37. data/spec/dummy/db/migrate/20180606205114_create_delayed_jobs.rb +18 -0
  38. data/spec/dummy/db/schema.rb +31 -0
  39. data/spec/dummy/log/development.log +34 -0
  40. data/spec/dummy/log/test.log +34540 -0
  41. data/spec/dummy/public/404.html +67 -0
  42. data/spec/dummy/public/422.html +67 -0
  43. data/spec/dummy/public/500.html +66 -0
  44. data/spec/dummy/public/favicon.ico +0 -0
  45. data/spec/lib/journaled_spec.rb +51 -0
  46. data/spec/models/concerns/journaled/actor_spec.rb +46 -0
  47. data/spec/models/concerns/journaled/changes_spec.rb +94 -0
  48. data/spec/models/database_change_protection_spec.rb +106 -0
  49. data/spec/models/journaled/actor_uri_provider_spec.rb +41 -0
  50. data/spec/models/journaled/change_writer_spec.rb +276 -0
  51. data/spec/models/journaled/delivery_spec.rb +156 -0
  52. data/spec/models/journaled/event_spec.rb +145 -0
  53. data/spec/models/journaled/json_schema_model/validator_spec.rb +133 -0
  54. data/spec/models/journaled/writer_spec.rb +129 -0
  55. data/spec/rails_helper.rb +20 -0
  56. data/spec/spec_helper.rb +22 -0
  57. data/spec/support/delayed_job_spec_helper.rb +11 -0
  58. data/spec/support/environment_spec_helper.rb +16 -0
  59. metadata +113 -4
@@ -0,0 +1,41 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe Journaled::ActorUriProvider do
4
+ describe "#actor_uri" do
5
+ let(:request_store) { double(:[] => nil) }
6
+ let(:actor) { double(to_global_id: actor_gid) }
7
+ let(:actor_gid) { double(to_s: "my_fancy_gid") }
8
+ let(:program_name) { "/usr/local/bin/puma_or_something" }
9
+
10
+ subject { described_class.instance }
11
+
12
+ around do |example|
13
+ orig_program_name = $PROGRAM_NAME
14
+ $PROGRAM_NAME = program_name
15
+ example.run
16
+ $PROGRAM_NAME = orig_program_name
17
+ end
18
+
19
+ before do
20
+ allow(RequestStore).to receive(:store).and_return(request_store)
21
+ end
22
+
23
+ it "returns the global ID of the entity returned by RequestStore.store[:journaled_actor_proc].call if set" do
24
+ allow(request_store).to receive(:[]).and_return(-> { actor })
25
+ expect(subject.actor_uri).to eq("my_fancy_gid")
26
+ expect(request_store).to have_received(:[]).with(:journaled_actor_proc)
27
+ end
28
+
29
+ context "when running in rake" do
30
+ let(:program_name) { "rake" }
31
+ it "slurps up command line username if available" do
32
+ allow(Etc).to receive(:getlogin).and_return("my_unix_username")
33
+ expect(subject.actor_uri).to eq("gid://local/my_unix_username")
34
+ end
35
+ end
36
+
37
+ it "falls back to printing out a GID of bare app name" do
38
+ expect(subject.actor_uri).to eq("gid://dummy")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,276 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe Journaled::ChangeWriter do
4
+ let(:model) do
5
+ now = Time.zone.now
6
+ double(
7
+ "Soldier",
8
+ id: 28_473,
9
+ class: model_class,
10
+ attributes: {
11
+ "name" => "bob",
12
+ "rank" => "first lieutenant",
13
+ "serial_number" => "foobar",
14
+ "last_sign_in_at" => now
15
+ },
16
+ saved_changes: {
17
+ "name" => %w(bill bob),
18
+ "last_sign_in_at" => now
19
+ }
20
+ )
21
+ end
22
+
23
+ let(:model_class) do
24
+ double(
25
+ "SoldierClass",
26
+ table_name: "soldiers",
27
+ attribute_names: %w(id name rank serial_number last_sign_in_at)
28
+ )
29
+ end
30
+
31
+ let(:change_definition) do
32
+ Journaled::ChangeDefinition.new(
33
+ attribute_names: %i(name rank serial_number),
34
+ logical_operation: "identity_change"
35
+ )
36
+ end
37
+
38
+ let(:faulty_change_definition) do
39
+ Journaled::ChangeDefinition.new(
40
+ attribute_names: %i(name rank serial_number nonexistent_thingie),
41
+ logical_operation: "identity_change"
42
+ )
43
+ end
44
+
45
+ subject { described_class.new(model: model, change_definition: change_definition) }
46
+
47
+ it "fails to instantiate with an undefined attribute_name" do
48
+ expect { described_class.new(model: model, change_definition: faulty_change_definition) }.to raise_error(/\bnonexistent_thingie\n/)
49
+ end
50
+
51
+ describe "#relevant_attributes" do
52
+ let(:model) do
53
+ double(
54
+ "Soldier",
55
+ id: 28_473,
56
+ class: model_class,
57
+ attributes: {
58
+ "name" => "bill",
59
+ "rank" => "first lieutenant",
60
+ "serial_number" => "foobar",
61
+ "last_sign_in_at" => Time.zone.now
62
+ },
63
+ saved_changes: {}
64
+ )
65
+ end
66
+
67
+ it "returns all relevant attributes regardless of saved changes" do
68
+ expect(subject.relevant_attributes).to eq(
69
+ "name" => "bill",
70
+ "rank" => "first lieutenant",
71
+ "serial_number" => "foobar"
72
+ )
73
+ end
74
+ end
75
+
76
+ describe "#relevant_unperturbed_attributes" do
77
+ let(:model) do
78
+ double(
79
+ "Soldier",
80
+ id: 28_473,
81
+ class: model_class,
82
+ attributes: {
83
+ "name" => "bill",
84
+ "rank" => "first lieutenant",
85
+ "serial_number" => "foobar",
86
+ "last_sign_in_at" => Time.zone.now
87
+ },
88
+ changes: {
89
+ "name" => %w(bob bill)
90
+ }
91
+ )
92
+ end
93
+
94
+ it "returns the pre-change value of the attributes, regardless of whether they changed" do
95
+ expect(subject.relevant_unperturbed_attributes).to eq(
96
+ "name" => "bob",
97
+ "rank" => "first lieutenant",
98
+ "serial_number" => "foobar"
99
+ )
100
+ end
101
+ end
102
+
103
+ describe "#relevant_changed_attributes" do
104
+ it "returns only relevant changes" do
105
+ expect(subject.relevant_changed_attributes).to eq("name" => "bob")
106
+ end
107
+ end
108
+
109
+ describe "#actor_uri" do
110
+ it "delegates to ActorUriProvider" do
111
+ allow(Journaled::ActorUriProvider).to receive(:instance).and_return(double(actor_uri: "my actor uri"))
112
+ expect(Journaled.actor_uri).to eq "my actor uri"
113
+ end
114
+ end
115
+
116
+ describe "#journaled_change_for" do
117
+ it "stores passed changes serialized to json" do
118
+ expect(subject.journaled_change_for("update", "name" => "bob").changes).to eq('{"name":"bob"}')
119
+ end
120
+
121
+ it "stores the model's table_name" do
122
+ expect(subject.journaled_change_for("update", {}).table_name).to eq("soldiers")
123
+ end
124
+
125
+ it "converts the model's record_id to a string" do
126
+ expect(subject.journaled_change_for("update", {}).record_id).to eq("28473")
127
+ end
128
+
129
+ it "stuffs the database operation directly" do
130
+ expect(subject.journaled_change_for("update", {}).database_operation).to eq("update")
131
+ expect(subject.journaled_change_for("delete", {}).database_operation).to eq("delete")
132
+ end
133
+
134
+ it "includes logical_operation" do
135
+ expect(subject.journaled_change_for("update", {}).logical_operation).to eq("identity_change")
136
+ end
137
+
138
+ it "doesn't set journaled_app_name if model class doesn't respond to it" do
139
+ expect(subject.journaled_change_for("update", {}).journaled_app_name).to eq(nil)
140
+ end
141
+
142
+ context "with journaled default app name set" do
143
+ around do |example|
144
+ orig_app_name = Journaled.default_app_name
145
+ Journaled.default_app_name = "foo"
146
+ example.run
147
+ Journaled.default_app_name = orig_app_name
148
+ end
149
+
150
+ it "passes through default" do
151
+ expect(subject.journaled_change_for("update", {}).journaled_app_name).to eq("foo")
152
+ end
153
+ end
154
+
155
+ context "when model class defines journaled_app_name" do
156
+ before do
157
+ allow(model_class).to receive(:journaled_app_name).and_return("my_app")
158
+ end
159
+
160
+ it "sets journaled_app_name if model_class responds to it" do
161
+ expect(subject.journaled_change_for("update", {}).journaled_app_name).to eq("my_app")
162
+ end
163
+ end
164
+ end
165
+
166
+ context "with journaling stubbed" do
167
+ let(:journaled_change) { instance_double(Journaled::Change, journal!: true) }
168
+
169
+ before do
170
+ allow(Journaled::Change).to receive(:new).and_return(nil) # must be restubbed to work in context
171
+ end
172
+
173
+ describe "#create" do
174
+ let(:model) do
175
+ double(
176
+ "Soldier",
177
+ id: 28_473,
178
+ class: model_class,
179
+ attributes: {
180
+ "name" => "bill",
181
+ "rank" => "first lieutenant",
182
+ "serial_number" => "foobar",
183
+ "last_sign_in_at" => Time.zone.now
184
+ },
185
+ saved_changes: {}
186
+ )
187
+ end
188
+
189
+ it "always journals all relevant attributes, even if unchanged" do
190
+ allow(Journaled::Change).to receive(:new) do |opts|
191
+ expect(opts[:changes]).to eq '{"name":"bill","rank":"first lieutenant","serial_number":"foobar"}'
192
+ journaled_change
193
+ end
194
+
195
+ subject.create
196
+
197
+ expect(Journaled::Change).to have_received(:new)
198
+ expect(journaled_change).to have_received(:journal!)
199
+ end
200
+ end
201
+
202
+ describe "#update" do
203
+ it "journals only relevant changes" do
204
+ allow(Journaled::Change).to receive(:new) do |opts|
205
+ expect(opts[:changes]).to eq '{"name":"bob"}'
206
+ journaled_change
207
+ end
208
+
209
+ subject.update
210
+
211
+ expect(Journaled::Change).to have_received(:new)
212
+ expect(journaled_change).to have_received(:journal!)
213
+ end
214
+
215
+ context "with no changes" do
216
+ let(:model) do
217
+ double(
218
+ "Soldier",
219
+ id: 28_473,
220
+ class: model_class,
221
+ attributes: {
222
+ "name" => "bill",
223
+ "rank" => "first lieutenant",
224
+ "serial_number" => "foobar",
225
+ "last_sign_in_at" => Time.zone.now
226
+ },
227
+ saved_changes: {}
228
+ )
229
+ end
230
+
231
+ it "doesn't journal" do
232
+ subject.update
233
+
234
+ expect(Journaled::Change).not_to have_received(:new)
235
+ expect(journaled_change).not_to have_received(:journal!)
236
+ end
237
+ end
238
+ end
239
+
240
+ describe "#delete" do
241
+ let(:model) do
242
+ now = Time.zone.now
243
+ double(
244
+ "Soldier",
245
+ id: 28_473,
246
+ class: model_class,
247
+ attributes: {
248
+ "name" => "bob",
249
+ "rank" => "first lieutenant",
250
+ "serial_number" => "foobar",
251
+ "last_sign_in_at" => now
252
+ },
253
+ changes: {
254
+ "name" => %w(bill bob)
255
+ }
256
+ )
257
+ end
258
+
259
+ it "journals the unperturbed values of all relevant attributes" do
260
+ allow(Journaled::Change).to receive(:new) do |opts|
261
+ expect(JSON.parse(opts[:changes])).to eq(
262
+ "name" => "bill",
263
+ "rank" => "first lieutenant",
264
+ "serial_number" => "foobar"
265
+ )
266
+ journaled_change
267
+ end
268
+
269
+ subject.delete
270
+
271
+ expect(Journaled::Change).to have_received(:new)
272
+ expect(journaled_change).to have_received(:journal!)
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,156 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe Journaled::Delivery do
4
+ let(:stream_name) { 'test_events' }
5
+ let(:partition_key) { 'fake_partition_key' }
6
+ let(:serialized_event) { '{"foo":"bar"}' }
7
+
8
+ around do |example|
9
+ with_env(JOURNALED_STREAM_NAME: stream_name) { example.run }
10
+ end
11
+
12
+ subject { described_class.new serialized_event: serialized_event, partition_key: partition_key, app_name: nil }
13
+
14
+ describe '#perform' do
15
+ let!(:stubbed_request) do
16
+ stub_request(:post, 'https://kinesis.us-east-1.amazonaws.com').to_return(status: return_status_code, body: return_status_body)
17
+ end
18
+ let(:return_status_code) { 200 }
19
+ let(:return_status_body) { return_status_body_hash.to_json }
20
+ let(:return_status_body_hash) { { RecordId: '101' } }
21
+
22
+ let(:stubbed_body) do
23
+ {
24
+ 'StreamName' => stream_name,
25
+ 'Data' => Base64.encode64(serialized_event).strip,
26
+ 'PartitionKey' => 'fake_partition_key'
27
+ }
28
+ end
29
+
30
+ before do
31
+ allow(Journaled).to receive(:enabled?).and_return(true)
32
+ end
33
+
34
+ it 'makes requests to AWS to put the event on the Kinesis with the correct body' do
35
+ subject.perform
36
+
37
+ expect(stubbed_request.with(body: stubbed_body.to_json)).to have_been_requested.once
38
+ end
39
+
40
+ context 'when the stream name env var is NOT set' do
41
+ let(:stream_name) { nil }
42
+
43
+ it 'raises an KeyError error' do
44
+ expect { subject.perform }.to raise_error KeyError
45
+ end
46
+ end
47
+
48
+ context 'when Amazon responds with an InternalFailure' do
49
+ let(:return_status_code) { 500 }
50
+ let(:return_status_body_hash) { { __type: 'InternalFailure' } }
51
+
52
+ it 'catches the error and re-raises a subclass of NotTrulyExceptionalError and logs about the failure' do
53
+ expect(Rails.logger).to receive(:error).with("Kinesis Error - Server Error occurred - Aws::Kinesis::Errors::InternalFailure").once
54
+ expect { subject.perform }.to raise_error described_class::KinesisTemporaryFailure
55
+ expect(stubbed_request).to have_been_requested.once
56
+ end
57
+ end
58
+
59
+ context 'when Amazon responds with a ServiceUnavailable' do
60
+ let(:return_status_code) { 503 }
61
+ let(:return_status_body_hash) { { __type: 'ServiceUnavailable' } }
62
+
63
+ it 'catches the error and re-raises a subclass of NotTrulyExceptionalError and logs about the failure' do
64
+ allow(Rails.logger).to receive(:error)
65
+ expect { subject.perform }.to raise_error described_class::KinesisTemporaryFailure
66
+ expect(stubbed_request).to have_been_requested.once
67
+ expect(Rails.logger).to have_received(:error).with(/\AKinesis Error/).once
68
+ end
69
+ end
70
+
71
+ context 'when we receive a 504 Gateway timeout' do
72
+ let(:return_status_code) { 504 }
73
+ let(:return_status_body) { nil }
74
+
75
+ it 'raises an error that subclasses Aws::Kinesis::Errors::ServiceError' do
76
+ expect { subject.perform }.to raise_error Aws::Kinesis::Errors::ServiceError
77
+ expect(stubbed_request).to have_been_requested.once
78
+ end
79
+ end
80
+
81
+ context 'when the IAM user does not have permission to put_record to the specified stream' do
82
+ let(:return_status_code) { 400 }
83
+ let(:return_status_body_hash) { { __type: 'AccessDeniedException' } }
84
+
85
+ it 'raises an AccessDeniedException error' do
86
+ expect { subject.perform }.to raise_error Aws::Kinesis::Errors::AccessDeniedException
87
+ expect(stubbed_request).to have_been_requested.once
88
+ end
89
+ end
90
+
91
+ context 'when the request timesout' do
92
+ let!(:stubbed_request) do
93
+ stub_request(:post, 'https://kinesis.us-east-1.amazonaws.com').to_timeout
94
+ end
95
+
96
+ it 'catches the error and re-raises a subclass of NotTrulyExceptionalError and logs about the failure' do
97
+ expect(Rails.logger).to receive(:error).with("Kinesis Error - Networking Error occurred - Seahorse::Client::NetworkingError").once
98
+ expect { subject.perform }.to raise_error described_class::KinesisTemporaryFailure
99
+ expect(stubbed_request).to have_been_requested.once
100
+ end
101
+ end
102
+ end
103
+
104
+ describe "#stream_name" do
105
+ context "when app_name is unspecified" do
106
+ subject { described_class.new serialized_event: serialized_event, partition_key: partition_key, app_name: nil }
107
+
108
+ it "is fetched from a prefixed ENV var if specified" do
109
+ allow(ENV).to receive(:fetch).and_return("expected_stream_name")
110
+ expect(subject.stream_name).to eq("expected_stream_name")
111
+ expect(ENV).to have_received(:fetch).with("JOURNALED_STREAM_NAME")
112
+ end
113
+ end
114
+
115
+ context "when app_name is specified" do
116
+ subject { described_class.new serialized_event: serialized_event, partition_key: partition_key, app_name: "my_funky_app_name" }
117
+
118
+ it "is fetched from a prefixed ENV var if specified" do
119
+ allow(ENV).to receive(:fetch).and_return("expected_stream_name")
120
+ expect(subject.stream_name).to eq("expected_stream_name")
121
+ expect(ENV).to have_received(:fetch).with("MY_FUNKY_APP_NAME_JOURNALED_STREAM_NAME")
122
+ end
123
+ end
124
+ end
125
+
126
+ describe "#kinesis_client_config" do
127
+ it "is in us-east-1 by default" do
128
+ with_env(AWS_DEFAULT_REGION: nil) do
129
+ expect(subject.kinesis_client_config).to include(region: 'us-east-1')
130
+ end
131
+ end
132
+
133
+ it "respects AWS_DEFAULT_REGION env var" do
134
+ with_env(AWS_DEFAULT_REGION: 'us-west-2') do
135
+ expect(subject.kinesis_client_config).to include(region: 'us-west-2')
136
+ end
137
+ end
138
+
139
+ it "doesn't limit retry" do
140
+ expect(subject.kinesis_client_config).to include(retry_limit: 0)
141
+ end
142
+
143
+ it "provides no AWS credentials by default" do
144
+ with_env(RUBY_AWS_ACCESS_KEY_ID: nil, RUBY_AWS_SECRET_ACCESS_KEY: nil) do
145
+ expect(subject.kinesis_client_config).not_to have_key(:access_key_id)
146
+ expect(subject.kinesis_client_config).not_to have_key(:secret_access_key)
147
+ end
148
+ end
149
+
150
+ it "will use legacy credentials if specified" do
151
+ with_env(RUBY_AWS_ACCESS_KEY_ID: 'key_id', RUBY_AWS_SECRET_ACCESS_KEY: 'secret') do
152
+ expect(subject.kinesis_client_config).to include(access_key_id: 'key_id', secret_access_key: 'secret')
153
+ end
154
+ end
155
+ end
156
+ end