decision_agent 0.3.0 → 1.0.1

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,2134 +0,0 @@
1
- require "spec_helper"
2
- require "rack/test"
3
- require_relative "../lib/decision_agent/web/server"
4
-
5
- RSpec.describe "DecisionAgent Web UI Rack Integration" do
6
- include Rack::Test::Methods
7
-
8
- def app
9
- DecisionAgent::Web::Server
10
- end
11
-
12
- describe "Rack interface" do
13
- it "responds to .call for Rack compatibility" do
14
- expect(DecisionAgent::Web::Server).to respond_to(:call)
15
- end
16
-
17
- it "serves the main page" do
18
- get "/"
19
- expect(last_response).to be_ok
20
- expect(last_response.body).to include("DecisionAgent")
21
- end
22
-
23
- it "serves the health endpoint" do
24
- get "/health"
25
- expect(last_response).to be_ok
26
- expect(last_response.content_type).to include("application/json")
27
-
28
- json = JSON.parse(last_response.body)
29
- expect(json["status"]).to eq("ok")
30
- expect(json["version"]).to eq(DecisionAgent::VERSION)
31
- end
32
-
33
- it "validates rules via POST /api/validate" do
34
- valid_rules = {
35
- version: "1.0",
36
- ruleset: "test_rules",
37
- rules: [{
38
- id: "test_rule",
39
- if: { field: "amount", op: "gt", value: 100 },
40
- then: { decision: "approve", weight: 0.9, reason: "Test" }
41
- }]
42
- }
43
-
44
- post "/api/validate", valid_rules.to_json, { "CONTENT_TYPE" => "application/json" }
45
-
46
- expect(last_response).to be_ok
47
- json = JSON.parse(last_response.body)
48
- expect(json["valid"]).to be true
49
- end
50
-
51
- it "returns error for invalid rules" do
52
- invalid_rules = {
53
- version: "1.0",
54
- ruleset: "test_rules",
55
- rules: [{
56
- id: "bad_rule"
57
- # Missing required fields
58
- }]
59
- }
60
-
61
- post "/api/validate", invalid_rules.to_json, { "CONTENT_TYPE" => "application/json" }
62
-
63
- expect(last_response.status).to eq(422)
64
- json = JSON.parse(last_response.body)
65
- expect(json["valid"]).to be false
66
- expect(json["errors"]).to be_an(Array)
67
- end
68
-
69
- it "evaluates rules via POST /api/evaluate" do
70
- rules = {
71
- version: "1.0",
72
- ruleset: "test_rules",
73
- rules: [{
74
- id: "high_value",
75
- if: { field: "amount", op: "gt", value: 1000 },
76
- then: { decision: "approve", weight: 0.9, reason: "High value" }
77
- }]
78
- }
79
-
80
- payload = {
81
- rules: rules,
82
- context: { amount: 1500 }
83
- }
84
-
85
- post "/api/evaluate", payload.to_json, { "CONTENT_TYPE" => "application/json" }
86
-
87
- expect(last_response).to be_ok
88
- json = JSON.parse(last_response.body)
89
- expect(json["success"]).to be true
90
- expect(json["decision"]).to eq("approve")
91
- expect(json["weight"]).to eq(0.9)
92
- expect(json["reason"]).to eq("High value")
93
- end
94
-
95
- it "serves example rules" do
96
- get "/api/examples"
97
-
98
- expect(last_response).to be_ok
99
- json = JSON.parse(last_response.body)
100
- expect(json).to be_an(Array)
101
- expect(json.length).to be > 0
102
- expect(json.first).to have_key("name")
103
- expect(json.first).to have_key("rules")
104
- end
105
-
106
- it "handles CORS preflight requests" do
107
- options "/api/validate"
108
-
109
- expect(last_response.status).to eq(200)
110
- expect(last_response.headers["Access-Control-Allow-Origin"]).to eq("*")
111
- expect(last_response.headers["Access-Control-Allow-Methods"]).to include("POST")
112
- end
113
- end
114
-
115
- describe "Password reset API" do
116
- before do
117
- # Create a test user
118
- authenticator = DecisionAgent::Web::Server.authenticator
119
- authenticator.create_user(
120
- email: "test@example.com",
121
- password: "oldpassword123"
122
- )
123
- end
124
-
125
- describe "POST /api/auth/password/reset-request" do
126
- it "returns success for valid email" do
127
- post "/api/auth/password/reset-request",
128
- { email: "test@example.com" }.to_json,
129
- { "CONTENT_TYPE" => "application/json" }
130
-
131
- expect(last_response).to be_ok
132
- json = JSON.parse(last_response.body)
133
- expect(json["success"]).to be true
134
- expect(json["token"]).to be_a(String)
135
- expect(json["expires_at"]).to be_a(String)
136
- end
137
-
138
- it "returns success even for non-existent email (security)" do
139
- post "/api/auth/password/reset-request",
140
- { email: "nonexistent@example.com" }.to_json,
141
- { "CONTENT_TYPE" => "application/json" }
142
-
143
- expect(last_response).to be_ok
144
- json = JSON.parse(last_response.body)
145
- expect(json["success"]).to be true
146
- expect(json["token"]).to be_nil
147
- end
148
-
149
- it "returns error when email is missing" do
150
- post "/api/auth/password/reset-request",
151
- {}.to_json,
152
- { "CONTENT_TYPE" => "application/json" }
153
-
154
- expect(last_response.status).to eq(400)
155
- json = JSON.parse(last_response.body)
156
- expect(json["error"]).to include("Email is required")
157
- end
158
- end
159
-
160
- describe "POST /api/auth/password/reset" do
161
- let(:reset_token) do
162
- authenticator = DecisionAgent::Web::Server.authenticator
163
- token = authenticator.request_password_reset("test@example.com")
164
- token.token
165
- end
166
-
167
- it "resets password with valid token" do
168
- post "/api/auth/password/reset",
169
- { token: reset_token, password: "newpassword123" }.to_json,
170
- { "CONTENT_TYPE" => "application/json" }
171
-
172
- expect(last_response).to be_ok
173
- json = JSON.parse(last_response.body)
174
- expect(json["success"]).to be true
175
- expect(json["message"]).to include("reset successfully")
176
-
177
- # Verify password was actually changed
178
- authenticator = DecisionAgent::Web::Server.authenticator
179
- user = authenticator.find_user_by_email("test@example.com")
180
- expect(user.authenticate("newpassword123")).to be true
181
- expect(user.authenticate("oldpassword123")).to be false
182
- end
183
-
184
- it "returns error for invalid token" do
185
- post "/api/auth/password/reset",
186
- { token: "invalid_token", password: "newpassword123" }.to_json,
187
- { "CONTENT_TYPE" => "application/json" }
188
-
189
- expect(last_response.status).to eq(400)
190
- json = JSON.parse(last_response.body)
191
- expect(json["error"]).to include("Invalid or expired")
192
- end
193
-
194
- it "returns error when password is too short" do
195
- post "/api/auth/password/reset",
196
- { token: reset_token, password: "short" }.to_json,
197
- { "CONTENT_TYPE" => "application/json" }
198
-
199
- expect(last_response.status).to eq(400)
200
- json = JSON.parse(last_response.body)
201
- expect(json["error"]).to include("at least 8 characters")
202
- end
203
-
204
- it "returns error when token is missing" do
205
- post "/api/auth/password/reset",
206
- { password: "newpassword123" }.to_json,
207
- { "CONTENT_TYPE" => "application/json" }
208
-
209
- expect(last_response.status).to eq(400)
210
- json = JSON.parse(last_response.body)
211
- expect(json["error"]).to include("Token and password are required")
212
- end
213
-
214
- it "returns error when password is missing" do
215
- post "/api/auth/password/reset",
216
- { token: reset_token }.to_json,
217
- { "CONTENT_TYPE" => "application/json" }
218
-
219
- expect(last_response.status).to eq(400)
220
- json = JSON.parse(last_response.body)
221
- expect(json["error"]).to include("Token and password are required")
222
- end
223
- end
224
- end
225
-
226
- describe "Rails mounting compatibility" do
227
- it "can be mounted in a Rack app" do
228
- # Simulate a Rails-style mount
229
- rack_app = Rack::Builder.new do
230
- map "/decision_agent" do
231
- run DecisionAgent::Web::Server
232
- end
233
- end
234
-
235
- # Create a test session for the mounted app
236
- test_session = Rack::Test::Session.new(Rack::MockSession.new(rack_app))
237
-
238
- # Test that the health endpoint works when mounted
239
- test_session.get "/decision_agent/health"
240
- expect(test_session.last_response).to be_ok
241
-
242
- json = JSON.parse(test_session.last_response.body)
243
- expect(json["status"]).to eq("ok")
244
- end
245
- end
246
-
247
- describe "API error handling" do
248
- it "handles invalid JSON in validate endpoint" do
249
- post "/api/validate", "invalid json", { "CONTENT_TYPE" => "application/json" }
250
- expect(last_response.status).to eq(400)
251
- json = JSON.parse(last_response.body)
252
- expect(json["valid"]).to be false
253
- expect(json["errors"]).to be_an(Array)
254
- end
255
-
256
- it "handles server errors in validate endpoint" do
257
- allow(DecisionAgent::Dsl::SchemaValidator).to receive(:validate!).and_raise(StandardError.new("Unexpected error"))
258
- post "/api/validate", {}.to_json, { "CONTENT_TYPE" => "application/json" }
259
- expect(last_response.status).to eq(500)
260
- json = JSON.parse(last_response.body)
261
- expect(json["valid"]).to be false
262
- expect(json["errors"]).to be_an(Array)
263
- end
264
-
265
- it "handles errors in evaluate endpoint" do
266
- post "/api/evaluate", "invalid json", { "CONTENT_TYPE" => "application/json" }
267
- expect(last_response.status).to eq(500)
268
- json = JSON.parse(last_response.body)
269
- expect(json["success"]).to be false
270
- end
271
-
272
- it "handles no rules matched in evaluate endpoint" do
273
- rules = {
274
- version: "1.0",
275
- ruleset: "test_rules",
276
- rules: [{
277
- id: "high_value",
278
- if: { field: "amount", op: "gt", value: 1000 },
279
- then: { decision: "approve", weight: 0.9, reason: "High value" }
280
- }]
281
- }
282
-
283
- payload = {
284
- rules: rules,
285
- context: { amount: 100 } # Won't match
286
- }
287
-
288
- post "/api/evaluate", payload.to_json, { "CONTENT_TYPE" => "application/json" }
289
- expect(last_response).to be_ok
290
- json = JSON.parse(last_response.body)
291
- expect(json["success"]).to be true
292
- expect(json["decision"]).to be_nil
293
- expect(json["message"]).to include("No rules matched")
294
- end
295
- end
296
-
297
- describe "Authentication API" do
298
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
299
-
300
- before do
301
- user = authenticator.create_user(
302
- email: "auth@example.com",
303
- password: "password123"
304
- )
305
- # Give user read permission for roles endpoint
306
- user.assign_role(:viewer)
307
- end
308
-
309
- describe "POST /api/auth/login" do
310
- it "logs in with valid credentials" do
311
- post "/api/auth/login",
312
- { email: "auth@example.com", password: "password123" }.to_json,
313
- { "CONTENT_TYPE" => "application/json" }
314
-
315
- expect(last_response).to be_ok
316
- json = JSON.parse(last_response.body)
317
- expect(json["token"]).to be_a(String)
318
- expect(json["user"]).to be_a(Hash)
319
- expect(json["expires_at"]).to be_a(String)
320
- end
321
-
322
- it "returns 401 for invalid credentials" do
323
- post "/api/auth/login",
324
- { email: "auth@example.com", password: "wrongpassword" }.to_json,
325
- { "CONTENT_TYPE" => "application/json" }
326
-
327
- expect(last_response.status).to eq(401)
328
- json = JSON.parse(last_response.body)
329
- expect(json["error"]).to include("Invalid email or password")
330
- end
331
-
332
- it "returns 400 when email is missing" do
333
- post "/api/auth/login",
334
- { password: "password123" }.to_json,
335
- { "CONTENT_TYPE" => "application/json" }
336
-
337
- expect(last_response.status).to eq(400)
338
- json = JSON.parse(last_response.body)
339
- expect(json["error"]).to include("Email and password are required")
340
- end
341
-
342
- it "returns 400 when password is missing" do
343
- post "/api/auth/login",
344
- { email: "auth@example.com" }.to_json,
345
- { "CONTENT_TYPE" => "application/json" }
346
-
347
- expect(last_response.status).to eq(400)
348
- json = JSON.parse(last_response.body)
349
- expect(json["error"]).to include("Email and password are required")
350
- end
351
-
352
- it "handles invalid JSON" do
353
- post "/api/auth/login", "invalid json", { "CONTENT_TYPE" => "application/json" }
354
- expect(last_response.status).to eq(400)
355
- json = JSON.parse(last_response.body)
356
- expect(json["error"]).to include("Invalid JSON")
357
- end
358
- end
359
-
360
- describe "POST /api/auth/logout" do
361
- it "logs out with valid token" do
362
- session = authenticator.login("auth@example.com", "password123")
363
- post "/api/auth/logout",
364
- {},
365
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
366
-
367
- expect(last_response).to be_ok
368
- json = JSON.parse(last_response.body)
369
- expect(json["success"]).to be true
370
- end
371
-
372
- it "logs out without token" do
373
- post "/api/auth/logout", {}, { "CONTENT_TYPE" => "application/json" }
374
- expect(last_response).to be_ok
375
- json = JSON.parse(last_response.body)
376
- expect(json["success"]).to be true
377
- end
378
- end
379
-
380
- describe "GET /api/auth/me" do
381
- it "returns current user when authenticated" do
382
- session = authenticator.login("auth@example.com", "password123")
383
- get "/api/auth/me", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
384
-
385
- expect(last_response).to be_ok
386
- json = JSON.parse(last_response.body)
387
- expect(json["email"]).to eq("auth@example.com")
388
- end
389
-
390
- it "returns 401 when not authenticated" do
391
- get "/api/auth/me"
392
- expect(last_response.status).to eq(401)
393
- json = JSON.parse(last_response.body)
394
- expect(json["error"]).to eq("Not authenticated")
395
- end
396
- end
397
-
398
- describe "GET /api/auth/roles" do
399
- it "requires authentication" do
400
- # Clear any existing session by not providing auth headers
401
- get "/api/auth/roles", {}, {}
402
- # If somehow a user is authenticated, they won't have permission, so we accept 403 as well
403
- expect([401, 403]).to include(last_response.status)
404
- end
405
-
406
- it "returns roles when authenticated with proper permission" do
407
- session = authenticator.login("auth@example.com", "password123")
408
- get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
409
-
410
- expect(last_response).to be_ok
411
- json = JSON.parse(last_response.body)
412
- expect(json).to be_an(Array)
413
- end
414
- end
415
- end
416
-
417
- describe "User management API" do
418
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
419
- let(:admin_user) do
420
- user = authenticator.create_user(
421
- email: "admin@example.com",
422
- password: "password123",
423
- roles: [:admin]
424
- )
425
- session = authenticator.login("admin@example.com", "password123")
426
- { user: user, session: session }
427
- end
428
-
429
- describe "POST /api/auth/users" do
430
- it "requires authentication" do
431
- post "/api/auth/users", {}.to_json, { "CONTENT_TYPE" => "application/json" }
432
- expect(last_response.status).to eq(401)
433
- end
434
-
435
- it "creates user when authenticated as admin" do
436
- post "/api/auth/users",
437
- { email: "newuser@example.com", password: "password123", roles: [] }.to_json,
438
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
439
-
440
- expect(last_response.status).to eq(201)
441
- json = JSON.parse(last_response.body)
442
- expect(json["email"]).to eq("newuser@example.com")
443
- end
444
-
445
- it "returns 400 when email is missing" do
446
- post "/api/auth/users",
447
- { password: "password123" }.to_json,
448
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
449
-
450
- expect(last_response.status).to eq(400)
451
- json = JSON.parse(last_response.body)
452
- expect(json["error"]).to include("Email and password are required")
453
- end
454
-
455
- it "returns 400 for invalid role" do
456
- post "/api/auth/users",
457
- { email: "user@example.com", password: "password123", roles: ["invalid_role"] }.to_json,
458
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
459
-
460
- expect(last_response.status).to eq(400)
461
- json = JSON.parse(last_response.body)
462
- expect(json["error"]).to include("Invalid role")
463
- end
464
- end
465
-
466
- describe "GET /api/auth/users" do
467
- it "requires authentication" do
468
- get "/api/auth/users"
469
- expect(last_response.status).to eq(401)
470
- end
471
-
472
- it "lists users when authenticated as admin" do
473
- get "/api/auth/users", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
474
- expect(last_response).to be_ok
475
- json = JSON.parse(last_response.body)
476
- expect(json).to be_an(Array)
477
- end
478
- end
479
-
480
- describe "POST /api/auth/users/:id/roles" do
481
- let(:test_user) do
482
- authenticator.create_user(
483
- email: "testuser@example.com",
484
- password: "password123"
485
- )
486
- end
487
-
488
- it "assigns role to user" do
489
- post "/api/auth/users/#{test_user.id}/roles",
490
- { role: "editor" }.to_json,
491
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
492
-
493
- expect(last_response).to be_ok
494
- json = JSON.parse(last_response.body)
495
- expect(json["roles"]).to include("editor")
496
- end
497
-
498
- it "returns 404 for non-existent user" do
499
- post "/api/auth/users/nonexistent/roles",
500
- { role: "editor" }.to_json,
501
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
502
-
503
- expect(last_response.status).to eq(404)
504
- json = JSON.parse(last_response.body)
505
- expect(json["error"]).to include("User not found")
506
- end
507
-
508
- it "returns 400 when role is missing" do
509
- post "/api/auth/users/#{test_user.id}/roles",
510
- {}.to_json,
511
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
512
-
513
- expect(last_response.status).to eq(400)
514
- json = JSON.parse(last_response.body)
515
- expect(json["error"]).to include("Role is required")
516
- end
517
- end
518
-
519
- describe "DELETE /api/auth/users/:id/roles/:role" do
520
- let(:test_user) do
521
- user = authenticator.create_user(
522
- email: "testuser2@example.com",
523
- password: "password123"
524
- )
525
- user.assign_role(:editor)
526
- user
527
- end
528
-
529
- it "removes role from user" do
530
- delete "/api/auth/users/#{test_user.id}/roles/editor",
531
- {},
532
- { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
533
-
534
- expect(last_response).to be_ok
535
- json = JSON.parse(last_response.body)
536
- expect(json["roles"]).not_to include("editor")
537
- end
538
-
539
- it "returns 404 for non-existent user" do
540
- delete "/api/auth/users/nonexistent/roles/editor",
541
- {},
542
- { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
543
-
544
- expect(last_response.status).to eq(404)
545
- json = JSON.parse(last_response.body)
546
- expect(json["error"]).to include("User not found")
547
- end
548
- end
549
- end
550
-
551
- describe "Audit API" do
552
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
553
- let(:admin_user) do
554
- user = authenticator.create_user(
555
- email: "auditadmin@example.com",
556
- password: "password123",
557
- roles: [:admin]
558
- )
559
- session = authenticator.login("auditadmin@example.com", "password123")
560
- { user: user, session: session }
561
- end
562
-
563
- describe "GET /api/auth/audit" do
564
- it "requires authentication" do
565
- get "/api/auth/audit"
566
- expect(last_response.status).to eq(401)
567
- end
568
-
569
- it "returns audit logs" do
570
- get "/api/auth/audit", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
571
- expect(last_response).to be_ok
572
- json = JSON.parse(last_response.body)
573
- expect(json).to be_an(Array)
574
- end
575
-
576
- it "filters by user_id" do
577
- get "/api/auth/audit?user_id=#{admin_user[:user].id}", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
578
- expect(last_response).to be_ok
579
- json = JSON.parse(last_response.body)
580
- expect(json).to be_an(Array)
581
- end
582
-
583
- it "filters by event_type" do
584
- get "/api/auth/audit?event_type=login", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
585
- expect(last_response).to be_ok
586
- json = JSON.parse(last_response.body)
587
- expect(json).to be_an(Array)
588
- end
589
- end
590
- end
591
-
592
- describe "Versioning API" do
593
- # Setup database for ActiveRecord adapter if available
594
- if defined?(ActiveRecord)
595
- before(:all) do
596
- # Setup in-memory SQLite database
597
- ActiveRecord::Base.establish_connection(
598
- adapter: "sqlite3",
599
- database: ":memory:"
600
- )
601
-
602
- # Create the schema
603
- ActiveRecord::Schema.define do
604
- create_table :rule_versions, force: true do |t|
605
- t.string :rule_id, null: false
606
- t.integer :version_number, null: false
607
- t.text :content, null: false
608
- t.string :created_by, null: false, default: "system"
609
- t.text :changelog
610
- t.string :status, null: false, default: "draft"
611
- t.timestamps
612
- end
613
-
614
- add_index :rule_versions, %i[rule_id version_number], unique: true
615
- add_index :rule_versions, %i[rule_id status]
616
- end
617
-
618
- # Define RuleVersion model if not already defined
619
- unless defined?(RuleVersion)
620
- class ::RuleVersion < ActiveRecord::Base
621
- validates :rule_id, presence: true
622
- validates :version_number, presence: true, uniqueness: { scope: :rule_id }
623
- validates :content, presence: true
624
- validates :status, inclusion: { in: %w[draft active archived] }
625
- validates :created_by, presence: true
626
-
627
- scope :active, -> { where(status: "active") }
628
- scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
629
- scope :latest, -> { order(version_number: :desc).limit(1) }
630
-
631
- before_create :set_next_version_number
632
-
633
- def parsed_content
634
- JSON.parse(content, symbolize_names: true)
635
- rescue JSON::ParserError
636
- {}
637
- end
638
-
639
- def content_hash=(hash)
640
- self.content = hash.to_json
641
- end
642
-
643
- def activate!
644
- transaction do
645
- self.class.where(rule_id: rule_id, status: "active")
646
- .where.not(id: id)
647
- .find_each do |v|
648
- v.update!(status: "archived")
649
- end
650
- update!(status: "active")
651
- end
652
- end
653
-
654
- private
655
-
656
- def set_next_version_number
657
- return if version_number.present?
658
-
659
- last_version = self.class.where(rule_id: rule_id)
660
- .order(version_number: :desc)
661
- .first
662
- self.version_number = last_version ? last_version.version_number + 1 : 1
663
- end
664
- end
665
- end
666
- end
667
-
668
- before(:each) do
669
- # Clean up between tests
670
- RuleVersion.delete_all if defined?(RuleVersion)
671
- end
672
- end
673
-
674
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
675
- let(:user) do
676
- u = authenticator.create_user(
677
- email: "version@example.com",
678
- password: "password123",
679
- roles: [:editor]
680
- )
681
- session = authenticator.login("version@example.com", "password123")
682
- { user: u, session: session }
683
- end
684
-
685
- describe "POST /api/versions" do
686
- it "requires authentication" do
687
- post "/api/versions", {}.to_json, { "CONTENT_TYPE" => "application/json" }
688
- expect(last_response.status).to eq(401)
689
- end
690
-
691
- it "creates a version" do
692
- post "/api/versions",
693
- {
694
- rule_id: "rule1",
695
- content: { test: "data" },
696
- created_by: "test@example.com",
697
- changelog: "Initial version"
698
- }.to_json,
699
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
700
-
701
- expect(last_response.status).to eq(201)
702
- json = JSON.parse(last_response.body)
703
- expect(json["rule_id"]).to eq("rule1")
704
- end
705
- end
706
-
707
- describe "GET /api/rules/:rule_id/versions" do
708
- it "requires authentication" do
709
- get "/api/rules/rule1/versions"
710
- expect(last_response.status).to eq(401)
711
- end
712
-
713
- it "returns versions for a rule" do
714
- get "/api/rules/rule1/versions", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
715
- expect(last_response).to be_ok
716
- json = JSON.parse(last_response.body)
717
- expect(json).to be_an(Array)
718
- end
719
- end
720
-
721
- describe "GET /api/rules/:rule_id/history" do
722
- it "requires authentication" do
723
- get "/api/rules/rule1/history"
724
- expect(last_response.status).to eq(401)
725
- end
726
-
727
- it "returns history for a rule" do
728
- get "/api/rules/rule1/history", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
729
- expect(last_response).to be_ok
730
- json = JSON.parse(last_response.body)
731
- expect(json).to be_a(Hash)
732
- end
733
- end
734
-
735
- describe "GET /api/versions/:version_id" do
736
- it "requires authentication" do
737
- get "/api/versions/version1"
738
- expect(last_response.status).to eq(401)
739
- end
740
-
741
- it "returns 404 for non-existent version" do
742
- get "/api/versions/nonexistent", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
743
- expect(last_response.status).to eq(404)
744
- json = JSON.parse(last_response.body)
745
- expect(json["error"]).to include("Version not found")
746
- end
747
- end
748
-
749
- describe "POST /api/versions/:version_id/activate" do
750
- it "requires authentication" do
751
- post "/api/versions/version1/activate", {}.to_json, { "CONTENT_TYPE" => "application/json" }
752
- expect(last_response.status).to eq(401)
753
- end
754
- end
755
-
756
- describe "GET /api/versions/:version_id_1/compare/:version_id_2" do
757
- it "requires authentication" do
758
- get "/api/versions/v1/compare/v2"
759
- expect(last_response.status).to eq(401)
760
- end
761
- end
762
-
763
- describe "DELETE /api/versions/:version_id" do
764
- it "requires authentication" do
765
- delete "/api/versions/version1"
766
- expect(last_response.status).to eq(401)
767
- end
768
- end
769
- end
770
-
771
- describe "Batch Testing API" do
772
- describe "POST /api/testing/batch/import" do
773
- let(:csv_content) do
774
- <<~CSV
775
- id,user_id,amount,expected_decision
776
- test_1,123,1000,approve
777
- test_2,456,5000,reject
778
- CSV
779
- end
780
-
781
- it "imports CSV file and returns test_id" do
782
- file = Tempfile.new(["test", ".csv"])
783
- file.write(csv_content)
784
- file.rewind
785
-
786
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
787
-
788
- expect(last_response.status).to eq(201)
789
- json = JSON.parse(last_response.body)
790
- expect(json["test_id"]).to be_a(String)
791
- expect(json["scenarios_count"]).to eq(2)
792
-
793
- file.close
794
- file.unlink
795
- end
796
-
797
- it "returns error when no file uploaded" do
798
- post "/api/testing/batch/import", {}, { "CONTENT_TYPE" => "multipart/form-data" }
799
-
800
- expect(last_response.status).to eq(400)
801
- json = JSON.parse(last_response.body)
802
- expect(json["error"]).to include("No file uploaded")
803
- end
804
-
805
- it "handles import errors gracefully" do
806
- file = Tempfile.new(["test", ".csv"])
807
- # Create CSV with missing required 'id' column to trigger an error
808
- file.write("col1,col2\nvalue1,value2\n")
809
- file.rewind
810
-
811
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
812
-
813
- # Should handle error gracefully (missing 'id' column should cause 422)
814
- expect([400, 422, 500]).to include(last_response.status)
815
-
816
- file.close
817
- file.unlink
818
- end
819
- end
820
-
821
- describe "POST /api/testing/batch/run" do
822
- let(:test_id) do
823
- # Create a test import first
824
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
825
- file = Tempfile.new(["test", ".csv"])
826
- file.write(csv_content)
827
- file.rewind
828
-
829
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
830
- json = JSON.parse(last_response.body)
831
- file.close
832
- file.unlink
833
- json["test_id"]
834
- end
835
-
836
- let(:rules_json) do
837
- {
838
- version: "1.0",
839
- ruleset: "test",
840
- rules: [
841
- {
842
- id: "rule_1",
843
- if: { field: "amount", op: "gt", value: 500 },
844
- then: { decision: "approve", weight: 0.9, reason: "High amount" }
845
- }
846
- ]
847
- }
848
- end
849
-
850
- it "runs batch test and returns results" do
851
- post "/api/testing/batch/run",
852
- { test_id: test_id, rules: rules_json }.to_json,
853
- { "CONTENT_TYPE" => "application/json" }
854
-
855
- expect(last_response.status).to eq(200)
856
- json = JSON.parse(last_response.body)
857
- expect(json["status"]).to eq("completed")
858
- expect(json["results_count"]).to eq(1)
859
- expect(json["statistics"]).to be_a(Hash)
860
- end
861
-
862
- it "returns error when test_id is missing" do
863
- post "/api/testing/batch/run",
864
- { rules: rules_json }.to_json,
865
- { "CONTENT_TYPE" => "application/json" }
866
-
867
- expect(last_response.status).to eq(400)
868
- json = JSON.parse(last_response.body)
869
- expect(json["error"]).to include("test_id is required")
870
- end
871
-
872
- it "returns error when rules are missing" do
873
- post "/api/testing/batch/run",
874
- { test_id: test_id }.to_json,
875
- { "CONTENT_TYPE" => "application/json" }
876
-
877
- expect(last_response.status).to eq(400)
878
- json = JSON.parse(last_response.body)
879
- expect(json["error"]).to include("rules JSON is required")
880
- end
881
-
882
- it "returns 404 when test not found" do
883
- post "/api/testing/batch/run",
884
- { test_id: "nonexistent", rules: rules_json }.to_json,
885
- { "CONTENT_TYPE" => "application/json" }
886
-
887
- expect(last_response.status).to eq(404)
888
- json = JSON.parse(last_response.body)
889
- expect(json["error"]).to include("Test not found")
890
- end
891
- end
892
-
893
- describe "GET /api/testing/batch/:id/results" do
894
- it "returns 404 when test not found" do
895
- get "/api/testing/batch/nonexistent/results"
896
-
897
- expect(last_response.status).to eq(404)
898
- json = JSON.parse(last_response.body)
899
- expect(json["error"]).to include("Test not found")
900
- end
901
-
902
- it "returns test results when available" do
903
- # Create and run a test first
904
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
905
- file = Tempfile.new(["test", ".csv"])
906
- file.write(csv_content)
907
- file.rewind
908
-
909
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
910
- import_json = JSON.parse(last_response.body)
911
- test_id = import_json["test_id"]
912
-
913
- rules_json = {
914
- version: "1.0",
915
- ruleset: "test",
916
- rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
917
- }
918
-
919
- post "/api/testing/batch/run", { test_id: test_id, rules: rules_json }.to_json, { "CONTENT_TYPE" => "application/json" }
920
-
921
- get "/api/testing/batch/#{test_id}/results"
922
-
923
- expect(last_response.status).to eq(200)
924
- json = JSON.parse(last_response.body)
925
- expect(json["test_id"]).to eq(test_id)
926
- expect(json["status"]).to eq("completed")
927
-
928
- file.close
929
- file.unlink
930
- end
931
- end
932
-
933
- describe "GET /api/testing/batch/:id/coverage" do
934
- it "returns 404 when test not found" do
935
- get "/api/testing/batch/nonexistent/coverage"
936
-
937
- expect(last_response.status).to eq(404)
938
- json = JSON.parse(last_response.body)
939
- expect(json["error"]).to include("Test not found")
940
- end
941
-
942
- it "returns error when coverage not available" do
943
- # Create a test but don't run it
944
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
945
- file = Tempfile.new(["test", ".csv"])
946
- file.write(csv_content)
947
- file.rewind
948
-
949
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
950
- import_json = JSON.parse(last_response.body)
951
- test_id = import_json["test_id"]
952
-
953
- get "/api/testing/batch/#{test_id}/coverage"
954
-
955
- expect(last_response.status).to eq(404)
956
- json = JSON.parse(last_response.body)
957
- expect(json["error"]).to include("Coverage report not available")
958
-
959
- file.close
960
- file.unlink
961
- end
962
- end
963
-
964
- describe "GET /testing/batch" do
965
- it "serves batch testing page" do
966
- get "/testing/batch"
967
- expect(last_response).to be_ok
968
- end
969
- end
970
-
971
- describe "GET /auth/login" do
972
- it "serves login page" do
973
- get "/auth/login"
974
- expect(last_response).to be_ok
975
- end
976
- end
977
-
978
- describe "GET /auth/users" do
979
- it "serves user management page" do
980
- get "/auth/users"
981
- expect(last_response).to be_ok
982
- end
983
- end
984
- end
985
-
986
- describe "Server class methods" do
987
- describe ".batch_test_storage" do
988
- it "returns storage hash" do
989
- expect(DecisionAgent::Web::Server.batch_test_storage).to be_a(Hash)
990
- end
991
- end
992
-
993
- describe ".authenticator" do
994
- it "returns default authenticator" do
995
- expect(DecisionAgent::Web::Server.authenticator).to be_a(DecisionAgent::Auth::Authenticator)
996
- end
997
-
998
- it "allows setting custom authenticator" do
999
- custom_auth = double("Authenticator")
1000
- DecisionAgent::Web::Server.authenticator = custom_auth
1001
- expect(DecisionAgent::Web::Server.authenticator).to eq(custom_auth)
1002
- DecisionAgent::Web::Server.authenticator = nil # Reset
1003
- end
1004
- end
1005
-
1006
- describe ".permission_checker" do
1007
- it "returns default permission checker" do
1008
- expect(DecisionAgent::Web::Server.permission_checker).to be_a(DecisionAgent::Auth::PermissionChecker)
1009
- end
1010
- end
1011
-
1012
- describe ".access_audit_logger" do
1013
- it "returns default access audit logger" do
1014
- expect(DecisionAgent::Web::Server.access_audit_logger).to be_a(DecisionAgent::Auth::AccessAuditLogger)
1015
- end
1016
- end
1017
-
1018
- describe ".start!" do
1019
- it "is a class method" do
1020
- expect(DecisionAgent::Web::Server).to respond_to(:start!)
1021
- end
1022
- end
1023
- end
1024
-
1025
- describe "Token extraction" do
1026
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
1027
-
1028
- before do
1029
- # Create user for token extraction tests
1030
- authenticator.create_user(email: "auth@example.com", password: "password123")
1031
- end
1032
-
1033
- it "extracts token from Authorization header" do
1034
- session = authenticator.login("auth@example.com", "password123")
1035
- expect(session).not_to be_nil
1036
- get "/api/auth/me", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
1037
- expect(last_response).to be_ok
1038
- end
1039
-
1040
- it "extracts token from query parameter" do
1041
- session = authenticator.login("auth@example.com", "password123")
1042
- expect(session).not_to be_nil
1043
- get "/api/auth/me?token=#{session.token}"
1044
- expect(last_response).to be_ok
1045
- end
1046
- end
1047
-
1048
- describe "extract_token method" do
1049
- it "extracts token from cookie" do
1050
- authenticator = DecisionAgent::Web::Server.authenticator
1051
- authenticator.create_user(email: "cookie@example.com", password: "password123")
1052
- session = authenticator.login("cookie@example.com", "password123")
1053
-
1054
- get "/api/auth/me", {}, { "HTTP_COOKIE" => "decision_agent_session=#{session.token}" }
1055
- expect(last_response).to be_ok
1056
- end
1057
- end
1058
-
1059
- describe "parse_validation_errors" do
1060
- it "parses validation error messages correctly" do
1061
- # This is tested indirectly through the validate endpoint
1062
- invalid_rules = { version: "1.0", ruleset: "test", rules: [{ id: "bad_rule" }] }
1063
-
1064
- post "/api/validate", invalid_rules.to_json, { "CONTENT_TYPE" => "application/json" }
1065
-
1066
- expect(last_response.status).to eq(422)
1067
- json = JSON.parse(last_response.body)
1068
- expect(json["errors"]).to be_an(Array)
1069
- end
1070
-
1071
- it "handles error messages without numbered format" do
1072
- allow(DecisionAgent::Dsl::SchemaValidator).to receive(:validate!).and_raise(
1073
- DecisionAgent::InvalidRuleDslError.new("Simple error message")
1074
- )
1075
-
1076
- post "/api/validate", {}.to_json, { "CONTENT_TYPE" => "application/json" }
1077
-
1078
- expect(last_response.status).to eq(422)
1079
- json = JSON.parse(last_response.body)
1080
- expect(json["errors"]).to be_an(Array)
1081
- expect(json["errors"]).to include("Simple error message")
1082
- end
1083
- end
1084
-
1085
- describe "Versioning API comprehensive tests" do
1086
- # Setup database for ActiveRecord adapter if available
1087
- if defined?(ActiveRecord)
1088
- before(:all) do
1089
- # Setup in-memory SQLite database
1090
- ActiveRecord::Base.establish_connection(
1091
- adapter: "sqlite3",
1092
- database: ":memory:"
1093
- )
1094
-
1095
- # Create the schema
1096
- ActiveRecord::Schema.define do
1097
- create_table :rule_versions, force: true do |t|
1098
- t.string :rule_id, null: false
1099
- t.integer :version_number, null: false
1100
- t.text :content, null: false
1101
- t.string :created_by, null: false, default: "system"
1102
- t.text :changelog
1103
- t.string :status, null: false, default: "draft"
1104
- t.timestamps
1105
- end
1106
-
1107
- add_index :rule_versions, %i[rule_id version_number], unique: true
1108
- add_index :rule_versions, %i[rule_id status]
1109
- end
1110
-
1111
- # Define RuleVersion model if not already defined
1112
- unless defined?(RuleVersion)
1113
- class ::RuleVersion < ActiveRecord::Base
1114
- validates :rule_id, presence: true
1115
- validates :version_number, presence: true, uniqueness: { scope: :rule_id }
1116
- validates :content, presence: true
1117
- validates :status, inclusion: { in: %w[draft active archived] }
1118
- validates :created_by, presence: true
1119
-
1120
- scope :active, -> { where(status: "active") }
1121
- scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
1122
- scope :latest, -> { order(version_number: :desc).limit(1) }
1123
-
1124
- before_create :set_next_version_number
1125
-
1126
- def parsed_content
1127
- JSON.parse(content, symbolize_names: true)
1128
- rescue JSON::ParserError
1129
- {}
1130
- end
1131
-
1132
- def content_hash=(hash)
1133
- self.content = hash.to_json
1134
- end
1135
-
1136
- def activate!
1137
- transaction do
1138
- self.class.where(rule_id: rule_id, status: "active")
1139
- .where.not(id: id)
1140
- .find_each do |v|
1141
- v.update!(status: "archived")
1142
- end
1143
- update!(status: "active")
1144
- end
1145
- end
1146
-
1147
- private
1148
-
1149
- def set_next_version_number
1150
- return if version_number.present?
1151
-
1152
- last_version = self.class.where(rule_id: rule_id)
1153
- .order(version_number: :desc)
1154
- .first
1155
- self.version_number = last_version ? last_version.version_number + 1 : 1
1156
- end
1157
- end
1158
- end
1159
- end
1160
-
1161
- before(:each) do
1162
- # Clean up between tests
1163
- RuleVersion.delete_all if defined?(RuleVersion)
1164
- end
1165
- end
1166
-
1167
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
1168
- let(:user) do
1169
- u = authenticator.create_user(
1170
- email: "versionuser@example.com",
1171
- password: "password123",
1172
- roles: [:editor]
1173
- )
1174
- session = authenticator.login("versionuser@example.com", "password123")
1175
- { user: u, session: session }
1176
- end
1177
-
1178
- describe "POST /api/versions" do
1179
- it "creates a version with all fields" do
1180
- post "/api/versions",
1181
- {
1182
- rule_id: "rule1",
1183
- content: { test: "data" },
1184
- created_by: "test@example.com",
1185
- changelog: "Initial version"
1186
- }.to_json,
1187
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1188
-
1189
- expect(last_response.status).to eq(201)
1190
- json = JSON.parse(last_response.body)
1191
- expect(json["rule_id"]).to eq("rule1")
1192
- end
1193
-
1194
- it "creates a version without changelog" do
1195
- post "/api/versions",
1196
- {
1197
- rule_id: "rule2",
1198
- content: { test: "data" }
1199
- }.to_json,
1200
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1201
-
1202
- expect(last_response.status).to eq(201)
1203
- end
1204
-
1205
- it "handles server errors" do
1206
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:save_version).and_raise(StandardError.new("DB error"))
1207
-
1208
- post "/api/versions",
1209
- {
1210
- rule_id: "rule1",
1211
- content: { test: "data" }
1212
- }.to_json,
1213
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1214
-
1215
- expect(last_response.status).to eq(500)
1216
- json = JSON.parse(last_response.body)
1217
- expect(json["error"]).to include("DB error")
1218
- end
1219
- end
1220
-
1221
- describe "GET /api/rules/:rule_id/versions" do
1222
- it "returns versions with limit parameter" do
1223
- get "/api/rules/rule1/versions?limit=5", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1224
- expect(last_response).to be_ok
1225
- json = JSON.parse(last_response.body)
1226
- expect(json).to be_an(Array)
1227
- end
1228
-
1229
- it "handles server errors" do
1230
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:get_versions).and_raise(StandardError.new("DB error"))
1231
-
1232
- get "/api/rules/rule1/versions", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1233
- expect(last_response.status).to eq(500)
1234
- end
1235
- end
1236
-
1237
- describe "GET /api/rules/:rule_id/history" do
1238
- it "returns history for a rule" do
1239
- get "/api/rules/rule1/history", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1240
- expect(last_response).to be_ok
1241
- json = JSON.parse(last_response.body)
1242
- expect(json).to be_a(Hash)
1243
- end
1244
-
1245
- it "handles server errors" do
1246
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:get_history).and_raise(StandardError.new("DB error"))
1247
-
1248
- get "/api/rules/rule1/history", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1249
- expect(last_response.status).to eq(500)
1250
- end
1251
- end
1252
-
1253
- describe "GET /api/versions/:version_id" do
1254
- it "returns a specific version" do
1255
- # First create a version
1256
- post "/api/versions",
1257
- {
1258
- rule_id: "rule1",
1259
- content: { test: "data" }
1260
- }.to_json,
1261
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1262
- version_json = JSON.parse(last_response.body)
1263
- version_id = version_json["id"]
1264
-
1265
- get "/api/versions/#{version_id}", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1266
- expect(last_response).to be_ok
1267
- json = JSON.parse(last_response.body)
1268
- expect(json["id"]).to eq(version_id)
1269
- end
1270
-
1271
- it "handles server errors" do
1272
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:get_version).and_raise(StandardError.new("DB error"))
1273
-
1274
- get "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1275
- expect(last_response.status).to eq(500)
1276
- end
1277
- end
1278
-
1279
- describe "POST /api/versions/:version_id/activate" do
1280
- it "activates a version" do
1281
- # First create a version
1282
- post "/api/versions",
1283
- {
1284
- rule_id: "rule1",
1285
- content: { test: "data" }
1286
- }.to_json,
1287
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1288
- version_json = JSON.parse(last_response.body)
1289
- version_id = version_json["id"]
1290
-
1291
- # Need deploy permission, create admin user
1292
- authenticator.create_user(
1293
- email: "deploy@example.com",
1294
- password: "password123",
1295
- roles: [:admin]
1296
- )
1297
- admin_session = authenticator.login("deploy@example.com", "password123")
1298
-
1299
- post "/api/versions/#{version_id}/activate",
1300
- { performed_by: "admin@example.com" }.to_json,
1301
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1302
-
1303
- expect(last_response).to be_ok
1304
- json = JSON.parse(last_response.body)
1305
- expect(json["id"]).to eq(version_id)
1306
- end
1307
-
1308
- it "activates with empty body" do
1309
- # Create admin user for deploy permission
1310
- authenticator.create_user(
1311
- email: "deploy2@example.com",
1312
- password: "password123",
1313
- roles: [:admin]
1314
- )
1315
- admin_session = authenticator.login("deploy2@example.com", "password123")
1316
-
1317
- post "/api/versions/v1/activate",
1318
- {}.to_json,
1319
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1320
-
1321
- # May succeed or fail depending on version existence, but should not error on empty body
1322
- expect([200, 404, 500]).to include(last_response.status)
1323
- end
1324
-
1325
- it "handles server errors" do
1326
- authenticator.create_user(
1327
- email: "deploy3@example.com",
1328
- password: "password123",
1329
- roles: [:admin]
1330
- )
1331
- admin_session = authenticator.login("deploy3@example.com", "password123")
1332
-
1333
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:rollback).and_raise(StandardError.new("DB error"))
1334
-
1335
- post "/api/versions/v1/activate",
1336
- {}.to_json,
1337
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1338
-
1339
- expect(last_response.status).to eq(500)
1340
- end
1341
- end
1342
-
1343
- describe "GET /api/versions/:version_id_1/compare/:version_id_2" do
1344
- it "compares two versions" do
1345
- get "/api/versions/v1/compare/v2", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1346
- # May return 404 if versions don't exist, but should handle the request
1347
- expect([200, 404]).to include(last_response.status)
1348
- end
1349
-
1350
- it "handles server errors" do
1351
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:compare).and_raise(StandardError.new("DB error"))
1352
-
1353
- get "/api/versions/v1/compare/v2", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1354
- expect(last_response.status).to eq(500)
1355
- end
1356
- end
1357
-
1358
- describe "DELETE /api/versions/:version_id" do
1359
- xit "deletes a version" do
1360
- # Need delete permission, create admin user
1361
- authenticator.create_user(
1362
- email: "delete@example.com",
1363
- password: "password123",
1364
- roles: [:admin]
1365
- )
1366
- admin_session = authenticator.login("delete@example.com", "password123")
1367
-
1368
- delete "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1369
- # May return 404 if version doesn't exist, but should handle the request
1370
- expect([200, 404]).to include(last_response.status)
1371
- end
1372
-
1373
- it "handles NotFoundError" do
1374
- authenticator.create_user(
1375
- email: "delete2@example.com",
1376
- password: "password123",
1377
- roles: [:admin]
1378
- )
1379
- admin_session = authenticator.login("delete2@example.com", "password123")
1380
-
1381
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:delete_version).and_raise(DecisionAgent::NotFoundError.new("Version not found"))
1382
-
1383
- delete "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1384
- expect(last_response.status).to eq(404)
1385
- json = JSON.parse(last_response.body)
1386
- expect(json["error"]).to include("Version not found")
1387
- end
1388
-
1389
- it "handles ValidationError" do
1390
- authenticator.create_user(
1391
- email: "delete3@example.com",
1392
- password: "password123",
1393
- roles: [:admin]
1394
- )
1395
- admin_session = authenticator.login("delete3@example.com", "password123")
1396
-
1397
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:delete_version).and_raise(DecisionAgent::ValidationError.new("Cannot delete active version"))
1398
-
1399
- delete "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1400
- expect(last_response.status).to eq(422)
1401
- json = JSON.parse(last_response.body)
1402
- expect(json["error"]).to include("Cannot delete active version")
1403
- end
1404
-
1405
- it "handles server errors" do
1406
- authenticator.create_user(
1407
- email: "delete4@example.com",
1408
- password: "password123",
1409
- roles: [:admin]
1410
- )
1411
- admin_session = authenticator.login("delete4@example.com", "password123")
1412
-
1413
- allow_any_instance_of(DecisionAgent::Versioning::VersionManager).to receive(:delete_version).and_raise(StandardError.new("DB error"))
1414
-
1415
- delete "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1416
- expect(last_response.status).to eq(500)
1417
- end
1418
-
1419
- it "handles unexpected errors during delete and converts to 404" do
1420
- authenticator.create_user(
1421
- email: "delete5@example.com",
1422
- password: "password123",
1423
- roles: [:admin]
1424
- )
1425
- admin_session = authenticator.login("delete5@example.com", "password123")
1426
-
1427
- # Simulate an unexpected error in the adapter (e.g., lock error, file system error)
1428
- # This should be caught and converted to NotFoundError, resulting in 404
1429
- adapter_instance = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: "./versions")
1430
- allow(DecisionAgent::Versioning::VersionManager).to receive(:new).and_return(
1431
- DecisionAgent::Versioning::VersionManager.new(adapter: adapter_instance)
1432
- )
1433
- allow(adapter_instance).to receive(:list_versions_unsafe).and_raise(StandardError.new("Unexpected error"))
1434
-
1435
- delete "/api/versions/v1", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1436
- # Should return 404, not 500, due to error handling
1437
- expect([200, 404]).to include(last_response.status)
1438
- end
1439
- end
1440
- end
1441
-
1442
- describe "Versioning API integration tests with real FileStorageAdapter" do
1443
- let(:temp_storage_path) { Dir.mktmpdir("versioning_test_") }
1444
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
1445
- let(:user) do
1446
- u = authenticator.create_user(
1447
- email: "versioninteg@example.com",
1448
- password: "password123",
1449
- roles: [:editor]
1450
- )
1451
- session = authenticator.login("versioninteg@example.com", "password123")
1452
- { user: u, session: session }
1453
- end
1454
-
1455
- before do
1456
- # Create a real FileStorageAdapter and inject it into the server's version_manager
1457
- real_adapter = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_storage_path)
1458
- real_version_manager = DecisionAgent::Versioning::VersionManager.new(adapter: real_adapter)
1459
-
1460
- # Stub the version_manager method to return our real manager
1461
- allow_any_instance_of(DecisionAgent::Web::Server).to receive(:version_manager).and_return(real_version_manager)
1462
- end
1463
-
1464
- after do
1465
- # Clean up temp directory
1466
- FileUtils.rm_rf(temp_storage_path)
1467
- end
1468
-
1469
- describe "POST /api/versions" do
1470
- it "creates a version with real file storage" do
1471
- rule_content = {
1472
- version: "1.0",
1473
- ruleset: "test_rules",
1474
- rules: [{
1475
- id: "rule1",
1476
- if: { field: "amount", op: "gt", value: 100 },
1477
- then: { decision: "approve", weight: 0.9, reason: "High amount" }
1478
- }]
1479
- }
1480
-
1481
- post "/api/versions",
1482
- {
1483
- rule_id: "integration_test_rule",
1484
- content: rule_content,
1485
- created_by: "integration@example.com",
1486
- changelog: "Integration test version"
1487
- }.to_json,
1488
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1489
-
1490
- expect(last_response.status).to eq(201)
1491
- json = JSON.parse(last_response.body)
1492
- expect(json["rule_id"]).to eq("integration_test_rule")
1493
- expect(json["version_number"]).to eq(1)
1494
- expect(json["status"]).to eq("active") # FileStorageAdapter defaults to "active"
1495
- expect(json["created_by"]).to eq("integration@example.com")
1496
-
1497
- # Verify file was actually created
1498
- rule_dir = File.join(temp_storage_path, "integration_test_rule")
1499
- expect(Dir.exist?(rule_dir)).to be true
1500
- version_file = File.join(rule_dir, "1.json")
1501
- expect(File.exist?(version_file)).to be true
1502
-
1503
- # Verify content
1504
- stored_content = JSON.parse(File.read(version_file))
1505
- # JSON parsing returns string keys, so we compare by converting both to same format
1506
- expected_content = JSON.parse(JSON.generate(rule_content))
1507
- expect(stored_content["content"]).to eq(expected_content)
1508
- end
1509
-
1510
- it "creates multiple versions and increments version number" do
1511
- rule_content = { version: "1.0", ruleset: "test", rules: [] }
1512
-
1513
- # Create first version
1514
- post "/api/versions",
1515
- {
1516
- rule_id: "multi_version_rule",
1517
- content: rule_content,
1518
- created_by: "test@example.com"
1519
- }.to_json,
1520
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1521
-
1522
- expect(last_response.status).to eq(201)
1523
- json1 = JSON.parse(last_response.body)
1524
- expect(json1["version_number"]).to eq(1)
1525
-
1526
- # Create second version
1527
- post "/api/versions",
1528
- {
1529
- rule_id: "multi_version_rule",
1530
- content: rule_content.merge(version: "2.0"),
1531
- created_by: "test@example.com"
1532
- }.to_json,
1533
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1534
-
1535
- expect(last_response.status).to eq(201)
1536
- json2 = JSON.parse(last_response.body)
1537
- expect(json2["version_number"]).to eq(2)
1538
- end
1539
- end
1540
-
1541
- describe "GET /api/rules/:rule_id/versions" do
1542
- it "returns versions from real file storage" do
1543
- rule_content = { version: "1.0", ruleset: "test", rules: [] }
1544
-
1545
- # Create two versions
1546
- post "/api/versions",
1547
- {
1548
- rule_id: "list_test_rule",
1549
- content: rule_content,
1550
- created_by: "test@example.com"
1551
- }.to_json,
1552
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1553
-
1554
- post "/api/versions",
1555
- {
1556
- rule_id: "list_test_rule",
1557
- content: rule_content,
1558
- created_by: "test@example.com"
1559
- }.to_json,
1560
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1561
-
1562
- # List versions
1563
- get "/api/rules/list_test_rule/versions", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1564
-
1565
- expect(last_response.status).to eq(200)
1566
- json = JSON.parse(last_response.body)
1567
- expect(json).to be_an(Array)
1568
- expect(json.length).to eq(2)
1569
- expect(json.first["version_number"]).to eq(2) # Most recent first
1570
- expect(json.last["version_number"]).to eq(1)
1571
- end
1572
-
1573
- it "respects limit parameter" do
1574
- rule_content = { version: "1.0", ruleset: "test", rules: [] }
1575
-
1576
- # Create three versions
1577
- 3.times do
1578
- post "/api/versions",
1579
- {
1580
- rule_id: "limit_test_rule",
1581
- content: rule_content,
1582
- created_by: "test@example.com"
1583
- }.to_json,
1584
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1585
- end
1586
-
1587
- # List with limit
1588
- get "/api/rules/limit_test_rule/versions?limit=2", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1589
-
1590
- expect(last_response.status).to eq(200)
1591
- json = JSON.parse(last_response.body)
1592
- expect(json.length).to eq(2)
1593
- end
1594
- end
1595
-
1596
- describe "GET /api/rules/:rule_id/history" do
1597
- it "returns history from real file storage" do
1598
- rule_content = { version: "1.0", ruleset: "test", rules: [] }
1599
-
1600
- # Create a version
1601
- post "/api/versions",
1602
- {
1603
- rule_id: "history_test_rule",
1604
- content: rule_content,
1605
- created_by: "test@example.com",
1606
- changelog: "Test changelog"
1607
- }.to_json,
1608
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1609
-
1610
- # Get history
1611
- get "/api/rules/history_test_rule/history", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1612
-
1613
- expect(last_response.status).to eq(200)
1614
- json = JSON.parse(last_response.body)
1615
- expect(json).to be_a(Hash)
1616
- expect(json["total_versions"]).to eq(1)
1617
- expect(json["versions"]).to be_an(Array)
1618
- expect(json["versions"].length).to eq(1)
1619
- end
1620
- end
1621
-
1622
- describe "GET /api/versions/:version_id" do
1623
- it "retrieves a specific version from real file storage" do
1624
- rule_content = {
1625
- version: "1.0",
1626
- ruleset: "test",
1627
- rules: [{ id: "test_rule", if: { field: "x", op: "eq", value: 1 }, then: { decision: "yes" } }]
1628
- }
1629
-
1630
- # Create a version
1631
- post "/api/versions",
1632
- {
1633
- rule_id: "get_version_test",
1634
- content: rule_content,
1635
- created_by: "test@example.com"
1636
- }.to_json,
1637
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1638
-
1639
- version_json = JSON.parse(last_response.body)
1640
- version_id = version_json["id"]
1641
-
1642
- # Get the version
1643
- get "/api/versions/#{version_id}", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1644
-
1645
- expect(last_response.status).to eq(200)
1646
- json = JSON.parse(last_response.body)
1647
- expect(json["id"]).to eq(version_id)
1648
- expect(json["rule_id"]).to eq("get_version_test")
1649
- # JSON parsing returns string keys, so we compare by converting both to same format
1650
- expected_content = JSON.parse(JSON.generate(rule_content))
1651
- expect(json["content"]).to eq(expected_content)
1652
- end
1653
- end
1654
-
1655
- describe "POST /api/versions/:version_id/activate" do
1656
- it "activates a version with real file storage" do
1657
- # Create admin user for deploy permission
1658
- authenticator.create_user(
1659
- email: "deployadmin@example.com",
1660
- password: "password123",
1661
- roles: [:admin]
1662
- )
1663
- admin_session = authenticator.login("deployadmin@example.com", "password123")
1664
-
1665
- rule_content = { version: "1.0", ruleset: "test", rules: [] }
1666
-
1667
- # Create a version
1668
- post "/api/versions",
1669
- {
1670
- rule_id: "activate_test_rule",
1671
- content: rule_content,
1672
- created_by: "test@example.com"
1673
- }.to_json,
1674
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1675
-
1676
- version_json = JSON.parse(last_response.body)
1677
- version_id = version_json["id"]
1678
-
1679
- # Activate the version
1680
- post "/api/versions/#{version_id}/activate",
1681
- { performed_by: "admin@example.com" }.to_json,
1682
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1683
-
1684
- expect(last_response.status).to eq(200)
1685
- json = JSON.parse(last_response.body)
1686
- expect(json["id"]).to eq(version_id)
1687
- expect(json["status"]).to eq("active")
1688
-
1689
- # Verify it's active by getting the version again
1690
- get "/api/versions/#{version_id}", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1691
- active_json = JSON.parse(last_response.body)
1692
- expect(active_json["status"]).to eq("active")
1693
- end
1694
- end
1695
-
1696
- describe "GET /api/versions/:version_id_1/compare/:version_id_2" do
1697
- it "compares two versions from real file storage" do
1698
- base_content = { version: "1.0", ruleset: "test", rules: [{ id: "r1" }] }
1699
- modified_content = { version: "1.0", ruleset: "test", rules: [{ id: "r1" }, { id: "r2" }] }
1700
-
1701
- # Create first version
1702
- post "/api/versions",
1703
- {
1704
- rule_id: "compare_test_rule",
1705
- content: base_content,
1706
- created_by: "test@example.com"
1707
- }.to_json,
1708
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1709
- v1_json = JSON.parse(last_response.body)
1710
- v1_id = v1_json["id"]
1711
-
1712
- # Create second version
1713
- post "/api/versions",
1714
- {
1715
- rule_id: "compare_test_rule",
1716
- content: modified_content,
1717
- created_by: "test@example.com"
1718
- }.to_json,
1719
- { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1720
- v2_json = JSON.parse(last_response.body)
1721
- v2_id = v2_json["id"]
1722
-
1723
- # Compare versions
1724
- get "/api/versions/#{v1_id}/compare/#{v2_id}", {}, { "HTTP_AUTHORIZATION" => "Bearer #{user[:session].token}" }
1725
-
1726
- expect(last_response.status).to eq(200)
1727
- json = JSON.parse(last_response.body)
1728
- expect(json).to be_a(Hash)
1729
- expect(json).to have_key("version_1") # compare_versions returns version_1 and version_2
1730
- expect(json).to have_key("version_2")
1731
- expect(json).to have_key("differences")
1732
- end
1733
- end
1734
- end
1735
-
1736
- describe "Batch Testing API comprehensive tests" do
1737
- describe "POST /api/testing/batch/import" do
1738
- it "handles Excel file import" do
1739
- skip "Roo gem not available" unless defined?(Roo)
1740
-
1741
- # Create a minimal Excel file for testing
1742
- # Since we can't easily create Excel files in tests, we'll test the error path
1743
- file = Tempfile.new(["test", ".xlsx"])
1744
- file.write("not excel content")
1745
- file.rewind
1746
-
1747
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") }, { "CONTENT_TYPE" => "multipart/form-data" }
1748
-
1749
- # Should handle error gracefully
1750
- expect([400, 422, 500]).to include(last_response.status)
1751
-
1752
- file.close
1753
- file.unlink
1754
- end
1755
-
1756
- it "handles file with no extension" do
1757
- file = Tempfile.new(["test", ""])
1758
- file.write("id,user_id\n")
1759
- file.rewind
1760
-
1761
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/plain") }, { "CONTENT_TYPE" => "multipart/form-data" }
1762
-
1763
- # Should treat as CSV or handle error
1764
- expect([201, 400, 422, 500]).to include(last_response.status)
1765
-
1766
- file.close
1767
- file.unlink
1768
- end
1769
- end
1770
-
1771
- describe "POST /api/testing/batch/run" do
1772
- it "handles batch test execution with options" do
1773
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1774
- file = Tempfile.new(["test", ".csv"])
1775
- file.write(csv_content)
1776
- file.rewind
1777
-
1778
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1779
- import_json = JSON.parse(last_response.body)
1780
- test_id = import_json["test_id"]
1781
-
1782
- rules_json = {
1783
- version: "1.0",
1784
- ruleset: "test",
1785
- rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1786
- }
1787
-
1788
- post "/api/testing/batch/run",
1789
- { test_id: test_id, rules: rules_json, options: { parallel: false, thread_count: 1 } }.to_json,
1790
- { "CONTENT_TYPE" => "application/json" }
1791
-
1792
- expect(last_response.status).to eq(200)
1793
- json = JSON.parse(last_response.body)
1794
- expect(json["status"]).to eq("completed")
1795
-
1796
- file.close
1797
- file.unlink
1798
- end
1799
-
1800
- it "handles execution errors and updates status" do
1801
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1802
- file = Tempfile.new(["test", ".csv"])
1803
- file.write(csv_content)
1804
- file.rewind
1805
-
1806
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1807
- import_json = JSON.parse(last_response.body)
1808
- test_id = import_json["test_id"]
1809
-
1810
- # Use invalid rules to cause an error
1811
- invalid_rules = { version: "1.0", ruleset: "test", rules: "invalid" }
1812
-
1813
- post "/api/testing/batch/run",
1814
- { test_id: test_id, rules: invalid_rules }.to_json,
1815
- { "CONTENT_TYPE" => "application/json" }
1816
-
1817
- expect(last_response.status).to eq(500)
1818
- json = JSON.parse(last_response.body)
1819
- expect(json["error"]).to be_present
1820
-
1821
- # Verify status was updated to failed
1822
- get "/api/testing/batch/#{test_id}/results"
1823
- result_json = JSON.parse(last_response.body)
1824
- expect(result_json["status"]).to eq("failed")
1825
-
1826
- file.close
1827
- file.unlink
1828
- end
1829
- end
1830
-
1831
- describe "GET /api/testing/batch/:id/results" do
1832
- it "returns full results with all fields" do
1833
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1834
- file = Tempfile.new(["test", ".csv"])
1835
- file.write(csv_content)
1836
- file.rewind
1837
-
1838
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1839
- import_json = JSON.parse(last_response.body)
1840
- test_id = import_json["test_id"]
1841
-
1842
- rules_json = {
1843
- version: "1.0",
1844
- ruleset: "test",
1845
- rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1846
- }
1847
-
1848
- post "/api/testing/batch/run", { test_id: test_id, rules: rules_json }.to_json, { "CONTENT_TYPE" => "application/json" }
1849
-
1850
- get "/api/testing/batch/#{test_id}/results"
1851
- expect(last_response.status).to eq(200)
1852
- json = JSON.parse(last_response.body)
1853
- expect(json["test_id"]).to eq(test_id)
1854
- expect(json["status"]).to eq("completed")
1855
- expect(json).to have_key("results")
1856
- expect(json).to have_key("statistics")
1857
-
1858
- file.close
1859
- file.unlink
1860
- end
1861
-
1862
- it "handles server errors" do
1863
- allow(DecisionAgent::Web::Server.batch_test_storage_mutex).to receive(:synchronize).and_raise(StandardError.new("Storage error"))
1864
-
1865
- get "/api/testing/batch/test123/results"
1866
- expect(last_response.status).to eq(500)
1867
- end
1868
- end
1869
-
1870
- describe "GET /api/testing/batch/:id/coverage" do
1871
- it "returns coverage report when available" do
1872
- csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1873
- file = Tempfile.new(["test", ".csv"])
1874
- file.write(csv_content)
1875
- file.rewind
1876
-
1877
- post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1878
- import_json = JSON.parse(last_response.body)
1879
- test_id = import_json["test_id"]
1880
-
1881
- rules_json = {
1882
- version: "1.0",
1883
- ruleset: "test",
1884
- rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1885
- }
1886
-
1887
- post "/api/testing/batch/run", { test_id: test_id, rules: rules_json }.to_json, { "CONTENT_TYPE" => "application/json" }
1888
-
1889
- get "/api/testing/batch/#{test_id}/coverage"
1890
- expect(last_response.status).to eq(200)
1891
- json = JSON.parse(last_response.body)
1892
- expect(json["test_id"]).to eq(test_id)
1893
- expect(json).to have_key("coverage")
1894
-
1895
- file.close
1896
- file.unlink
1897
- end
1898
-
1899
- it "handles server errors" do
1900
- allow(DecisionAgent::Web::Server.batch_test_storage_mutex).to receive(:synchronize).and_raise(StandardError.new("Storage error"))
1901
-
1902
- get "/api/testing/batch/test123/coverage"
1903
- expect(last_response.status).to eq(500)
1904
- end
1905
- end
1906
-
1907
- describe "GET /testing/batch, /auth/login, /auth/users" do
1908
- it "handles missing files gracefully" do
1909
- # Stub send_file to raise error
1910
- allow_any_instance_of(DecisionAgent::Web::Server).to receive(:send_file).and_raise(StandardError.new("File not found"))
1911
-
1912
- get "/testing/batch"
1913
- expect(last_response.status).to eq(404)
1914
- expect(last_response.body).to include("Batch testing page not found")
1915
-
1916
- get "/auth/login"
1917
- expect(last_response.status).to eq(404)
1918
- expect(last_response.body).to include("Login page not found")
1919
-
1920
- get "/auth/users"
1921
- expect(last_response.status).to eq(404)
1922
- expect(last_response.body).to include("User management page not found")
1923
- end
1924
- end
1925
- end
1926
-
1927
- describe "Auth and permission edge cases" do
1928
- let(:authenticator) { DecisionAgent::Web::Server.authenticator }
1929
-
1930
- describe "require_permission!" do
1931
- it "denies access and logs permission check" do
1932
- authenticator.create_user(
1933
- email: "noperm@example.com",
1934
- password: "password123",
1935
- roles: []
1936
- )
1937
- session = authenticator.login("noperm@example.com", "password123")
1938
-
1939
- get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
1940
- expect(last_response.status).to eq(403)
1941
- json = JSON.parse(last_response.body)
1942
- expect(json["error"]).to include("Permission denied")
1943
- end
1944
-
1945
- it "requires authentication before checking permissions" do
1946
- get "/api/auth/roles", {}, {}
1947
- # May return 401 (no auth) or 403 (auth but no permission)
1948
- expect([401, 403]).to include(last_response.status)
1949
- end
1950
-
1951
- it "handles audit logger failures gracefully" do
1952
- authenticator.create_user(
1953
- email: "loggerfail@example.com",
1954
- password: "password123",
1955
- roles: []
1956
- )
1957
- session = authenticator.login("loggerfail@example.com", "password123")
1958
-
1959
- # Simulate audit logger failure
1960
- allow_any_instance_of(DecisionAgent::Auth::AccessAuditLogger).to receive(:log_permission_check).and_raise(StandardError.new("Logger error"))
1961
-
1962
- # Should still deny permission even if logging fails
1963
- get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
1964
- expect(last_response.status).to eq(403)
1965
- json = JSON.parse(last_response.body)
1966
- expect(json["error"]).to include("Permission denied")
1967
- end
1968
-
1969
- it "handles audit logger failures when permission is granted" do
1970
- authenticator.create_user(
1971
- email: "loggerfail2@example.com",
1972
- password: "password123",
1973
- roles: [:admin]
1974
- )
1975
- admin_session = authenticator.login("loggerfail2@example.com", "password123")
1976
-
1977
- # Simulate audit logger failure after permission check passes
1978
- allow_any_instance_of(DecisionAgent::Auth::AccessAuditLogger).to receive(:log_permission_check).and_raise(StandardError.new("Logger error"))
1979
-
1980
- # Should still allow access even if logging fails
1981
- get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1982
- expect(last_response.status).to eq(200)
1983
- end
1984
- end
1985
-
1986
- describe "extract_token" do
1987
- it "extracts token from cookie" do
1988
- authenticator.create_user(
1989
- email: "cookieuser@example.com",
1990
- password: "password123"
1991
- )
1992
- session = authenticator.login("cookieuser@example.com", "password123")
1993
-
1994
- get "/api/auth/me", {}, { "HTTP_COOKIE" => "decision_agent_session=#{session.token}" }
1995
- expect(last_response).to be_ok
1996
- json = JSON.parse(last_response.body)
1997
- expect(json["email"]).to eq("cookieuser@example.com")
1998
- end
1999
-
2000
- it "extracts token from query parameter" do
2001
- authenticator.create_user(
2002
- email: "queryuser@example.com",
2003
- password: "password123"
2004
- )
2005
- session = authenticator.login("queryuser@example.com", "password123")
2006
-
2007
- get "/api/auth/me?token=#{session.token}"
2008
- expect(last_response).to be_ok
2009
- json = JSON.parse(last_response.body)
2010
- expect(json["email"]).to eq("queryuser@example.com")
2011
- end
2012
-
2013
- it "prefers Authorization header over cookie" do
2014
- authenticator.create_user(
2015
- email: "prefuser@example.com",
2016
- password: "password123"
2017
- )
2018
- session1 = authenticator.login("prefuser@example.com", "password123")
2019
- authenticator.create_user(
2020
- email: "prefuser2@example.com",
2021
- password: "password123"
2022
- )
2023
- session2 = authenticator.login("prefuser2@example.com", "password123")
2024
-
2025
- get "/api/auth/me",
2026
- {},
2027
- { "HTTP_AUTHORIZATION" => "Bearer #{session1.token}", "HTTP_COOKIE" => "decision_agent_session=#{session2.token}" }
2028
- expect(last_response).to be_ok
2029
- json = JSON.parse(last_response.body)
2030
- expect(json["email"]).to eq("prefuser@example.com")
2031
- end
2032
- end
2033
-
2034
- describe "POST /api/auth/logout" do
2035
- it "handles logout with invalid token gracefully" do
2036
- post "/api/auth/logout", {}, { "HTTP_AUTHORIZATION" => "Bearer invalid_token" }
2037
- expect(last_response).to be_ok
2038
- json = JSON.parse(last_response.body)
2039
- expect(json["success"]).to be true
2040
- end
2041
- end
2042
-
2043
- describe "GET /api/auth/audit" do
2044
- let(:admin_user) do
2045
- user = authenticator.create_user(
2046
- email: "auditadmin2@example.com",
2047
- password: "password123",
2048
- roles: [:admin]
2049
- )
2050
- session = authenticator.login("auditadmin2@example.com", "password123")
2051
- { user: user, session: session }
2052
- end
2053
-
2054
- it "filters by multiple parameters" do
2055
- get "/api/auth/audit?user_id=#{admin_user[:user].id}&event_type=login&limit=10",
2056
- {},
2057
- { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
2058
- expect(last_response).to be_ok
2059
- json = JSON.parse(last_response.body)
2060
- expect(json).to be_an(Array)
2061
- end
2062
-
2063
- it "handles server errors" do
2064
- allow(DecisionAgent::Web::Server.access_audit_logger).to receive(:query).and_raise(StandardError.new("DB error"))
2065
-
2066
- get "/api/auth/audit", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
2067
- expect(last_response.status).to eq(500)
2068
- end
2069
- end
2070
- end
2071
-
2072
- describe "Class methods" do
2073
- describe ".start!" do
2074
- it "sets port and bind" do
2075
- expect(DecisionAgent::Web::Server).to respond_to(:start!)
2076
- # We can't easily test this without actually starting a server, so we just verify the method exists
2077
- end
2078
- end
2079
-
2080
- describe ".batch_test_storage" do
2081
- it "initializes storage hash if nil" do
2082
- original_storage = DecisionAgent::Web::Server.instance_variable_get(:@batch_test_storage)
2083
- DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage, nil)
2084
-
2085
- storage = DecisionAgent::Web::Server.batch_test_storage
2086
- expect(storage).to be_a(Hash)
2087
-
2088
- DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage, original_storage)
2089
- end
2090
- end
2091
-
2092
- describe ".batch_test_storage_mutex" do
2093
- it "initializes mutex if nil" do
2094
- original_mutex = DecisionAgent::Web::Server.instance_variable_get(:@batch_test_storage_mutex)
2095
- DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage_mutex, nil)
2096
-
2097
- mutex = DecisionAgent::Web::Server.batch_test_storage_mutex
2098
- expect(mutex).to be_a(Mutex)
2099
-
2100
- DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage_mutex, original_mutex)
2101
- end
2102
- end
2103
-
2104
- describe ".authenticator=" do
2105
- it "allows setting custom authenticator" do
2106
- original_auth = DecisionAgent::Web::Server.authenticator
2107
- custom_auth = double("Authenticator")
2108
- DecisionAgent::Web::Server.authenticator = custom_auth
2109
- expect(DecisionAgent::Web::Server.authenticator).to eq(custom_auth)
2110
- DecisionAgent::Web::Server.authenticator = original_auth
2111
- end
2112
- end
2113
-
2114
- describe ".permission_checker=" do
2115
- it "allows setting custom permission checker" do
2116
- original_checker = DecisionAgent::Web::Server.permission_checker
2117
- custom_checker = double("PermissionChecker")
2118
- DecisionAgent::Web::Server.permission_checker = custom_checker
2119
- expect(DecisionAgent::Web::Server.permission_checker).to eq(custom_checker)
2120
- DecisionAgent::Web::Server.permission_checker = original_checker
2121
- end
2122
- end
2123
-
2124
- describe ".access_audit_logger=" do
2125
- it "allows setting custom access audit logger" do
2126
- original_logger = DecisionAgent::Web::Server.access_audit_logger
2127
- custom_logger = double("AccessAuditLogger")
2128
- DecisionAgent::Web::Server.access_audit_logger = custom_logger
2129
- expect(DecisionAgent::Web::Server.access_audit_logger).to eq(custom_logger)
2130
- DecisionAgent::Web::Server.access_audit_logger = original_logger
2131
- end
2132
- end
2133
- end
2134
- end