decision_agent 0.1.4 → 0.1.7

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -232
  3. data/bin/decision_agent +1 -1
  4. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
  5. data/lib/decision_agent/agent.rb +5 -3
  6. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  7. data/lib/decision_agent/auth/authenticator.rb +127 -0
  8. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  9. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  10. data/lib/decision_agent/auth/permission.rb +29 -0
  11. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  12. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  13. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  14. data/lib/decision_agent/auth/role.rb +56 -0
  15. data/lib/decision_agent/auth/session.rb +33 -0
  16. data/lib/decision_agent/auth/session_manager.rb +57 -0
  17. data/lib/decision_agent/auth/user.rb +70 -0
  18. data/lib/decision_agent/context.rb +24 -4
  19. data/lib/decision_agent/decision.rb +10 -3
  20. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  21. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  22. data/lib/decision_agent/errors.rb +38 -0
  23. data/lib/decision_agent/evaluation.rb +10 -3
  24. data/lib/decision_agent/evaluation_validator.rb +8 -13
  25. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  26. data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
  27. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  28. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  29. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  30. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  31. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  32. data/lib/decision_agent/version.rb +10 -1
  33. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  34. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  35. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  36. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  37. data/lib/decision_agent/web/public/app.js +184 -29
  38. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  39. data/lib/decision_agent/web/public/index.html +38 -10
  40. data/lib/decision_agent/web/public/login.html +298 -0
  41. data/lib/decision_agent/web/public/users.html +679 -0
  42. data/lib/decision_agent/web/server.rb +873 -7
  43. data/lib/decision_agent.rb +52 -0
  44. data/lib/generators/decision_agent/install/templates/README +1 -1
  45. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  46. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  47. data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
  48. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  49. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  50. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  51. data/spec/advanced_operators_spec.rb +1003 -0
  52. data/spec/agent_spec.rb +40 -0
  53. data/spec/audit_adapters_spec.rb +18 -0
  54. data/spec/auth/access_audit_logger_spec.rb +394 -0
  55. data/spec/auth/authenticator_spec.rb +112 -0
  56. data/spec/auth/password_reset_spec.rb +294 -0
  57. data/spec/auth/permission_checker_spec.rb +207 -0
  58. data/spec/auth/permission_spec.rb +73 -0
  59. data/spec/auth/rbac_adapter_spec.rb +550 -0
  60. data/spec/auth/rbac_config_spec.rb +82 -0
  61. data/spec/auth/role_spec.rb +51 -0
  62. data/spec/auth/session_manager_spec.rb +172 -0
  63. data/spec/auth/session_spec.rb +112 -0
  64. data/spec/auth/user_spec.rb +130 -0
  65. data/spec/context_spec.rb +43 -0
  66. data/spec/decision_agent_spec.rb +96 -0
  67. data/spec/decision_spec.rb +423 -0
  68. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  69. data/spec/evaluation_spec.rb +364 -0
  70. data/spec/evaluation_validator_spec.rb +165 -0
  71. data/spec/monitoring/metrics_collector_spec.rb +220 -2
  72. data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
  73. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  74. data/spec/performance_optimizations_spec.rb +486 -0
  75. data/spec/spec_helper.rb +23 -0
  76. data/spec/testing/batch_test_importer_spec.rb +693 -0
  77. data/spec/testing/batch_test_runner_spec.rb +307 -0
  78. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  79. data/spec/testing/test_result_comparator_spec.rb +392 -0
  80. data/spec/testing/test_scenario_spec.rb +113 -0
  81. data/spec/versioning/adapter_spec.rb +156 -0
  82. data/spec/versioning_spec.rb +253 -0
  83. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  84. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  85. data/spec/web_ui_rack_spec.rb +1705 -0
  86. metadata +103 -11
  87. data/spec/examples.txt +0 -612
@@ -112,6 +112,117 @@ RSpec.describe "DecisionAgent Web UI Rack Integration" do
112
112
  end
113
113
  end
114
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
+
115
226
  describe "Rails mounting compatibility" do
116
227
  it "can be mounted in a Rack app" do
117
228
  # Simulate a Rails-style mount
@@ -132,4 +243,1598 @@ RSpec.describe "DecisionAgent Web UI Rack Integration" do
132
243
  expect(json["status"]).to eq("ok")
133
244
  end
