decision_agent 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +84 -233
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +37 -9
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +59 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/examples.txt +1542 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +221 -3
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- metadata +123 -6
data/spec/web_ui_rack_spec.rb
CHANGED
|
@@ -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
|