decision_agent 0.1.4 → 0.1.6

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
  4. data/lib/decision_agent/agent.rb +5 -3
  5. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  6. data/lib/decision_agent/auth/authenticator.rb +127 -0
  7. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  8. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  9. data/lib/decision_agent/auth/permission.rb +29 -0
  10. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  11. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  12. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  13. data/lib/decision_agent/auth/role.rb +56 -0
  14. data/lib/decision_agent/auth/session.rb +33 -0
  15. data/lib/decision_agent/auth/session_manager.rb +57 -0
  16. data/lib/decision_agent/auth/user.rb +70 -0
  17. data/lib/decision_agent/context.rb +24 -4
  18. data/lib/decision_agent/decision.rb +10 -3
  19. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  20. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  21. data/lib/decision_agent/errors.rb +38 -0
  22. data/lib/decision_agent/evaluation.rb +10 -3
  23. data/lib/decision_agent/evaluation_validator.rb +8 -13
  24. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  25. data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
  26. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  27. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  28. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  29. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  30. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  31. data/lib/decision_agent/version.rb +10 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  33. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  34. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  35. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  36. data/lib/decision_agent/web/public/app.js +184 -29
  37. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  38. data/lib/decision_agent/web/public/index.html +37 -9
  39. data/lib/decision_agent/web/public/login.html +298 -0
  40. data/lib/decision_agent/web/public/users.html +679 -0
  41. data/lib/decision_agent/web/server.rb +873 -7
  42. data/lib/decision_agent.rb +52 -0
  43. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  45. data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
  46. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  47. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  48. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  49. data/spec/advanced_operators_spec.rb +1003 -0
  50. data/spec/agent_spec.rb +40 -0
  51. data/spec/audit_adapters_spec.rb +18 -0
  52. data/spec/auth/access_audit_logger_spec.rb +394 -0
  53. data/spec/auth/authenticator_spec.rb +112 -0
  54. data/spec/auth/password_reset_spec.rb +294 -0
  55. data/spec/auth/permission_checker_spec.rb +207 -0
  56. data/spec/auth/permission_spec.rb +73 -0
  57. data/spec/auth/rbac_adapter_spec.rb +550 -0
  58. data/spec/auth/rbac_config_spec.rb +82 -0
  59. data/spec/auth/role_spec.rb +51 -0
  60. data/spec/auth/session_manager_spec.rb +172 -0
  61. data/spec/auth/session_spec.rb +112 -0
  62. data/spec/auth/user_spec.rb +130 -0
  63. data/spec/context_spec.rb +43 -0
  64. data/spec/decision_agent_spec.rb +96 -0
  65. data/spec/decision_spec.rb +423 -0
  66. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  67. data/spec/evaluation_spec.rb +364 -0
  68. data/spec/evaluation_validator_spec.rb +165 -0
  69. data/spec/examples.txt +1542 -612
  70. data/spec/monitoring/metrics_collector_spec.rb +220 -2
  71. data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
  72. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  73. data/spec/performance_optimizations_spec.rb +486 -0
  74. data/spec/spec_helper.rb +23 -0
  75. data/spec/testing/batch_test_importer_spec.rb +693 -0
  76. data/spec/testing/batch_test_runner_spec.rb +307 -0
  77. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  78. data/spec/testing/test_result_comparator_spec.rb +392 -0
  79. data/spec/testing/test_scenario_spec.rb +113 -0
  80. data/spec/versioning/adapter_spec.rb +156 -0
  81. data/spec/versioning_spec.rb +253 -0
  82. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  83. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  84. data/spec/web_ui_rack_spec.rb +1705 -0
  85. metadata +99 -6
@@ -186,6 +186,259 @@ RSpec.describe "DecisionAgent Versioning System" do
186
186
  expect(comparison).to be_nil
187
187
  end
188
188
  end