134
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 "Batch Testing API comprehensive tests" do
1443
+ describe "POST /api/testing/batch/import" do
1444
+ it "handles Excel file import" do
1445
+ skip "Roo gem not available" unless defined?(Roo)
1446
+
1447
+ # Create a minimal Excel file for testing
1448
+ # Since we can't easily create Excel files in tests, we'll test the error path
1449
+ file = Tempfile.new(["test", ".xlsx"])
1450
+ file.write("not excel content")
1451
+ file.rewind
1452
+
1453
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") }, { "CONTENT_TYPE" => "multipart/form-data" }
1454
+
1455
+ # Should handle error gracefully
1456
+ expect([400, 422, 500]).to include(last_response.status)
1457
+
1458
+ file.close
1459
+ file.unlink
1460
+ end
1461
+
1462
+ it "handles file with no extension" do
1463
+ file = Tempfile.new(["test", ""])
1464
+ file.write("id,user_id\n")
1465
+ file.rewind
1466
+
1467
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/plain") }, { "CONTENT_TYPE" => "multipart/form-data" }
1468
+
1469
+ # Should treat as CSV or handle error
1470
+ expect([201, 400, 422, 500]).to include(last_response.status)
1471
+
1472
+ file.close
1473
+ file.unlink
1474
+ end
1475
+ end
1476
+
1477
+ describe "POST /api/testing/batch/run" do
1478
+ it "handles batch test execution with options" do
1479
+ csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1480
+ file = Tempfile.new(["test", ".csv"])
1481
+ file.write(csv_content)
1482
+ file.rewind
1483
+
1484
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1485
+ import_json = JSON.parse(last_response.body)
1486
+ test_id = import_json["test_id"]
1487
+
1488
+ rules_json = {
1489
+ version: "1.0",
1490
+ ruleset: "test",
1491
+ rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1492
+ }
1493
+
1494
+ post "/api/testing/batch/run",
1495
+ { test_id: test_id, rules: rules_json, options: { parallel: false, thread_count: 1 } }.to_json,
1496
+ { "CONTENT_TYPE" => "application/json" }
1497
+
1498
+ expect(last_response.status).to eq(200)
1499
+ json = JSON.parse(last_response.body)
1500
+ expect(json["status"]).to eq("completed")
1501
+
1502
+ file.close
1503
+ file.unlink
1504
+ end
1505
+
1506
+ it "handles execution errors and updates status" do
1507
+ csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1508
+ file = Tempfile.new(["test", ".csv"])
1509
+ file.write(csv_content)
1510
+ file.rewind
1511
+
1512
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1513
+ import_json = JSON.parse(last_response.body)
1514
+ test_id = import_json["test_id"]
1515
+
1516
+ # Use invalid rules to cause an error
1517
+ invalid_rules = { version: "1.0", ruleset: "test", rules: "invalid" }
1518
+
1519
+ post "/api/testing/batch/run",
1520
+ { test_id: test_id, rules: invalid_rules }.to_json,
1521
+ { "CONTENT_TYPE" => "application/json" }
1522
+
1523
+ expect(last_response.status).to eq(500)
1524
+ json = JSON.parse(last_response.body)
1525
+ expect(json["error"]).to be_present
1526
+
1527
+ # Verify status was updated to failed
1528
+ get "/api/testing/batch/#{test_id}/results"
1529
+ result_json = JSON.parse(last_response.body)
1530
+ expect(result_json["status"]).to eq("failed")
1531
+
1532
+ file.close
1533
+ file.unlink
1534
+ end
1535
+ end
1536
+
1537
+ describe "GET /api/testing/batch/:id/results" do
1538
+ it "returns full results with all fields" do
1539
+ csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1540
+ file = Tempfile.new(["test", ".csv"])
1541
+ file.write(csv_content)
1542
+ file.rewind
1543
+
1544
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1545
+ import_json = JSON.parse(last_response.body)
1546
+ test_id = import_json["test_id"]
1547
+
1548
+ rules_json = {
1549
+ version: "1.0",
1550
+ ruleset: "test",
1551
+ rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1552
+ }
1553
+
1554
+ post "/api/testing/batch/run", { test_id: test_id, rules: rules_json }.to_json, { "CONTENT_TYPE" => "application/json" }
1555
+
1556
+ get "/api/testing/batch/#{test_id}/results"
1557
+ expect(last_response.status).to eq(200)
1558
+ json = JSON.parse(last_response.body)
1559
+ expect(json["test_id"]).to eq(test_id)
1560
+ expect(json["status"]).to eq("completed")
1561
+ expect(json).to have_key("results")
1562
+ expect(json).to have_key("statistics")
1563
+
1564
+ file.close
1565
+ file.unlink
1566
+ end
1567
+
1568
+ it "handles server errors" do
1569
+ allow(DecisionAgent::Web::Server.batch_test_storage_mutex).to receive(:synchronize).and_raise(StandardError.new("Storage error"))
1570
+
1571
+ get "/api/testing/batch/test123/results"
1572
+ expect(last_response.status).to eq(500)
1573
+ end
1574
+ end
1575
+
1576
+ describe "GET /api/testing/batch/:id/coverage" do
1577
+ it "returns coverage report when available" do
1578
+ csv_content = "id,user_id,amount\ntest_1,123,1000\n"
1579
+ file = Tempfile.new(["test", ".csv"])
1580
+ file.write(csv_content)
1581
+ file.rewind
1582
+
1583
+ post "/api/testing/batch/import", { file: Rack::Test::UploadedFile.new(file.path, "text/csv") }, { "CONTENT_TYPE" => "multipart/form-data" }
1584
+ import_json = JSON.parse(last_response.body)
1585
+ test_id = import_json["test_id"]
1586
+
1587
+ rules_json = {
1588
+ version: "1.0",
1589
+ ruleset: "test",
1590
+ rules: [{ id: "rule_1", if: { field: "amount", op: "gt", value: 500 }, then: { decision: "approve", weight: 0.9, reason: "Test" } }]
1591
+ }
1592
+
1593
+ post "/api/testing/batch/run", { test_id: test_id, rules: rules_json }.to_json, { "CONTENT_TYPE" => "application/json" }
1594
+
1595
+ get "/api/testing/batch/#{test_id}/coverage"
1596
+ expect(last_response.status).to eq(200)
1597
+ json = JSON.parse(last_response.body)
1598
+ expect(json["test_id"]).to eq(test_id)
1599
+ expect(json).to have_key("coverage")
1600
+
1601
+ file.close
1602
+ file.unlink
1603
+ end
1604
+
1605
+ it "handles server errors" do
1606
+ allow(DecisionAgent::Web::Server.batch_test_storage_mutex).to receive(:synchronize).and_raise(StandardError.new("Storage error"))
1607
+
1608
+ get "/api/testing/batch/test123/coverage"
1609
+ expect(last_response.status).to eq(500)
1610
+ end
1611
+ end
1612
+
1613
+ describe "GET /testing/batch, /auth/login, /auth/users" do
1614
+ it "handles missing files gracefully" do
1615
+ # Stub send_file to raise error
1616
+ allow_any_instance_of(DecisionAgent::Web::Server).to receive(:send_file).and_raise(StandardError.new("File not found"))
1617
+
1618
+ get "/testing/batch"
1619
+ expect(last_response.status).to eq(404)
1620
+ expect(last_response.body).to include("Batch testing page not found")
1621
+
1622
+ get "/auth/login"
1623
+ expect(last_response.status).to eq(404)
1624
+ expect(last_response.body).to include("Login page not found")
1625
+
1626
+ get "/auth/users"
1627
+ expect(last_response.status).to eq(404)
1628
+ expect(last_response.body).to include("User management page not found")
1629
+ end
1630
+ end
1631
+ end
1632
+
1633
+ describe "Auth and permission edge cases" do
1634
+ let(:authenticator) { DecisionAgent::Web::Server.authenticator }
1635
+
1636
+ describe "require_permission!" do
1637
+ it "denies access and logs permission check" do
1638
+ authenticator.create_user(
1639
+ email: "noperm@example.com",
1640
+ password: "password123",
1641
+ roles: []
1642
+ )
1643
+ session = authenticator.login("noperm@example.com", "password123")
1644
+
1645
+ get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
1646
+ expect(last_response.status).to eq(403)
1647
+ json = JSON.parse(last_response.body)
1648
+ expect(json["error"]).to include("Permission denied")
1649
+ end
1650
+
1651
+ it "requires authentication before checking permissions" do
1652
+ get "/api/auth/roles", {}, {}
1653
+ # May return 401 (no auth) or 403 (auth but no permission)
1654
+ expect([401, 403]).to include(last_response.status)
1655
+ end
1656
+
1657
+ it "handles audit logger failures gracefully" do
1658
+ authenticator.create_user(
1659
+ email: "loggerfail@example.com",
1660
+ password: "password123",
1661
+ roles: []
1662
+ )
1663
+ session = authenticator.login("loggerfail@example.com", "password123")
1664
+
1665
+ # Simulate audit logger failure
1666
+ allow_any_instance_of(DecisionAgent::Auth::AccessAuditLogger).to receive(:log_permission_check).and_raise(StandardError.new("Logger error"))
1667
+
1668
+ # Should still deny permission even if logging fails
1669
+ get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{session.token}" }
1670
+ expect(last_response.status).to eq(403)
1671
+ json = JSON.parse(last_response.body)
1672
+ expect(json["error"]).to include("Permission denied")
1673
+ end
1674
+
1675
+ it "handles audit logger failures when permission is granted" do
1676
+ authenticator.create_user(
1677
+ email: "loggerfail2@example.com",
1678
+ password: "password123",
1679
+ roles: [:admin]
1680
+ )
1681
+ admin_session = authenticator.login("loggerfail2@example.com", "password123")
1682
+
1683
+ # Simulate audit logger failure after permission check passes
1684
+ allow_any_instance_of(DecisionAgent::Auth::AccessAuditLogger).to receive(:log_permission_check).and_raise(StandardError.new("Logger error"))
1685
+
1686
+ # Should still allow access even if logging fails
1687
+ get "/api/auth/roles", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_session.token}" }
1688
+ expect(last_response.status).to eq(200)
1689
+ end
1690
+ end
1691
+
1692
+ describe "extract_token" do
1693
+ it "extracts token from cookie" do
1694
+ authenticator.create_user(
1695
+ email: "cookieuser@example.com",
1696
+ password: "password123"
1697
+ )
1698
+ session = authenticator.login("cookieuser@example.com", "password123")
1699
+
1700
+ get "/api/auth/me", {}, { "HTTP_COOKIE" => "decision_agent_session=#{session.token}" }
1701
+ expect(last_response).to be_ok
1702
+ json = JSON.parse(last_response.body)
1703
+ expect(json["email"]).to eq("cookieuser@example.com")
1704
+ end
1705
+
1706
+ it "extracts token from query parameter" do
1707
+ authenticator.create_user(
1708
+ email: "queryuser@example.com",
1709
+ password: "password123"
1710
+ )
1711
+ session = authenticator.login("queryuser@example.com", "password123")
1712
+
1713
+ get "/api/auth/me?token=#{session.token}"
1714
+ expect(last_response).to be_ok
1715
+ json = JSON.parse(last_response.body)
1716
+ expect(json["email"]).to eq("queryuser@example.com")
1717
+ end
1718
+
1719
+ it "prefers Authorization header over cookie" do
1720
+ authenticator.create_user(
1721
+ email: "prefuser@example.com",
1722
+ password: "password123"
1723
+ )
1724
+ session1 = authenticator.login("prefuser@example.com", "password123")
1725
+ authenticator.create_user(
1726
+ email: "prefuser2@example.com",
1727
+ password: "password123"
1728
+ )
1729
+ session2 = authenticator.login("prefuser2@example.com", "password123")
1730
+
1731
+ get "/api/auth/me",
1732
+ {},
1733
+ { "HTTP_AUTHORIZATION" => "Bearer #{session1.token}", "HTTP_COOKIE" => "decision_agent_session=#{session2.token}" }
1734
+ expect(last_response).to be_ok
1735
+ json = JSON.parse(last_response.body)
1736
+ expect(json["email"]).to eq("prefuser@example.com")
1737
+ end
1738
+ end
1739
+
1740
+ describe "POST /api/auth/logout" do
1741
+ it "handles logout with invalid token gracefully" do
1742
+ post "/api/auth/logout", {}, { "HTTP_AUTHORIZATION" => "Bearer invalid_token" }
1743
+ expect(last_response).to be_ok
1744
+ json = JSON.parse(last_response.body)
1745
+ expect(json["success"]).to be true
1746
+ end
1747
+ end
1748
+
1749
+ describe "GET /api/auth/audit" do
1750
+ let(:admin_user) do
1751
+ user = authenticator.create_user(
1752
+ email: "auditadmin2@example.com",
1753
+ password: "password123",
1754
+ roles: [:admin]
1755
+ )
1756
+ session = authenticator.login("auditadmin2@example.com", "password123")
1757
+ { user: user, session: session }
1758
+ end
1759
+
1760
+ it "filters by multiple parameters" do
1761
+ get "/api/auth/audit?user_id=#{admin_user[:user].id}&event_type=login&limit=10",
1762
+ {},
1763
+ { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
1764
+ expect(last_response).to be_ok
1765
+ json = JSON.parse(last_response.body)
1766
+ expect(json).to be_an(Array)
1767
+ end
1768
+
1769
+ it "handles server errors" do
1770
+ allow(DecisionAgent::Web::Server.access_audit_logger).to receive(:query).and_raise(StandardError.new("DB error"))
1771
+
1772
+ get "/api/auth/audit", {}, { "HTTP_AUTHORIZATION" => "Bearer #{admin_user[:session].token}" }
1773
+ expect(last_response.status).to eq(500)
1774
+ end
1775
+ end
1776
+ end
1777
+
1778
+ describe "Class methods" do
1779
+ describe ".start!" do
1780
+ it "sets port and bind" do
1781
+ expect(DecisionAgent::Web::Server).to respond_to(:start!)
1782
+ # We can't easily test this without actually starting a server, so we just verify the method exists
1783
+ end
1784
+ end
1785
+
1786
+ describe ".batch_test_storage" do
1787
+ it "initializes storage hash if nil" do
1788
+ original_storage = DecisionAgent::Web::Server.instance_variable_get(:@batch_test_storage)
1789
+ DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage, nil)
1790
+
1791
+ storage = DecisionAgent::Web::Server.batch_test_storage
1792
+ expect(storage).to be_a(Hash)
1793
+
1794
+ DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage, original_storage)
1795
+ end
1796
+ end
1797
+
1798
+ describe ".batch_test_storage_mutex" do
1799
+ it "initializes mutex if nil" do
1800
+ original_mutex = DecisionAgent::Web::Server.instance_variable_get(:@batch_test_storage_mutex)
1801
+ DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage_mutex, nil)
1802
+
1803
+ mutex = DecisionAgent::Web::Server.batch_test_storage_mutex
1804
+ expect(mutex).to be_a(Mutex)
1805
+
1806
+ DecisionAgent::Web::Server.instance_variable_set(:@batch_test_storage_mutex, original_mutex)
1807
+ end
1808
+ end
1809
+
1810
+ describe ".authenticator=" do
1811
+ it "allows setting custom authenticator" do
1812
+ original_auth = DecisionAgent::Web::Server.authenticator
1813
+ custom_auth = double("Authenticator")
1814
+ DecisionAgent::Web::Server.authenticator = custom_auth
1815
+ expect(DecisionAgent::Web::Server.authenticator).to eq(custom_auth)
1816
+ DecisionAgent::Web::Server.authenticator = original_auth
1817
+ end
1818
+ end
1819
+
1820
+ describe ".permission_checker=" do
1821
+ it "allows setting custom permission checker" do
1822
+ original_checker = DecisionAgent::Web::Server.permission_checker
1823
+ custom_checker = double("PermissionChecker")
1824
+ DecisionAgent::Web::Server.permission_checker = custom_checker
1825
+ expect(DecisionAgent::Web::Server.permission_checker).to eq(custom_checker)
1826
+ DecisionAgent::Web::Server.permission_checker = original_checker
1827
+ end
1828
+ end
1829
+
1830
+ describe ".access_audit_logger=" do
1831
+ it "allows setting custom access audit logger" do
1832
+ original_logger = DecisionAgent::Web::Server.access_audit_logger
1833
+ custom_logger = double("AccessAuditLogger")
1834
+ DecisionAgent::Web::Server.access_audit_logger = custom_logger
1835
+ expect(DecisionAgent::Web::Server.access_audit_logger).to eq(custom_logger)
1836
+ DecisionAgent::Web::Server.access_audit_logger = original_logger
1837
+ end
1838
+ end
1839
+ end
135
1840
  end