logstash-input-okta_system_log 0.9.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.
@@ -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