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