- Add test_test_entity.py with 46 pure unit tests covering the full domain entity - Fix _FakeSettings in 11 test files (REPORT_TEMPLATES_DIR, JIRA, TEMPO) - Fix stale db.commit assertions to db.flush after UoW refactor - Add missing mock fields for TestEntity.from_orm compatibility - Make database.py skip pool args for SQLite in test environment - Disable slowapi rate limiter in test client fixture - Inject test engine into app.database to fix threading errors - Update role assertions to match current require_any_role policy - Mark 6 legacy V1 endpoint tests as xfail (replaced by V2 workflow)
720 lines
26 KiB
Python
720 lines
26 KiB
Python
"""T-134: Final integration tests for V2 — end-to-end flows.
|
|
|
|
Covers:
|
|
- Full E2E flow: import template -> create test -> execute -> evaluate -> validate
|
|
- Rejection/recovery flow
|
|
- Notification generation during state changes
|
|
- Metrics accuracy after operations
|
|
- Report generation
|
|
- Remediation field management
|
|
|
|
Uses mock objects to test the workflow service and router logic
|
|
without requiring a running database.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import uuid
|
|
import inspect
|
|
from unittest.mock import MagicMock, patch
|
|
from types import ModuleType
|
|
from datetime import datetime, timedelta
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub heavy dependencies before importing app modules
|
|
# ---------------------------------------------------------------------------
|
|
|
|
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
if backend_dir not in sys.path:
|
|
sys.path.insert(0, backend_dir)
|
|
|
|
if "pydantic_settings" not in sys.modules:
|
|
_ps = ModuleType("pydantic_settings")
|
|
class _BaseSettings:
|
|
def __init__(self, **kwargs): pass
|
|
def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs)
|
|
_ps.BaseSettings = _BaseSettings
|
|
sys.modules["pydantic_settings"] = _ps
|
|
|
|
if "app.config" not in sys.modules:
|
|
_cfg = ModuleType("app.config")
|
|
class _FakeSettings:
|
|
DATABASE_URL = "sqlite:///:memory:"
|
|
SECRET_KEY = "test"
|
|
ALGORITHM = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
|
MINIO_ENDPOINT = "localhost:9000"
|
|
MINIO_ACCESS_KEY = "test"
|
|
MINIO_SECRET_KEY = "test"
|
|
MINIO_BUCKET = "test"
|
|
REPORT_TEMPLATES_DIR = "app/templates/reports"
|
|
REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
|
COMPANY_NAME = "Test Org"
|
|
COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
|
|
JIRA_ENABLED = False
|
|
JIRA_URL = ""
|
|
JIRA_USERNAME = ""
|
|
JIRA_API_TOKEN = ""
|
|
JIRA_IS_CLOUD = True
|
|
JIRA_DEFAULT_PROJECT = ""
|
|
JIRA_ISSUE_TYPE_TEST = "Task"
|
|
JIRA_ISSUE_TYPE_CAMPAIGN = "Epic"
|
|
TEMPO_ENABLED = False
|
|
TEMPO_API_TOKEN = ""
|
|
TEMPO_DEFAULT_WORK_TYPE = "Red Team"
|
|
NVD_API_KEY = ""
|
|
STALE_THRESHOLD_DAYS = 365
|
|
CORS_ORIGINS = "http://localhost:3000"
|
|
SCORING_WEIGHT_TESTS = 40
|
|
SCORING_WEIGHT_DETECTION_RULES = 20
|
|
SCORING_WEIGHT_D3FEND = 15
|
|
SCORING_WEIGHT_FRESHNESS = 15
|
|
SCORING_WEIGHT_PLATFORM_DIVERSITY = 10
|
|
_cfg.settings = _FakeSettings()
|
|
sys.modules["app.config"] = _cfg
|
|
|
|
if "app.database" not in sys.modules:
|
|
_db = ModuleType("app.database")
|
|
_db.Base = type("Base", (), {"metadata": MagicMock()})
|
|
_db.get_db = MagicMock()
|
|
_db.SessionLocal = MagicMock()
|
|
sys.modules["app.database"] = _db
|
|
|
|
# Stub jose with JWTError
|
|
if "jose" not in sys.modules:
|
|
_jose = ModuleType("jose")
|
|
class _JWTError(Exception): pass
|
|
_jose.JWTError = _JWTError
|
|
_jose.jwt = MagicMock()
|
|
sys.modules["jose"] = _jose
|
|
|
|
# Stub apscheduler
|
|
for _mod in ["apscheduler", "apscheduler.schedulers", "apscheduler.triggers", "apscheduler.triggers.cron"]:
|
|
if _mod not in sys.modules:
|
|
sys.modules[_mod] = ModuleType(_mod)
|
|
|
|
if "apscheduler.schedulers.background" not in sys.modules:
|
|
_apsched = ModuleType("apscheduler.schedulers.background")
|
|
class _FakeBGScheduler:
|
|
def add_job(self, *a, **kw): pass
|
|
def start(self): pass
|
|
def shutdown(self, **kw): pass
|
|
_apsched.BackgroundScheduler = _FakeBGScheduler
|
|
sys.modules["apscheduler.schedulers.background"] = _apsched
|
|
|
|
if "taxii2client" not in sys.modules:
|
|
sys.modules["taxii2client"] = ModuleType("taxii2client")
|
|
if "taxii2client.v20" not in sys.modules:
|
|
_tv20 = ModuleType("taxii2client.v20")
|
|
_tv20.Server = MagicMock
|
|
_tv20.Collection = MagicMock
|
|
sys.modules["taxii2client.v20"] = _tv20
|
|
|
|
for _mod in [
|
|
"boto3", "botocore", "botocore.exceptions",
|
|
"passlib", "passlib.context",
|
|
]:
|
|
if _mod not in sys.modules:
|
|
sys.modules[_mod] = ModuleType(_mod)
|
|
|
|
# Now safe to import
|
|
from app.models.enums import TestState, TestResult, TechniqueStatus
|
|
from app.services.test_workflow_service import (
|
|
can_transition,
|
|
VALID_TRANSITIONS,
|
|
transition_state,
|
|
start_execution,
|
|
submit_red_evidence,
|
|
submit_blue_evidence,
|
|
validate_as_red_lead,
|
|
validate_as_blue_lead,
|
|
check_dual_validation,
|
|
reopen_test,
|
|
)
|
|
from app.services.notification_service import (
|
|
create_notification,
|
|
mark_as_read,
|
|
mark_all_as_read,
|
|
get_unread_count,
|
|
cleanup_old_notifications,
|
|
notify_test_state_change,
|
|
)
|
|
|
|
passed = 0
|
|
failed = 0
|
|
|
|
|
|
def _make_test(**overrides):
|
|
"""Create a mock Test object with sensible defaults."""
|
|
t = MagicMock()
|
|
t.id = overrides.get("id", uuid.uuid4())
|
|
t.name = overrides.get("name", "Integration Test")
|
|
t.technique_id = overrides.get("technique_id", uuid.uuid4())
|
|
t.created_by = overrides.get("created_by", uuid.uuid4())
|
|
t.state = overrides.get("state", TestState.draft)
|
|
t.red_validation_status = overrides.get("red_validation_status", None)
|
|
t.blue_validation_status = overrides.get("blue_validation_status", None)
|
|
t.red_validated_by = None
|
|
t.red_validated_at = None
|
|
t.red_validation_notes = None
|
|
t.blue_validated_by = None
|
|
t.blue_validated_at = None
|
|
t.blue_validation_notes = None
|
|
t.attack_success = None
|
|
t.detection_result = None
|
|
t.remediation_steps = None
|
|
t.remediation_status = None
|
|
t.remediation_assignee = None
|
|
for k, v in overrides.items():
|
|
setattr(t, k, v)
|
|
return t
|
|
|
|
|
|
def _make_user(role="admin"):
|
|
u = MagicMock()
|
|
u.id = uuid.uuid4()
|
|
u.role = role
|
|
u.is_active = True
|
|
u.username = f"test_{role}"
|
|
return u
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 1 — Full E2E happy path through workflow
|
|
# ===========================================================================
|
|
|
|
|
|
def test_full_e2e_flow():
|
|
"""Full lifecycle: draft → red_executing → blue_evaluating → in_review → validated"""
|
|
global passed, failed
|
|
try:
|
|
db = MagicMock()
|
|
test = _make_test(state=TestState.draft)
|
|
red_tech = _make_user("red_tech")
|
|
blue_tech = _make_user("blue_tech")
|
|
red_lead = _make_user("red_lead")
|
|
blue_lead = _make_user("blue_lead")
|
|
|
|
# draft -> red_executing
|
|
assert can_transition(test, TestState.red_executing)
|
|
test.state = TestState.red_executing
|
|
|
|
# red_executing -> blue_evaluating
|
|
assert can_transition(test, TestState.blue_evaluating)
|
|
test.state = TestState.blue_evaluating
|
|
|
|
# blue_evaluating -> in_review
|
|
assert can_transition(test, TestState.in_review)
|
|
test.state = TestState.in_review
|
|
|
|
# Both leads approve → validated
|
|
test.red_validation_status = "approved"
|
|
test.blue_validation_status = "approved"
|
|
check_dual_validation(db, test)
|
|
assert test.state == TestState.validated
|
|
|
|
print(" PASS: test_full_e2e_flow")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_full_e2e_flow — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 2 — Rejection and recovery flow
|
|
# ===========================================================================
|
|
|
|
|
|
def test_rejection_recovery_flow():
|
|
"""in_review → rejected → draft → start over"""
|
|
global passed, failed
|
|
try:
|
|
db = MagicMock()
|
|
test = _make_test(state=TestState.in_review)
|
|
|
|
# Red lead rejects
|
|
test.red_validation_status = "rejected"
|
|
test.blue_validation_status = None
|
|
check_dual_validation(db, test)
|
|
assert test.state == TestState.rejected
|
|
|
|
# Reopen: rejected → draft
|
|
assert can_transition(test, TestState.draft)
|
|
test.state = TestState.draft
|
|
test.red_validation_status = None
|
|
test.blue_validation_status = None
|
|
|
|
# Can restart: draft → red_executing
|
|
assert can_transition(test, TestState.red_executing)
|
|
|
|
print(" PASS: test_rejection_recovery_flow")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_rejection_recovery_flow — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 3 — Notification dispatch on state changes
|
|
# ===========================================================================
|
|
|
|
|
|
def test_notification_dispatching():
|
|
"""Verify notifications are dispatched for key state changes."""
|
|
global passed, failed
|
|
try:
|
|
db = MagicMock()
|
|
test = _make_test(state=TestState.blue_evaluating)
|
|
|
|
# Check the function can call create_notification
|
|
src = inspect.getsource(notify_test_state_change)
|
|
assert "blue_evaluating" in src, "Should handle blue_evaluating state"
|
|
assert "in_review" in src, "Should handle in_review state"
|
|
assert "rejected" in src, "Should handle rejected state"
|
|
assert "validated" in src, "Should handle validated state"
|
|
assert "create_notification" in src, "Should call create_notification"
|
|
assert "blue_tech" in src, "Should notify blue_tech users"
|
|
assert "red_lead" in src or "blue_lead" in src, "Should notify leads"
|
|
|
|
print(" PASS: test_notification_dispatching")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_notification_dispatching — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 4 — Notification cleanup service
|
|
# ===========================================================================
|
|
|
|
|
|
def test_notification_cleanup():
|
|
"""cleanup_old_notifications deletes read notifications older than cutoff."""
|
|
global passed, failed
|
|
try:
|
|
src = inspect.getsource(cleanup_old_notifications)
|
|
assert "timedelta" in src, "Should use timedelta for cutoff"
|
|
assert "read" in src.lower(), "Should filter by read status"
|
|
assert "delete" in src, "Should call delete()"
|
|
|
|
print(" PASS: test_notification_cleanup")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_notification_cleanup — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 5 — Metrics endpoints exist
|
|
# ===========================================================================
|
|
|
|
|
|
def test_metrics_endpoints_exist():
|
|
"""Verify V2 metrics endpoints are registered."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers import metrics
|
|
src = inspect.getsource(metrics)
|
|
assert "test-pipeline" in src, "Should have /metrics/test-pipeline"
|
|
assert "team-activity" in src, "Should have /metrics/team-activity"
|
|
assert "validation-rate" in src, "Should have /metrics/validation-rate"
|
|
assert "recent-tests" in src, "Should have /metrics/recent-tests"
|
|
|
|
print(" PASS: test_metrics_endpoints_exist")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_metrics_endpoints_exist — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 6 — Reports endpoints exist
|
|
# ===========================================================================
|
|
|
|
|
|
def test_reports_endpoints_exist():
|
|
"""Verify report endpoints are registered."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers import reports
|
|
src = inspect.getsource(reports)
|
|
assert "coverage-summary" in src, "Should have /reports/coverage-summary"
|
|
assert "coverage-csv" in src, "Should have /reports/coverage-csv"
|
|
assert "test-results" in src, "Should have /reports/test-results"
|
|
assert "remediation-status" in src, "Should have /reports/remediation-status"
|
|
assert "StreamingResponse" in src, "Should use StreamingResponse for CSV"
|
|
|
|
print(" PASS: test_reports_endpoints_exist")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_reports_endpoints_exist — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 7 — Report filtering
|
|
# ===========================================================================
|
|
|
|
|
|
def test_report_filtering_logic():
|
|
"""Reports support tactic, platform, state and date filters."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers import reports
|
|
src = inspect.getsource(reports)
|
|
assert "tactic" in src, "Should filter by tactic"
|
|
assert "platform" in src, "Should filter by platform"
|
|
assert "date_from" in src, "Should filter by date_from"
|
|
assert "date_to" in src, "Should filter by date_to"
|
|
assert "remediation_status" in src, "Should filter remediation by status"
|
|
|
|
print(" PASS: test_report_filtering_logic")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_report_filtering_logic — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 8 — Remediation fields in Test model
|
|
# ===========================================================================
|
|
|
|
|
|
def test_remediation_fields():
|
|
"""Test model includes remediation_steps, remediation_status, remediation_assignee."""
|
|
global passed, failed
|
|
try:
|
|
from app.models.test import Test
|
|
src = inspect.getsource(Test)
|
|
assert "remediation_steps" in src, "Should have remediation_steps"
|
|
assert "remediation_status" in src, "Should have remediation_status"
|
|
assert "remediation_assignee" in src, "Should have remediation_assignee"
|
|
|
|
print(" PASS: test_remediation_fields")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_remediation_fields — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 9 — Template suggested_remediation field
|
|
# ===========================================================================
|
|
|
|
|
|
def test_template_suggested_remediation():
|
|
"""TestTemplate has suggested_remediation and it's passed on instantiation."""
|
|
global passed, failed
|
|
try:
|
|
from app.models.test_template import TestTemplate
|
|
src = inspect.getsource(TestTemplate)
|
|
assert "suggested_remediation" in src, "Should have suggested_remediation"
|
|
|
|
from app.routers.tests import create_test_from_template
|
|
src2 = inspect.getsource(create_test_from_template)
|
|
assert "suggested_remediation" in src2 or "remediation_steps" in src2, \
|
|
"from-template endpoint should copy remediation"
|
|
|
|
print(" PASS: test_template_suggested_remediation")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_template_suggested_remediation — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 10 — Remediation endpoint exists in router
|
|
# ===========================================================================
|
|
|
|
|
|
def test_remediation_endpoint():
|
|
"""PATCH /tests/{id}/remediation exists."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers.tests import update_remediation
|
|
src = inspect.getsource(update_remediation)
|
|
assert "remediation" in src.lower(), "Should handle remediation fields"
|
|
|
|
print(" PASS: test_remediation_endpoint")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_remediation_endpoint — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 11 — Notifications model
|
|
# ===========================================================================
|
|
|
|
|
|
def test_notification_model():
|
|
"""Notification model has required fields and indexes."""
|
|
global passed, failed
|
|
try:
|
|
from app.models.notification import Notification
|
|
src = inspect.getsource(Notification)
|
|
assert "user_id" in src, "Should have user_id"
|
|
assert "type" in src, "Should have type"
|
|
assert "title" in src, "Should have title"
|
|
assert "message" in src, "Should have message"
|
|
assert "entity_type" in src, "Should have entity_type"
|
|
assert "entity_id" in src, "Should have entity_id"
|
|
assert "read" in src, "Should have read"
|
|
assert "ix_notifications_user_id" in src, "Should have user_id index"
|
|
assert "ix_notifications_read" in src, "Should have read index"
|
|
|
|
print(" PASS: test_notification_model")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_notification_model — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 12 — Notification endpoints exist
|
|
# ===========================================================================
|
|
|
|
|
|
def test_notification_endpoints():
|
|
"""Notification router has list, unread-count, mark-read, read-all."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers import notifications
|
|
src = inspect.getsource(notifications)
|
|
assert "unread-count" in src, "Should have /unread-count"
|
|
assert "read-all" in src, "Should have /read-all"
|
|
assert "mark_as_read" in src, "Should call mark_as_read"
|
|
assert "mark_all_as_read" in src, "Should call mark_all_as_read"
|
|
assert "get_unread_count" in src, "Should call get_unread_count"
|
|
|
|
print(" PASS: test_notification_endpoints")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_notification_endpoints — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 13 — Error responses include structured detail
|
|
# ===========================================================================
|
|
|
|
|
|
def test_structured_error_responses():
|
|
"""Workflow errors include code and valid_transitions."""
|
|
global passed, failed
|
|
try:
|
|
src = inspect.getsource(transition_state)
|
|
assert "INVALID_TRANSITION" in src, "Should include INVALID_TRANSITION code"
|
|
assert "valid_transitions" in src, "Should include valid_transitions list"
|
|
assert "current_state" in src, "Should include current_state"
|
|
|
|
print(" PASS: test_structured_error_responses")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_structured_error_responses — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 14 — Workflow integration triggers notifications
|
|
# ===========================================================================
|
|
|
|
|
|
def test_workflow_triggers_notifications():
|
|
"""transition_state calls notify_test_state_change."""
|
|
global passed, failed
|
|
try:
|
|
src = inspect.getsource(transition_state)
|
|
assert "notify_test_state_change" in src, "Should call notify_test_state_change"
|
|
|
|
# Notifications are best-effort (wrapped in try/except)
|
|
assert "except" in src, "Notification errors should be caught"
|
|
|
|
print(" PASS: test_workflow_triggers_notifications")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_workflow_triggers_notifications — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 15 — Scheduler includes notification cleanup
|
|
# ===========================================================================
|
|
|
|
|
|
def test_scheduler_has_notification_cleanup():
|
|
"""Background scheduler includes notification cleanup job."""
|
|
global passed, failed
|
|
try:
|
|
from app.jobs import mitre_sync_job
|
|
src = inspect.getsource(mitre_sync_job)
|
|
assert "notification_cleanup" in src, "Should register notification_cleanup job"
|
|
assert "cleanup_old_notifications" in src, "Should import cleanup_old_notifications"
|
|
|
|
print(" PASS: test_scheduler_has_notification_cleanup")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_scheduler_has_notification_cleanup — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 16 — Sidebar navigation includes Reports
|
|
# ===========================================================================
|
|
|
|
|
|
def test_navigation_includes_reports():
|
|
"""Frontend App.tsx registers /reports route."""
|
|
global passed, failed
|
|
try:
|
|
app_path = os.path.join(
|
|
os.path.dirname(__file__), "..", "..", "frontend", "src", "App.tsx"
|
|
)
|
|
if os.path.exists(app_path):
|
|
with open(app_path) as f:
|
|
content = f.read()
|
|
assert "/reports" in content, "App.tsx should have /reports route"
|
|
assert "ReportsPage" in content, "App.tsx should import ReportsPage"
|
|
else:
|
|
# If running from a different CWD, just check the router module
|
|
pass
|
|
|
|
print(" PASS: test_navigation_includes_reports")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_navigation_includes_reports — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 17 — Coverage CSV export
|
|
# ===========================================================================
|
|
|
|
|
|
def test_coverage_csv_export():
|
|
"""Report router has CSV endpoint with StreamingResponse."""
|
|
global passed, failed
|
|
try:
|
|
from app.routers.reports import coverage_csv
|
|
src = inspect.getsource(coverage_csv)
|
|
assert "csv" in src, "Should use csv module"
|
|
assert "StreamingResponse" in src or "text/csv" in src, "Should set CSV content type"
|
|
assert "Content-Disposition" in src, "Should set download filename"
|
|
|
|
print(" PASS: test_coverage_csv_export")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_coverage_csv_export — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 18 — Dual validation logic completeness
|
|
# ===========================================================================
|
|
|
|
|
|
def test_dual_validation_all_scenarios():
|
|
"""Test all 4 possible dual validation outcomes."""
|
|
global passed, failed
|
|
try:
|
|
db = MagicMock()
|
|
|
|
# Scenario 1: both approved -> validated
|
|
t1 = _make_test(state=TestState.in_review)
|
|
t1.red_validation_status = "approved"
|
|
t1.blue_validation_status = "approved"
|
|
check_dual_validation(db, t1)
|
|
assert t1.state == TestState.validated
|
|
|
|
# Scenario 2: red rejected -> rejected
|
|
t2 = _make_test(state=TestState.in_review)
|
|
t2.red_validation_status = "rejected"
|
|
t2.blue_validation_status = None
|
|
check_dual_validation(db, t2)
|
|
assert t2.state == TestState.rejected
|
|
|
|
# Scenario 3: blue rejected -> rejected
|
|
t3 = _make_test(state=TestState.in_review)
|
|
t3.red_validation_status = "approved"
|
|
t3.blue_validation_status = "rejected"
|
|
check_dual_validation(db, t3)
|
|
assert t3.state == TestState.rejected
|
|
|
|
# Scenario 4: one approved, other pending -> stays in_review
|
|
t4 = _make_test(state=TestState.in_review)
|
|
t4.red_validation_status = "approved"
|
|
t4.blue_validation_status = None
|
|
check_dual_validation(db, t4)
|
|
assert t4.state == TestState.in_review
|
|
|
|
print(" PASS: test_dual_validation_all_scenarios")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_dual_validation_all_scenarios — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 19 — All V2 routers registered in main.py
|
|
# ===========================================================================
|
|
|
|
|
|
def test_all_routers_registered():
|
|
"""main.py includes all V2 routers."""
|
|
global passed, failed
|
|
try:
|
|
main_path = os.path.join(os.path.dirname(__file__), "..", "app", "main.py")
|
|
with open(main_path) as f:
|
|
content = f.read()
|
|
|
|
for router_name in [
|
|
"notifications", "reports", "tests", "test_templates",
|
|
"metrics", "evidence", "auth", "techniques", "system",
|
|
"users", "audit",
|
|
]:
|
|
assert router_name in content, f"main.py should include {router_name} router"
|
|
|
|
print(" PASS: test_all_routers_registered")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_all_routers_registered — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# TEST 20 — Notification mark-all-as-read service
|
|
# ===========================================================================
|
|
|
|
|
|
def test_mark_all_as_read_service():
|
|
"""mark_all_as_read updates all unread notifications for a user."""
|
|
global passed, failed
|
|
try:
|
|
src = inspect.getsource(mark_all_as_read)
|
|
assert "read" in src.lower(), "Should filter by read status"
|
|
assert "update" in src, "Should call update()"
|
|
assert "commit" in src, "Should commit changes"
|
|
|
|
print(" PASS: test_mark_all_as_read_service")
|
|
passed += 1
|
|
except Exception as e:
|
|
print(f" FAIL: test_mark_all_as_read_service — {e}")
|
|
failed += 1
|
|
|
|
|
|
# ===========================================================================
|
|
# Run all
|
|
# ===========================================================================
|
|
|
|
|
|
if __name__ == "__main__":
|
|
tests = [fn for name, fn in globals().items() if name.startswith("test_") and callable(fn)]
|
|
print(f"\nRunning {len(tests)} integration V2 tests...\n")
|
|
for fn in tests:
|
|
fn()
|
|
print(f"\n{'='*50}")
|
|
print(f"Results: {passed} passed, {failed} failed out of {passed + failed}")
|
|
if failed > 0:
|
|
sys.exit(1)
|
|
print("All integration V2 tests passed!")
|