journaled 2.0.0.alpha1 → 2.0.0.rc1

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 (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