189
+
190
+ describe "#delete_version" do
191
+ it "deletes a version and removes it from index" do
192
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
193
+ adapter.create_version(rule_id: rule_id, content: rule_content)
194
+
195
+ # Delete v1 (draft, not active)
196
+ result = adapter.delete_version(version_id: v1[:id])
197
+ expect(result).to be true
198
+
199
+ # Verify it's deleted
200
+ expect(adapter.get_version(version_id: v1[:id])).to be_nil
201
+ end
202
+
203
+ it "raises error when trying to delete active version" do
204
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
205
+
206
+ expect do
207
+ adapter.delete_version(version_id: v1[:id])
208
+ end.to raise_error(DecisionAgent::ValidationError, /Cannot delete active version/)
209
+ end
210
+
211
+ it "raises error for nonexistent version" do
212
+ expect do
213
+ adapter.delete_version(version_id: "nonexistent")
214
+ end.to raise_error(DecisionAgent::NotFoundError)
215
+ end
216
+
217
+ it "handles file already deleted" do
218
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
219
+ adapter.create_version(rule_id: rule_id, content: rule_content)
220
+
221
+ # Delete the file manually
222
+ rule_dir = File.join(adapter.storage_path, rule_id)
223
+ filename = "#{v1[:version_number]}.json"
224
+ filepath = File.join(rule_dir, filename)
225
+ FileUtils.rm_f(filepath)
226
+
227
+ # Should handle gracefully
228
+ result = adapter.delete_version(version_id: v1[:id])
229
+ expect(result).to be false
230
+ end
231
+
232
+ it "converts index lookup errors to NotFoundError" do
233
+ # Simulate an error during index lookup
234
+ allow(adapter).to receive(:get_rule_id_from_index).and_raise(StandardError.new("Index error"))
235
+
236
+ expect do
237
+ adapter.delete_version(version_id: "test_version")
238
+ end.to raise_error(DecisionAgent::NotFoundError, /Version not found: test_version/)
239
+ end
240
+
241
+ it "handles missing directory when searching for version files" do
242
+ # Create a version
243
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
244
+ adapter.create_version(rule_id: rule_id, content: rule_content)
245
+ version_id = v1[:id]
246
+
247
+ # Manually remove the rule directory to simulate missing directory
248
+ rule_dir = File.join(adapter.storage_path, rule_id)
249
+ FileUtils.rm_rf(rule_dir)
250
+
251
+ # The version should still be in the index, but directory is gone
252
+ # This simulates a stale index entry
253
+ # When delete_version is called, it will find the rule_id from index,
254
+ # but the directory won't exist when searching for files
255
+ result = adapter.delete_version(version_id: version_id)
256
+ expect(result).to be false
257
+ end
258
+
259
+ it "handles version ID type mismatches with string conversion" do
260
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
261
+ adapter.create_version(rule_id: rule_id, content: rule_content)
262
+
263
+ # Version IDs are stored as strings, but test that .to_s comparison works
264
+ # This ensures the code handles cases where version_id might be passed as different types
265
+ version_id = v1[:id]
266
+ expect(version_id).to be_a(String)
267
+
268
+ # Should work with string version_id
269
+ result = adapter.delete_version(version_id: version_id)
270
+ expect(result).to be true
271
+
272
+ # Verify it's actually deleted
273
+ expect(adapter.get_version(version_id: version_id)).to be_nil
274
+ end
275
+
276
+ it "handles file read errors gracefully when searching for version" do
277
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
278
+ adapter.create_version(rule_id: rule_id, content: rule_content)
279
+
280
+ # Delete the actual version file but keep it in the index
281
+ # This simulates a scenario where the file was manually deleted
282
+ rule_dir = File.join(adapter.storage_path, rule_id)
283
+ filename = "#{v1[:version_number]}.json"
284
+ filepath = File.join(rule_dir, filename)
285
+ FileUtils.rm_f(filepath)
286
+
287
+ # The version should still be in the index, but the file is gone
288
+ # When delete_version is called, it will:
289
+ # 1. Find rule_id from index
290
+ # 2. List versions - v1 won't be found (file deleted), but v2 will be
291
+ # 3. Since v1 not in list, search through files
292
+ # 4. Should handle missing files gracefully and return false
293
+ result = adapter.delete_version(version_id: v1[:id])
294
+ expect(result).to be false
295
+ end
296
+
297
+ it "handles case where version is in index but not in versions list and directory missing" do
298
+ # Create a version
299
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
300
+ version_id = v1[:id]
301
+
302
+ # Remove the directory but keep the index entry
303
+ rule_dir = File.join(adapter.storage_path, rule_id)
304
+ FileUtils.rm_rf(rule_dir)
305
+
306
+ # This tests the path where:
307
+ # 1. get_rule_id_from_index returns a rule_id (version is in index)
308
+ # 2. list_versions_unsafe returns empty (directory doesn't exist)
309
+ # 3. Dir.glob fails with Errno::ENOENT (directory missing)
310
+ # 4. Should return false gracefully
311
+ result = adapter.delete_version(version_id: version_id)
312
+ expect(result).to be false
313
+ end
314
+
315
+ it "handles unexpected errors during lock operation and converts to NotFoundError" do
316
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
317
+
318
+ # Simulate an unexpected error during the lock operation (e.g., mutex error, file system error)
319
+ allow(adapter).to receive(:list_versions_unsafe).and_raise(StandardError.new("Unexpected lock error"))
320
+
321
+ # Should convert unexpected error to NotFoundError instead of letting it propagate as 500
322
+ expect do
323
+ adapter.delete_version(version_id: v1[:id])
324
+ end.to raise_error(DecisionAgent::NotFoundError, /Version not found: #{v1[:id]}/)
325
+ end
326
+
327
+ it "preserves ValidationError when trying to delete active version" do
328
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
329
+
330
+ # Should raise ValidationError, not NotFoundError
331
+ expect do
332
+ adapter.delete_version(version_id: v1[:id])
333
+ end.to raise_error(DecisionAgent::ValidationError, /Cannot delete active version/)
334
+ end
335
+ end
336
+
337
+ describe "#list_versions_unsafe" do
338
+ it "handles corrupted JSON files gracefully" do
339
+ # Create a valid version
340
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
341
+
342
+ # Create a corrupted JSON file in the same directory
343
+ rule_dir = File.join(adapter.storage_path, rule_id)
344
+ corrupted_file = File.join(rule_dir, "999.json")
345
+ File.write(corrupted_file, "invalid json content{")
346
+
347
+ # Should skip corrupted files and return only valid versions
348
+ versions = adapter.send(:list_versions_unsafe, rule_id: rule_id)
349
+ expect(versions.length).to eq(1)
350
+ expect(versions.first[:id]).to eq(v1[:id])
351
+
352
+ # Clean up
353
+ FileUtils.rm_f(corrupted_file)
354
+ end
355
+
356
+ it "handles missing files gracefully" do
357
+ # Create a valid version
358
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
359
+
360
+ # Delete the file but keep directory
361
+ rule_dir = File.join(adapter.storage_path, rule_id)
362
+ filename = "#{v1[:version_number]}.json"
363
+ filepath = File.join(rule_dir, filename)
364
+ FileUtils.rm_f(filepath)
365
+
366
+ # Should handle missing files gracefully
367
+ versions = adapter.send(:list_versions_unsafe, rule_id: rule_id)
368
+ expect(versions).to be_an(Array)
369
+ # May or may not include the deleted version depending on timing
370
+ end
371
+ end
372
+
373
+ describe "index management" do
374
+ it "loads index on initialization" do
375
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
376
+
377
+ # Create new adapter instance - should load index
378
+ new_adapter = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir)
379
+
380
+ # Should be able to find version using index
381
+ found = new_adapter.get_version(version_id: v1[:id])
382
+ expect(found).not_to be_nil
383
+ end
384
+
385
+ it "handles corrupted JSON files in index loading" do
386
+ # Create a corrupted JSON file
387
+ rule_dir = File.join(temp_dir, rule_id)
388
+ FileUtils.mkdir_p(rule_dir)
389
+ corrupted_file = File.join(rule_dir, "1.json")
390
+ File.write(corrupted_file, "invalid json content{")
391
+
392
+ # Should handle gracefully and skip corrupted files
393
+ expect do
394
+ _new_adapter = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir)
395
+ end.not_to raise_error
396
+ end
397
+
398
+ it "updates index when creating versions" do
399
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
400
+
401
+ # Index should be updated
402
+ found = adapter.get_version(version_id: v1[:id])
403
+ expect(found).not_to be_nil
404
+ end
405
+
406
+ it "removes from index when deleting versions" do
407
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
408
+ adapter.create_version(rule_id: rule_id, content: rule_content)
409
+
410
+ version_id = v1[:id]
411
+ adapter.delete_version(version_id: version_id)
412
+
413
+ # Should not find in index
414
+ expect(adapter.get_version(version_id: version_id)).to be_nil
415
+ end
416
+ end
417
+
418
+ describe "filename sanitization" do
419
+ it "sanitizes special characters in rule_id" do
420
+ special_rule_id = "rule/with\\special:chars*?"
421
+ version = adapter.create_version(rule_id: special_rule_id, content: rule_content)
422
+
423
+ # Should create valid filename
424
+ expect(version[:rule_id]).to eq(special_rule_id)
425
+
426
+ # Should be able to retrieve it
427
+ found = adapter.get_version(version_id: version[:id])
428
+ expect(found).not_to be_nil
429
+ end
430
+ end
431
+
432
+ describe "error handling" do
433
+ it "handles update_version_status_unsafe with invalid status" do
434
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
435
+
436
+ # Try to update with invalid status via reflection (testing private method behavior)
437
+ expect do
438
+ adapter.send(:update_version_status_unsafe, v1[:id], "invalid_status", rule_id)
439
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid status/)
440
+ end
441
+ end
189
442
  end
