logstash-input-okta_system_log 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,35 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'logstash-input-okta_system_log'
3
+ s.version = '0.9.0'
4
+ s.licenses = ['Apache-2.0']
5
+ s.summary = 'This plugin fetches log events from Okta using the System Log API'
6
+ s.homepage = 'https://github.com/SecurityRiskAdvisors/logstash-input-okta_system_log'
7
+ s.authors = ['Security Risk Advisors']
8
+ s.email = 'security@securityriskadvisors.com'
9
+ s.require_paths = ['lib']
10
+
11
+ # Files
12
+ s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
13
+ # Tests
14
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
15
+
16
+ # Special flag to let us know this is actually a logstash plugin
17
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "input" }
18
+
19
+ # Gem dependencies
20
+ s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
21
+ s.add_runtime_dependency 'logstash-codec-plain'
22
+ s.add_runtime_dependency 'stud', "~> 0.0.22"
23
+ #s.add_runtime_dependency 'logstash-mixin-http_client', ">= 6.0.0", "< 7.0.0"
24
+ s.add_runtime_dependency 'logstash-mixin-http_client', ">= 2.2.4", "< 7.0.0" # Maintains 2.4 compat
25
+ s.add_runtime_dependency 'rufus-scheduler', "~>3.0.9"
26
+
27
+ s.add_development_dependency 'logstash-codec-json'
28
+ s.add_development_dependency 'logstash-codec-line'
29
+ s.add_development_dependency 'logstash-devutils', '>= 0.0.16'
30
+ s.add_development_dependency 'flores'
31
+ s.add_development_dependency 'timecop'
32
+ s.add_development_dependency 'rake', "~> 12.1.0"
33
+
34
+
35
+ end
@@ -0,0 +1,753 @@
1
+ require "logstash/devutils/rspec/spec_helper"
2
+ require 'logstash/inputs/okta_system_log'
3
+ require 'flores/random'
4
+ require "timecop"
5
+ require "base64"
6
+ require "rspec/wait"
7
+
8
+ describe LogStash::Inputs::OktaSystemLog do
9
+ let(:queue) { Queue.new }
10
+ let(:default_schedule) {
11
+ { "every" => "30s" }
12
+ }
13
+ let(:default_limit) { 1000 }
14
+ let(:default_auth_token_key) { "asdflkjasdflkjasdf932r098-asdf" }
15
+ let(:default_host) { "localhost" }
16
+ let(:metadata_target) { "_http_poller_metadata" }
17
+ let(:default_state_file_path) { "/dev/null" }
18
+
19
+ let(:default_opts) {
20
+ {
21
+ "schedule" => default_schedule,
22
+ "limit" => default_limit,
23
+ "hostname" => default_host,
24
+ "auth_token_key" => default_auth_token_key,
25
+ "metadata_target" => metadata_target,
26
+ "state_file_path" => default_state_file_path,
27
+ "codec" => "json"
28
+ }
29
+ }
30
+ let(:klass) { LogStash::Inputs::OktaSystemLog }
31
+
32
+ describe "config" do
33
+ shared_examples "configuration errors" do
34
+ it "raises an exception" do
35
+ expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
36
+ end
37
+ end
38
+
39
+ subject { klass.new(opts) }
40
+
41
+ before(:each) do
42
+ subject
43
+ allow(File).to receive(:directory?).with(opts["state_file_path"]) { false }
44
+ allow(File).to receive(:exist?).with(opts["state_file_path"]) { true }
45
+ allow(File).to receive(:stat).with(opts["state_file_path"]) { double("file_stat") }
46
+ # We don't really want to use the atomic write function
47
+ allow(subject).to receive(:detect_write_method).with(opts["state_file_path"]) { subject.method(:non_atomic_write) }
48
+ allow(File).to receive(:size).with(opts["state_file_path"]) { 0 }
49
+ allow(subject).to receive(:update_state_file) { nil }
50
+
51
+ # Might need these later
52
+ #allow(File).to receive(:read).with(opts["state_file_path"], 1) { "\n" }
53
+ #allow(LogStash::Environment).to receive(:windows?) { false }
54
+ #allow(File).to receive(:chardev?).with(opts["state_file_path"]) { false }
55
+ #allow(File).to receive(:blockdev?).with(opts["state_file_path"]) { false }
56
+ #allow(File).to receive(:socket?).with(opts["state_file_path"]) { false }
57
+ end
58
+
59
+ context "the hostname is not in the correct format" do
60
+ let(:opts) { default_opts.merge({"hostname" => "asdf__"}) }
61
+ include_examples("configuration errors")
62
+ end
63
+
64
+ context "both hostname and custom_url are set" do
65
+ let(:opts) { default_opts.merge({"custom_url" => "http://localhost/foo/bar"}) }
66
+ include_examples("configuration errors")
67
+ end
68
+
69
+ context "custom_url is in an incorrect format" do
70
+ let(:opts) {
71
+ opts = default_opts.merge({"custom_url" => "http://___/foo/bar"}).clone
72
+ opts.delete("hostname")
73
+ opts
74
+ }
75
+ include_examples("configuration errors")
76
+ end
77
+
78
+ context "The since parameter is not in the correct format" do
79
+ let(:opts) { default_opts.merge({"since" => "1234567890"}) }
80
+ include_examples("configuration errors")
81
+ end
82
+
83
+ context "The limit parameter is too large" do
84
+ let(:opts) { default_opts.merge({"limit" => 10000}) }
85
+ include_examples("configuration errors")
86
+ end
87
+
88
+ context "The limit is too small" do
89
+ let(:opts) { default_opts.merge({"limit" => -10000}) }
90
+ include_examples("configuration errors")
91
+ end
92
+
93
+ context "the q parameter has too many items" do
94
+ let(:opts) { default_opts.merge({"q" => Array.new(size=11, obj="a")}) }
95
+ include_examples("configuration errors")
96
+ end
97
+
98
+ context "the q parameter item has a space" do
99
+ let(:opts) { default_opts.merge({"q" => ["a b"]}) }
100
+ include_examples("configuration errors")
101
+ end
102
+
103
+ context "the q parameter item is too long" do
104
+ let(:opts) { default_opts.merge({"q" => ["a" * 41]}) }
105
+ include_examples("configuration errors")
106
+ end
107
+
108
+ context "the metadata target is not set" do
109
+ let(:opts) {
110
+ opts = default_opts.clone
111
+ opts.delete("metadata_target")
112
+ opts
113
+ }
114
+ it "sets the metadata function to apply_metadata" do
115
+ subject.register
116
+ expect(subject.instance_variable_get("@metadata_function")).to eql(subject.method(:apply_metadata))
117
+ expect(subject.instance_variable_get("@metadata_target")).to eql("@metadata")
118
+ end
119
+ end
120
+
121
+
122
+ context "auth_token management" do
123
+ let(:auth_file_opts) {
124
+ auth_file_opts = default_opts.merge({"auth_token_file" => "/dev/null"}).clone
125
+ auth_file_opts.delete("auth_token_key")
126
+ auth_file_opts
127
+ }
128
+
129
+ context "custom_auth_header is defined with auth_token_key" do
130
+ let(:opts) {default_opts.merge({"custom_auth_header" => "Basic user:password"})}
131
+ include_examples("configuration errors")
132
+ end
133
+
134
+ context "custom_auth_header is defined with auth_token_file" do
135
+ let(:opts) {auth_file_opts.merge({"custom_auth_header" => "Basic user:password"})}
136
+ include_examples("configuration errors")
137
+ end
138
+
139
+ context "both auth_token key and file are provided" do
140
+ let(:opts) {default_opts.merge({"auth_token_file" => "/dev/null"})}
141
+ include_examples("configuration errors")
142
+ end
143
+
144
+ context "neither auth_token key nor file are provided" do
145
+ let(:opts) {
146
+ opts = default_opts.clone
147
+ opts.delete("auth_token_key")
148
+ opts
149
+ }
150
+ include_examples("configuration errors")
151
+ end
152
+
153
+ context "auth_token_file is too large" do
154
+ let(:opts) {auth_file_opts}
155
+ before {allow(File).to receive(:size).with(opts["auth_token_file"]) { 1 * 2**11 }}
156
+ include_examples("configuration errors")
157
+ end
158
+
159
+ context "auth_token_file could not be read" do
160
+ let(:opts) {auth_file_opts}
161
+ before {
162
+ allow(File).to receive(:size).with(opts["auth_token_file"]) { 10 }
163
+ allow(File).to receive(:read).with(opts["auth_token_file"], 10) { raise IOError }
164
+ }
165
+ include_examples("configuration errors")
166
+ end
167
+
168
+ context "auth_token returns an unauthorized error" do
169
+ let(:opts) { default_opts }
170
+ before do
171
+ subject.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
172
+ :body => "{}",
173
+ :code => klass::HTTP_UNAUTHORIZED_401
174
+ )
175
+ end
176
+ include_examples("configuration errors")
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "instances" do
182
+ subject { klass.new(default_opts) }
183
+
184
+ before do
185
+ subject.client.stub("https://#{default_opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
186
+ :body => "{}",
187
+ :code => klass::HTTP_OK_200
188
+ )
189
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
190
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
191
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
192
+ # We don't really want to use the atomic write function
193
+ allow(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
194
+ allow(File).to receive(:size).with(default_state_file_path) { 0 }
195
+ allow(subject).to receive(:update_state_file) { nil }
196
+ subject.register
197
+ end
198
+
199
+ describe "#run" do
200
+ it "should setup a scheduler" do
201
+ runner = Thread.new do
202
+ subject.run(double("queue"))
203
+ expect(subject.instance_variable_get("@scheduler")).to be_a_kind_of(Rufus::Scheduler)
204
+ end
205
+ runner.kill
206
+ runner.join
207
+ end
208
+ end
209
+
210
+ describe "#run_once" do
211
+ it "should issue an async request for each url" do
212
+ expect(subject).to receive(:request_async).with(queue).once
213
+
214
+ subject.send(:run_once, queue) # :run_once is a private method
215
+ end
216
+ end
217
+ end
218
+
219
+ describe "scheduler configuration" do
220
+ before do
221
+ instance.client.stub("https://#{default_opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
222
+ :body => "{}",
223
+ :code => klass::HTTP_OK_200
224
+ )
225
+ allow(File).to receive(:directory?).and_call_original
226
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
227
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
228
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
229
+ # We don't really want to use the atomic write function
230
+ allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
231
+ allow(File).to receive(:size).with(default_state_file_path) { 0 }
232
+ allow(instance).to receive(:update_state_file) { nil }
233
+ instance.register
234
+ end
235
+
236
+ context "given 'cron' expression" do
237
+ let(:opts) { default_opts.merge("schedule" => {"cron" => "* * * * * UTC"}) }
238
+ let(:instance) { klass.new(opts) }
239
+ it "should run at the schedule" do
240
+ Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
241
+ Timecop.scale(60)
242
+ queue = Queue.new
243
+ runner = Thread.new do
244
+ instance.run(queue)
245
+ end
246
+ sleep 3
247
+ instance.stop
248
+ runner.kill
249
+ runner.join
250
+ expect(queue.size).to eq(2)
251
+ Timecop.return
252
+ end
253
+ end
254
+
255
+ context "given 'at' expression" do
256
+ let(:opts) { default_opts.merge("schedule" => {"at" => "2000-01-01 00:05:00 +0000"}) }
257
+ let(:instance) { klass.new(opts) }
258
+ it "should run at the schedule" do
259
+ Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
260
+ Timecop.scale(60 * 5)
261
+ queue = Queue.new
262
+ runner = Thread.new do
263
+ instance.run(queue)
264
+ end
265
+ sleep 2
266
+ instance.stop
267
+ runner.kill
268
+ runner.join
269
+ expect(queue.size).to eq(1)
270
+ Timecop.return
271
+ end
272
+ end
273
+
274
+ context "given 'every' expression" do
275
+ let(:opts) { default_opts.merge("schedule" => {"every" => "2s"}) }
276
+ let(:instance) { klass.new(opts) }
277
+ it "should run at the schedule" do
278
+ queue = Queue.new
279
+ runner = Thread.new do
280
+ instance.run(queue)
281
+ end
282
+ #T 0123456
283
+ #events x x x x
284
+ #expects 3 events at T=5
285
+ sleep 5
286
+ instance.stop
287
+ runner.kill
288
+ runner.join
289
+ expect(queue.size).to eq(3)
290
+ end
291
+ end
292
+
293
+ context "given 'in' expression" do
294
+ let(:opts) { default_opts.merge("schedule" => {"in" => "2s"}) }
295
+ let(:instance) { klass.new(opts) }
296
+ it "should run at the schedule" do
297
+ queue = Queue.new
298
+ runner = Thread.new do
299
+ instance.run(queue)
300
+ end
301
+ sleep 3
302
+ instance.stop
303
+ runner.kill
304
+ runner.join
305
+ expect(queue.size).to eq(1)
306
+ end
307
+ end
308
+ end
309
+
310
+ describe "events" do
311
+ shared_examples("matching metadata") {
312
+ let(:metadata) { event.get(metadata_target) }
313
+ let(:options) { defined?(settings) ? settings : opts }
314
+ # The URL gets modified b/c of the limit that is placed on the API
315
+ #let(:metadata_url) { "https://#{options["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{options["limit"]}" }
316
+ let(:metadata_url) {
317
+ if (custom_settings)
318
+ options["custom_url"]+"?limit=#{options["limit"]}"
319
+ else
320
+ "https://#{options["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{options["limit"]}"
321
+ end
322
+ }
323
+
324
+ it "should have the correct request url" do
325
+ expect(metadata["url"].to_s).to eql(metadata_url)
326
+ end
327
+
328
+ it "should have the correct code" do
329
+ expect(metadata["code"]).to eql(code)
330
+ end
331
+ }
332
+
333
+ shared_examples "unprocessable_requests" do
334
+ let(:poller) { klass.new(settings) }
335
+ subject(:event) {
336
+ poller.send(:run_once, queue)
337
+ queue.pop(true)
338
+ }
339
+
340
+ before do
341
+ unless (custom_settings)
342
+ poller.client.stub("https://#{settings["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
343
+ :body => "{}",
344
+ :code => klass::HTTP_OK_200
345
+ )
346
+ end
347
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
348
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
349
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
350
+ # We don't really want to use the atomic write function
351
+ allow(poller).to receive(:detect_write_method).with(default_state_file_path) { poller.method(:non_atomic_write) }
352
+ allow(File).to receive(:size).with(default_state_file_path) { 0 }
353
+ allow(poller).to receive(:update_state_file) { nil }
354
+ poller.register
355
+ allow(poller).to receive(:handle_failure).and_call_original
356
+ allow(poller).to receive(:handle_success)
357
+ event # materialize the subject
358
+ end
359
+
360
+ it "should enqueue a message" do
361
+ expect(event).to be_a(LogStash::Event)
362
+ end
363
+
364
+ it "should enqueue a message with 'http_request_error' set" do
365
+ expect(event.get("http_request_error")).to be_a(Hash)
366
+ end
367
+
368
+ it "should tag the event with '_http_request_error'" do
369
+ expect(event.get("tags")).to include('_http_request_error')
370
+ end
371
+
372
+ it "should invoke handle failure exactly once" do
373
+ expect(poller).to have_received(:handle_failure).once
374
+ end
375
+
376
+ it "should not invoke handle success at all" do
377
+ expect(poller).not_to have_received(:handle_success)
378
+ end
379
+
380
+ include_examples("matching metadata")
381
+
382
+ end
383
+
384
+ context "with a non responsive server" do
385
+ context "due to an invalid hostname" do # Fail with handlers
386
+ let(:custom_settings) { false }
387
+ let(:hostname) { "thouetnhoeu89ueoueohtueohtneuohn" }
388
+ let(:code) { nil } # no response expected
389
+
390
+ let(:settings) { default_opts.merge("hostname" => hostname) }
391
+
392
+ include_examples("unprocessable_requests")
393
+ end
394
+
395
+ context "due to a non-existent host" do # Fail with handlers
396
+ let(:custom_settings) { true }
397
+ let(:custom_url) { "http://thouetnhoeu89ueoueohtueohtneuohn/path/api" }
398
+ let(:code) { nil } # no response expected
399
+
400
+ let(:settings) {
401
+
402
+ settings = default_opts.merge("custom_url" => custom_url).clone
403
+ settings.delete("hostname")
404
+ settings
405
+ }
406
+
407
+ include_examples("unprocessable_requests")
408
+
409
+
410
+ end
411
+ context "due to a bogus port number" do # fail with return?
412
+ let(:invalid_port) { Flores::Random.integer(65536..1000000) }
413
+ let(:custom_settings) { true }
414
+ let(:custom_url) { "http://127.0.0.1:#{invalid_port}" }
415
+ let(:settings) {
416
+ settings = default_opts.merge("custom_url" => custom_url.to_s).clone
417
+ settings.delete("hostname")
418
+ settings
419
+ }
420
+ let(:code) { nil } # No response expected
421
+
422
+ include_examples("unprocessable_requests")
423
+ end
424
+ end
425
+
426
+ describe "a valid request and decoded response" do
427
+ let(:payload) {{"a" => 2, "hello" => ["a", "b", "c"]}}
428
+ let(:response_body) { LogStash::Json.dump(payload) }
429
+ let(:code) { klass::HTTP_OK_200 }
430
+ let(:hostname) { default_host }
431
+ let(:custom_settings) { false }
432
+
433
+ let(:opts) { default_opts }
434
+ let(:instance) {
435
+ klass.new(opts)
436
+ }
437
+
438
+ subject(:event) {
439
+ queue.pop(true)
440
+ }
441
+
442
+ before do
443
+ instance.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
444
+ :body => "{}",
445
+ :code => klass::HTTP_OK_200
446
+ )
447
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
448
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
449
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
450
+ # We don't really want to use the atomic write function
451
+ allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
452
+ allow(File).to receive(:size).with(default_state_file_path) { 0 }
453
+ allow(instance).to receive(:update_state_file) { nil }
454
+
455
+ instance.register
456
+ allow(instance).to receive(:decorate)
457
+ instance.client.stub(%r{#{opts["hostname"]}.*},
458
+ :body => response_body,
459
+ :code => code
460
+ )
461
+
462
+ instance.send(:run_once, queue)
463
+ end
464
+
465
+ it "should have a matching message" do
466
+ expect(event.to_hash).to include(payload)
467
+ end
468
+
469
+ it "should decorate the event" do
470
+ expect(instance).to have_received(:decorate).once
471
+ end
472
+
473
+ include_examples("matching metadata")
474
+
475
+ context "with an empty body" do
476
+ let(:response_body) { "" }
477
+ it "should return an empty event" do
478
+ instance.send(:run_once, queue)
479
+ expect(event.get("[_http_poller_metadata][response_headers][content-length]")).to eql("0")
480
+ end
481
+ end
482
+
483
+ context "with metadata omitted" do
484
+ let(:opts) {
485
+ opts = default_opts.clone
486
+ opts.delete("metadata_target")
487
+ opts
488
+ }
489
+
490
+ it "should not have any metadata on the event" do
491
+ instance.send(:run_once, queue)
492
+ expect(event.get(metadata_target)).to be_nil
493
+ end
494
+ end
495
+
496
+ context "with a specified target" do
497
+ let(:target) { "mytarget" }
498
+ let(:opts) { default_opts.merge("target" => target) }
499
+
500
+ it "should store the event info in the target" do
501
+ # When events go through the pipeline they are java-ified
502
+ # this normalizes the payload to java types
503
+ payload_normalized = LogStash::Json.load(LogStash::Json.dump(payload))
504
+ expect(event.get(target)).to include(payload_normalized)
505
+ end
506
+ end
507
+
508
+ context "with non-200 HTTP response codes" do
509
+ let(:code) { |example| example.metadata[:http_code] }
510
+ let(:response_body) { "{}" }
511
+
512
+ it "responds to a 500 code", :http_code => 500 do
513
+ instance.send(:run_once, queue)
514
+ expect(event.to_hash).to include("http_response_error")
515
+ expect(event.to_hash["http_response_error"]).to include({"http_code" => code})
516
+ expect(event.get("tags")).to include('_http_response_error')
517
+ end
518
+ it "responds to a 401/Unauthorized code", :http_code => 401 do
519
+ instance.send(:run_once, queue)
520
+ expect(event.to_hash).to include("okta_response_error")
521
+ expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
522
+ expect(event.get("tags")).to include('_okta_response_error')
523
+ end
524
+ it "responds to a 400 code", :http_code => 400 do
525
+ instance.send(:run_once, queue)
526
+ expect(event.to_hash).to include("okta_response_error")
527
+ expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
528
+ expect(event.get("tags")).to include('_okta_response_error')
529
+ end
530
+ context "specific okta errors" do
531
+ let(:payload) { {:okta_error => "E0000031" } }
532
+ let(:response_body) { LogStash::Json.dump(payload) }
533
+
534
+ describe "filter string error" do
535
+ let(:payload) { {:okta_error => "E0000031" } }
536
+ let(:response_body) { LogStash::Json.dump(payload) }
537
+ it "generates a filter string error event", :http_code => 400 do
538
+ expect(event.to_hash).to include("okta_response_error")
539
+ expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
540
+ expect(event.to_hash["okta_response_error"]).to include({"okta_plugin_status" => "Filter string was not valid."})
541
+ expect(event.get("tags")).to include('_okta_response_error')
542
+ end
543
+ end
544
+
545
+ describe "start_date error" do
546
+ let(:payload) { {:okta_error => "E0000030" } }
547
+ let(:response_body) { LogStash::Json.dump(payload) }
548
+ it "generates a start_date error event", :http_code => 400 do
549
+ expect(event.to_hash).to include("okta_response_error")
550
+ expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
551
+ expect(event.to_hash["okta_response_error"]).to include({"okta_plugin_status" => "since was not valid."})
552
+ expect(event.get("tags")).to include('_okta_response_error')
553
+ end
554
+ end
555
+ end
556
+ end
557
+ end
558
+ end
559
+
560
+ describe "stopping" do
561
+ let(:config) { default_opts }
562
+ before do
563
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
564
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
565
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
566
+ # We don't really want to use the atomic write function
567
+ allow(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
568
+ allow(File).to receive(:size).with(default_state_file_path) { 0 }
569
+ allow(subject).to receive(:update_state_file) { nil }
570
+ end
571
+ it_behaves_like "an interruptible input plugin"
572
+ end
573
+
574
+ describe "state file" do
575
+ context "when being setup" do
576
+
577
+ let(:opts) {
578
+ opts = default_opts.merge({"state_file_path" => default_state_file_path}).clone
579
+ opts
580
+ }
581
+
582
+ subject { klass.new(opts) }
583
+
584
+ let(:state_file_url) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{opts["limit"]}&after=asdfasdf" }
585
+ let(:test_url) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{opts["limit"]}" }
586
+ let(:state_file_url_changed) { "http://example.com/?limit=1000" }
587
+
588
+ before(:each) do
589
+ subject.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
590
+ :body => "{}",
591
+ :code => klass::HTTP_OK_200
592
+ )
593
+ end
594
+
595
+
596
+ it "sets up the state file correctly" do
597
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
598
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
599
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
600
+ # We don't really want to use the atomic write function
601
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
602
+ expect(File).to receive(:size).with(default_state_file_path) { 0 }
603
+ subject.register
604
+ end
605
+
606
+ it "raises an error on file read" do
607
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
608
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
609
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
610
+ # We don't really want to use the atomic write function
611
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
612
+ expect(File).to receive(:size).with(default_state_file_path) { 10 }
613
+ expect(File).to receive(:read).with(default_state_file_path, 10) { raise IOError }
614
+ expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
615
+ end
616
+
617
+ it "creates a url based on the state file" do
618
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
619
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
620
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
621
+ # We don't really want to use the atomic write function
622
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
623
+ expect(File).to receive(:size).with(default_state_file_path) { "#{state_file_url}\n".length }
624
+ expect(File).to receive(:read).with(default_state_file_path, "#{state_file_url}\n".length) { "#{state_file_url}\n" }
625
+ subject.register
626
+ expect(subject.instance_variable_get("@url")).to eql(state_file_url)
627
+ end
628
+
629
+ it "uses the URL from options when state file is empty" do
630
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
631
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
632
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
633
+ # We don't really want to use the atomic write function
634
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
635
+ expect(File).to receive(:size).with(default_state_file_path) { 0 }
636
+ subject.register
637
+ expect(subject.instance_variable_get("@url").to_s).to eql(test_url)
638
+ end
639
+
640
+ it "raises an error when the config url is not part of the saved state" do
641
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
642
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
643
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
644
+ # We don't really want to use the atomic write function
645
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
646
+ expect(File).to receive(:size).with(default_state_file_path) { "#{state_file_url_changed}\n".length }
647
+ expect(File).to receive(:read).with(default_state_file_path, "#{state_file_url_changed}\n".length) { "#{state_file_url_changed}\n" }
648
+ expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
649
+ end
650
+
651
+ it "sets the the failure mode to error" do
652
+ expect(File).to receive(:directory?).with(default_state_file_path) { false }
653
+ expect(File).to receive(:exist?).with(default_state_file_path) { true }
654
+ expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
655
+ # We don't really want to use the atomic write function
656
+ expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
657
+ expect(File).to receive(:size).with(default_state_file_path) { 0 }
658
+ subject.register
659
+ expect(subject.instance_variable_get("@state_file_failure_function")).to eql(subject.method(:error_state_file))
660
+ end
661
+ end
662
+
663
+ context "when running" do
664
+ let(:opts) {
665
+ opts = default_opts.merge({"state_file_path" => default_state_file_path}).clone
666
+ opts
667
+ }
668
+ let(:instance) { klass.new(opts) }
669
+
670
+ let(:payload) { '[{"eventId":"tevIMARaEyiSzm3sm1gvfn8cA1479235809000"}]}]' }
671
+ let(:response_body) { LogStash::Json.dump(payload) }
672
+
673
+ let(:url_initial) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?after=1" }
674
+ let(:url_final) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?after=2" }
675
+ let(:headers) { {"link" => ["<#{url_initial}>; rel=\"self\"", "<#{url_final}>; rel=\"next\""]} }
676
+ let(:code) { klass::HTTP_OK_200 }
677
+ let(:file_path) { opts['state_file_dir'] + opts["state_file_prefix"] }
678
+ let(:file_obj) { double("file") }
679
+ let(:fd) { double("fd") }
680
+ let(:time_anchor) { 2 }
681
+
682
+ before(:each) do |example|
683
+ allow(File).to receive(:directory?).with(default_state_file_path) { false }
684
+ allow(File).to receive(:exist?).with(default_state_file_path) { true }
685
+ allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
686
+ # We don't really want to use the atomic write function
687
+ allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
688
+ allow(File).to receive(:size).with(default_state_file_path) { "#{url_initial}\n".length }
689
+ allow(File).to receive(:read).with(default_state_file_path, "#{url_initial}\n".length) { "#{url_initial}\n" }
690
+
691
+ instance.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
692
+ :body => "{}",
693
+ :code => code
694
+ )
695
+ instance.register
696
+ instance.client.stub( url_initial,
697
+ :headers => headers,
698
+ :body => response_body,
699
+ :code => code )
700
+
701
+ allow(instance).to receive(:handle_failure) { instance.instance_variable_set(:@continue,false) }
702
+ allow(instance).to receive(:get_time_int) { time_anchor }
703
+ end
704
+
705
+ it "updates the state file after data is fetched" do
706
+ expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
707
+ expect(IO).to receive(:open).with(fd).and_yield(file_obj)
708
+ expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
709
+ instance.client.stub( url_final,
710
+ :headers => {:link => "<#{url_final}>; rel=\"self\""},
711
+ :body => "{}",
712
+ :code => code )
713
+ instance.send(:run_once, queue)
714
+ end
715
+
716
+ it "updates the state file after a failure" do
717
+ expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
718
+ expect(IO).to receive(:open).with(fd).and_yield(file_obj)
719
+ expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
720
+ instance.send(:run_once, queue)
721
+ end
722
+
723
+ context "when stop is called" do
724
+ it "saves the state in the file" do
725
+ # We are still testing the same condition
726
+ expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
727
+ expect(IO).to receive(:open).with(fd).and_yield(file_obj)
728
+ expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
729
+
730
+ # Force a sleep to make the thread hang in the failure condition.
731
+ allow(instance).to receive(:handle_failure) {
732
+ instance.instance_variable_set(:@continue,false)
733
+ sleep(30)
734
+ }
735
+
736
+ plugin_thread = Thread.new(instance,queue) { |subject, queue|
737
+ instance.send(:run, queue)
738
+ }
739
+
740
+ # Sleep for a bit to make sure things are started.
741
+ sleep 0.5
742
+ expect(plugin_thread).to be_alive
743
+
744
+ instance.do_stop
745
+
746
+ # As they say in the logstash thread, why 3?
747
+ # Because 2 is too short, and 4 is too long.
748
+ wait(3).for { plugin_thread }.to_not be_alive
749
+ end
750
+ end
751
+ end
752
+ end
753
+ end