190
443
 
191
444
  describe DecisionAgent::Versioning::VersionManager do
@@ -0,0 +1,133 @@
1
+ require "spec_helper"
2
+ require "rack/test"
3
+ require_relative "../../../lib/decision_agent/web/middleware/auth_middleware"
4
+
5
+ RSpec.describe DecisionAgent::Web::Middleware::AuthMiddleware do
6
+ include Rack::Test::Methods
7
+
8
+ let(:authenticator) { double("Authenticator") }
9
+ let(:access_audit_logger) { double("AccessAuditLogger") }
10
+ let(:app) { ->(_env) { [200, {}, ["OK"]] } }
11
+ let(:middleware) { described_class.new(app, authenticator: authenticator, access_audit_logger: access_audit_logger) }
12
+
13
+ describe "#initialize" do
14
+ it "initializes with app and authenticator" do
15
+ expect(middleware.instance_variable_get(:@app)).to eq(app)
16
+ expect(middleware.instance_variable_get(:@authenticator)).to eq(authenticator)
17
+ expect(middleware.instance_variable_get(:@access_audit_logger)).to eq(access_audit_logger)
18
+ end
19
+
20
+ it "initializes without access_audit_logger" do
21
+ middleware = described_class.new(app, authenticator: authenticator)
22
+ expect(middleware.instance_variable_get(:@access_audit_logger)).to be_nil
23
+ end
24
+ end
25
+
26
+ describe "#call" do
27
+ context "with Authorization header" do
28
+ it "extracts token from Bearer header" do
29
+ user = double("User", id: "user1")
30
+ session = double("Session", token: "token123")
31
+ auth_result = { user: user, session: session }
32
+
33
+ allow(authenticator).to receive(:authenticate).with("token123").and_return(auth_result)
34
+
35
+ env = Rack::MockRequest.env_for("/", "HTTP_AUTHORIZATION" => "Bearer token123")
36
+ status, = middleware.call(env)
37
+
38
+ expect(status).to eq(200)
39
+ expect(env["decision_agent.user"]).to eq(user)
40
+ expect(env["decision_agent.session"]).to eq(session)
41
+ end
42
+
43
+ it "handles missing Bearer prefix" do
44
+ env = Rack::MockRequest.env_for("/", "HTTP_AUTHORIZATION" => "token123")
45
+ status, = middleware.call(env)
46
+
47
+ expect(status).to eq(200)
48
+ expect(env["decision_agent.user"]).to be_nil
49
+ end
50
+ end
51
+
52
+ context "with session cookie" do
53
+ it "extracts token from cookie" do
54
+ user = double("User", id: "user1")
55
+ session = double("Session", token: "cookie_token")
56
+ auth_result = { user: user, session: session }
57
+
58
+ allow(authenticator).to receive(:authenticate).with("cookie_token").and_return(auth_result)
59
+
60
+ env = Rack::MockRequest.env_for("/")
61
+ request = Rack::Request.new(env)
62
+ allow(request).to receive(:cookies).and_return("decision_agent_session" => "cookie_token")
63
+ allow(Rack::Request).to receive(:new).and_return(request)
64
+
65
+ status, = middleware.call(env)
66
+
67
+ expect(status).to eq(200)
68
+ expect(env["decision_agent.user"]).to eq(user)
69
+ end
70
+ end
71
+
72
+ context "with query parameter" do
73
+ it "extracts token from query parameter" do
74
+ user = double("User", id: "user1")
75
+ session = double("Session", token: "query_token")
76
+ auth_result = { user: user, session: session }
77
+
78
+ allow(authenticator).to receive(:authenticate).with("query_token").and_return(auth_result)
79
+
80
+ env = Rack::MockRequest.env_for("/?token=query_token")
81
+ status, = middleware.call(env)
82
+
83
+ expect(status).to eq(200)
84
+ expect(env["decision_agent.user"]).to eq(user)
85
+ end
86
+ end
87
+
88
+ context "without token" do
89
+ it "calls app without setting user" do
90
+ env = Rack::MockRequest.env_for("/")
91
+ status, = middleware.call(env)
92
+
93
+ expect(status).to eq(200)
94
+ expect(env["decision_agent.user"]).to be_nil
95
+ expect(env["decision_agent.session"]).to be_nil
96
+ end
97
+ end
98
+
99
+ context "with invalid token" do
100
+ it "calls app without setting user when authentication fails" do
101
+ allow(authenticator).to receive(:authenticate).with("invalid_token").and_return(nil)
102
+
103
+ env = Rack::MockRequest.env_for("/", "HTTP_AUTHORIZATION" => "Bearer invalid_token")
104
+ status, = middleware.call(env)
105
+
106
+ expect(status).to eq(200)
107
+ expect(env["decision_agent.user"]).to be_nil
108
+ expect(env["decision_agent.session"]).to be_nil
109
+ end
110
+ end
111
+
112
+ context "token priority" do
113
+ it "prefers Authorization header over cookie" do
114
+ user_header = double("User", id: "header_user")
115
+ user_cookie = double("User", id: "cookie_user")
116
+ session_header = double("Session")
117
+ session_cookie = double("Session")
118
+
119
+ allow(authenticator).to receive(:authenticate).with("header_token").and_return({ user: user_header, session: session_header })
120
+ allow(authenticator).to receive(:authenticate).with("cookie_token").and_return({ user: user_cookie, session: session_cookie })
121
+
122
+ env = Rack::MockRequest.env_for("/?token=cookie_token", "HTTP_AUTHORIZATION" => "Bearer header_token")
123
+ request = Rack::Request.new(env)
124
+ allow(request).to receive(:cookies).and_return("decision_agent_session" => "cookie_token")
125
+ allow(Rack::Request).to receive(:new).and_return(request)
126
+
127
+ middleware.call(env)
128
+
129
+ expect(env["decision_agent.user"]).to eq(user_header)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,247 @@
1
+ require "spec_helper"
2
+ require "rack/test"
3
+ require_relative "../../../lib/decision_agent/web/middleware/permission_middleware"
4
+
5
+ RSpec.describe DecisionAgent::Web::Middleware::PermissionMiddleware do
6
+ include Rack::Test::Methods
7
+
8
+ let(:permission_checker) { double("PermissionChecker") }
9
+ let(:access_audit_logger) { double("AccessAuditLogger") }
10
+ let(:app) { ->(_env) { [200, {}, ["OK"]] } }
11
+ let(:user) { double("User", id: "user1", email: "user@example.com") }
12
+
13
+ describe "#initialize" do
14
+ it "initializes with app and permission_checker" do
15
+ middleware = described_class.new(app, permission_checker: permission_checker)
16
+ expect(middleware.instance_variable_get(:@app)).to eq(app)
17
+ expect(middleware.instance_variable_get(:@permission_checker)).to eq(permission_checker)
18
+ expect(middleware.instance_variable_get(:@required_permission)).to be_nil
19
+ end
20
+
21
+ it "initializes with required_permission" do
22
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :write)
23
+ expect(middleware.instance_variable_get(:@required_permission)).to eq(:write)
24
+ end
25
+
26
+ it "initializes with access_audit_logger" do
27
+ middleware = described_class.new(app, permission_checker: permission_checker, access_audit_logger: access_audit_logger)
28
+ expect(middleware.instance_variable_get(:@access_audit_logger)).to eq(access_audit_logger)
29
+ end
30
+ end
31
+
32
+ describe "#call" do
33
+ context "without user" do
34
+ it "returns 401 when user is not authenticated" do
35
+ middleware = described_class.new(app, permission_checker: permission_checker)
36
+ env = Rack::MockRequest.env_for("/")
37
+
38
+ status, headers, body = middleware.call(env)
39
+
40
+ expect(status).to eq(401)
41
+ expect(headers["Content-Type"]).to include("application/json")
42
+ body_text = body.first
43
+ expect(JSON.parse(body_text)["error"]).to eq("Authentication required")
44
+ end
45
+ end
46
+
47
+ context "with inactive user" do
48
+ it "returns 403 when user is not active" do
49
+ middleware = described_class.new(app, permission_checker: permission_checker)
50
+ env = Rack::MockRequest.env_for("/")
51
+ env["decision_agent.user"] = user
52
+
53
+ allow(permission_checker).to receive(:active?).with(user).and_return(false)
54
+
55
+ status, _, body = middleware.call(env)
56
+
57
+ expect(status).to eq(403)
58
+ body_text = body.first
59
+ expect(JSON.parse(body_text)["error"]).to eq("User account is not active")
60
+ end
61
+ end
62
+
63
+ context "without required permission" do
64
+ it "calls app when no permission required" do
65
+ middleware = described_class.new(app, permission_checker: permission_checker)
66
+ env = Rack::MockRequest.env_for("/")
67
+ env["decision_agent.user"] = user
68
+
69
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
70
+
71
+ status, _, body = middleware.call(env)
72
+
73
+ expect(status).to eq(200)
74
+ expect(body.first).to eq("OK")
75
+ end
76
+ end
77
+
78
+ context "with required permission" do
79
+ let(:middleware) { described_class.new(app, permission_checker: permission_checker, required_permission: :write, access_audit_logger: access_audit_logger) }
80
+
81
+ before do
82
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
83
+ end
84
+
85
+ it "calls app when permission is granted" do
86
+ env = Rack::MockRequest.env_for("/api/rules/123")
87
+ env["decision_agent.user"] = user
88
+
89
+ allow(permission_checker).to receive(:can?).with(user, :write, nil).and_return(true)
90
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
91
+ allow(access_audit_logger).to receive(:log_permission_check)
92
+
93
+ status, _, body = middleware.call(env)
94
+
95
+ expect(status).to eq(200)
96
+ expect(body.first).to eq("OK")
97
+ end
98
+
99
+ it "returns 403 when permission is denied" do
100
+ env = Rack::MockRequest.env_for("/api/rules/123")
101
+ env["decision_agent.user"] = user
102
+
103
+ allow(permission_checker).to receive(:can?).with(user, :write, nil).and_return(false)
104
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
105
+ allow(access_audit_logger).to receive(:log_permission_check)
106
+
107
+ status, _, body = middleware.call(env)
108
+
109
+ expect(status).to eq(403)
110
+ body_text = body.first
111
+ expect(JSON.parse(body_text)["error"]).to eq("Permission denied: write")
112
+ end
113
+
114
+ it "logs permission check when access_audit_logger is provided" do
115
+ env = Rack::MockRequest.env_for("/api/rules/123")
116
+ env["decision_agent.user"] = user
117
+
118
+ allow(permission_checker).to receive(:can?).with(user, :write, nil).and_return(true)
119
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
120
+ allow(access_audit_logger).to receive(:log_permission_check)
121
+
122
+ middleware.call(env)
123
+
124
+ expect(access_audit_logger).to have_received(:log_permission_check).with(
125
+ user_id: "user1",
126
+ permission: :write,
127
+ resource_type: "rule",
128
+ resource_id: "123",
129
+ granted: true
130
+ )
131
+ end
132
+
133
+ it "does not log when access_audit_logger is not provided" do
134
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :write)
135
+ env = Rack::MockRequest.env_for("/api/rules/123")
136
+ env["decision_agent.user"] = user
137
+
138
+ allow(permission_checker).to receive(:can?).with(user, :write, nil).and_return(true)
139
+
140
+ expect { middleware.call(env) }.not_to raise_error
141
+ end
142
+ end
143
+
144
+ describe "#extract_resource_type" do
145
+ it "extracts resource type from path" do
146
+ middleware = described_class.new(app, permission_checker: permission_checker)
147
+ env = Rack::MockRequest.env_for("/api/rules/123")
148
+ env["decision_agent.user"] = user
149
+
150
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
151
+
152
+ middleware.call(env)
153
+
154
+ # Verify extraction happened (indirectly through logging)
155
+ expect(permission_checker).to have_received(:active?).with(user)
156
+ end
157
+
158
+ it "handles paths without /api/ prefix" do
159
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :read)
160
+ env = Rack::MockRequest.env_for("/other/path")
161
+ env["decision_agent.user"] = user
162
+
163
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
164
+ allow(permission_checker).to receive(:can?).with(user, :read, nil).and_return(true)
165
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
166
+
167
+ status, = middleware.call(env)
168
+
169
+ expect(status).to eq(200)
170
+ end
171
+
172
+ it "singularizes resource type (removes trailing 's')" do
173
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :read, access_audit_logger: access_audit_logger)
174
+ env = Rack::MockRequest.env_for("/api/rules/123")
175
+ env["decision_agent.user"] = user
176
+
177
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
178
+ allow(permission_checker).to receive(:can?).with(user, :read, nil).and_return(true)
179
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
180
+ allow(access_audit_logger).to receive(:log_permission_check)
181
+
182
+ middleware.call(env)
183
+
184
+ expect(access_audit_logger).to have_received(:log_permission_check).with(
185
+ user_id: "user1",
186
+ permission: :read,
187
+ resource_type: "rule",
188
+ resource_id: "123",
189
+ granted: true
190
+ )
191
+ end
192
+ end
193
+
194
+ describe "#extract_resource_id" do
195
+ it "extracts id from params" do
196
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :read, access_audit_logger: access_audit_logger)
197
+ env = Rack::MockRequest.env_for("/api/rules/123?id=456")
198
+ env["decision_agent.user"] = user
199
+
200
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
201
+ allow(permission_checker).to receive(:can?).with(user, :read, nil).and_return(true)
202
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
203
+ allow(access_audit_logger).to receive(:log_permission_check)
204
+
205
+ middleware.call(env)
206
+
207
+ expect(access_audit_logger).to have_received(:log_permission_check) do |args|
208
+ expect(args[:resource_id]).to eq("456")
209
+ end
210
+ end
211
+
212
+ it "extracts rule_id from params" do
213
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :read, access_audit_logger: access_audit_logger)
214
+ env = Rack::MockRequest.env_for("/api/rules?rule_id=789")
215
+ env["decision_agent.user"] = user
216
+
217
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
218
+ allow(permission_checker).to receive(:can?).with(user, :read, nil).and_return(true)
219
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
220
+ allow(access_audit_logger).to receive(:log_permission_check)
221
+
222
+ middleware.call(env)
223
+
224
+ expect(access_audit_logger).to have_received(:log_permission_check) do |args|
225
+ expect(args[:resource_id]).to eq("789")
226
+ end
227
+ end
228
+
229
+ it "extracts version_id from params" do
230
+ middleware = described_class.new(app, permission_checker: permission_checker, required_permission: :read, access_audit_logger: access_audit_logger)
231
+ env = Rack::MockRequest.env_for("/api/versions?version_id=999")
232
+ env["decision_agent.user"] = user
233
+
234
+ allow(permission_checker).to receive(:active?).with(user).and_return(true)
235
+ allow(permission_checker).to receive(:can?).with(user, :read, nil).and_return(true)
236
+ allow(permission_checker).to receive(:user_id).with(user).and_return("user1")
237
+ allow(access_audit_logger).to receive(:log_permission_check)
238
+
239
+ middleware.call(env)
240
+
241
+ expect(access_audit_logger).to have_received(:log_permission_check) do |args|
242
+ expect(args[:resource_id]).to eq("999")
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end