Files
Aegis/backend/tests/test_campaigns_and_snapshots.py
T
kitos cf4a6c3cde
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
- Approve endpoint now only creates Jira tickets immediately when
  start_date is now/past; a new periodic job (every 15 min) catches
  campaigns whose scheduled start_date has since arrived.
- Recurring campaign clones now go to pending_approval instead of
  active, routing through the same manager-approval gate as any other
  campaign; managers are notified instead of red_tech.
- Fix UTC conversion for the campaign approval start_date input and
  extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
2026-07-16 11:09:10 +02:00

549 lines
20 KiB
Python

"""Tests for campaigns, snapshots, and re-testing — T-237.
Uses the in-memory SQLite test database from conftest.py.
"""
import uuid
from datetime import datetime, timedelta
import pytest
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.campaign import Campaign, CampaignTest
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.enums import TestState, TestResult, TechniqueStatus
from app.services.campaign_service import (
validate_no_circular_dependency,
get_campaign_progress,
)
from app.services.campaign_scheduler_service import (
calculate_next_run,
check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
)
from app.services.snapshot_service import (
create_snapshot,
compare_snapshots,
cleanup_old_snapshots,
)
from app.services.test_workflow_service import (
handle_remediation_completed,
get_retest_chain,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def techniques(db):
"""Create a set of techniques for testing."""
techs = []
for mid, name, status in [
("T1059", "Command Line", TechniqueStatus.validated),
("T1078", "Valid Accounts", TechniqueStatus.partial),
("T1053", "Scheduled Tasks", TechniqueStatus.not_covered),
]:
tech = Technique(
mitre_id=mid,
name=name,
tactic="execution",
platforms=["windows"],
status_global=status,
)
db.add(tech)
techs.append(tech)
db.commit()
for t in techs:
db.refresh(t)
return techs
@pytest.fixture
def campaign_with_tests(db, techniques, admin_user):
"""Create a campaign with ordered tests."""
campaign = Campaign(
name="Test Campaign",
type="custom",
status="draft",
created_by=admin_user.id,
)
db.add(campaign)
db.flush()
tests = []
for i, tech in enumerate(techniques):
test = Test(
technique_id=tech.id,
name=f"Test for {tech.mitre_id}",
state=TestState.draft,
created_by=admin_user.id,
)
db.add(test)
db.flush()
tests.append(test)
ct = CampaignTest(
campaign_id=campaign.id,
test_id=test.id,
order_index=i,
phase="execution",
)
db.add(ct)
db.commit()
db.refresh(campaign)
return {"campaign": campaign, "tests": tests}
# ═══════════════════════════════════════════════════════════════════════
# Campaign Tests
# ═══════════════════════════════════════════════════════════════════════
class TestCampaigns:
def test_create_campaign_with_tests(self, db, campaign_with_tests):
"""CRUD básico de campaña con tests ordenados."""
campaign = campaign_with_tests["campaign"]
assert campaign.name == "Test Campaign"
assert campaign.status == "draft"
cts = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign.id)
.order_by(CampaignTest.order_index)
.all()
)
assert len(cts) == 3
assert cts[0].order_index == 0
assert cts[1].order_index == 1
assert cts[2].order_index == 2
def test_campaign_progress_calculation(self, db, campaign_with_tests):
"""Progreso se calcula según estado de tests."""
campaign = campaign_with_tests["campaign"]
tests = campaign_with_tests["tests"]
# Initially all draft → 0% complete
progress = get_campaign_progress(db, campaign.id)
assert progress["total"] == 3
assert progress["completion_pct"] == 0.0
# Validate one test
tests[0].state = TestState.validated
db.commit()
progress = get_campaign_progress(db, campaign.id)
assert progress["completion_pct"] == pytest.approx(33.3, abs=0.1)
def test_circular_dependency_prevention(self, db, campaign_with_tests):
"""Intentar crear dependencia circular en campaign_tests falla."""
from app.domain.exceptions import InvalidOperationError
campaign = campaign_with_tests["campaign"]
cts = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign.id)
.order_by(CampaignTest.order_index)
.all()
)
# Create A -> B dependency
cts[1].depends_on = cts[0].id
db.commit()
# Try to create B -> A (circular)
with pytest.raises(InvalidOperationError) as exc_info:
validate_no_circular_dependency(
db, campaign.id, cts[0].id, cts[1].id
)
assert exc_info.value.code == "INVALID_OPERATION"
def test_campaign_scheduling_next_run(self):
"""next_run_at se calcula correctamente para weekly/monthly/quarterly."""
base = datetime(2026, 1, 1)
weekly = calculate_next_run(base, "weekly")
assert weekly == datetime(2026, 1, 8)
monthly = calculate_next_run(base, "monthly")
assert monthly == datetime(2026, 1, 31)
quarterly = calculate_next_run(base, "quarterly")
assert quarterly == datetime(2026, 4, 1)
def test_campaign_cloning(self, db, campaign_with_tests, admin_user):
"""Clonación de campaña recurrente crea tests nuevos con datos correctos."""
campaign = campaign_with_tests["campaign"]
original_tests = campaign_with_tests["tests"]
# Set up as recurring
campaign.is_recurring = True
campaign.recurrence_pattern = "monthly"
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1) # Due now
db.commit()
# Run the scheduler
spawned = check_and_run_recurring_campaigns(db)
assert spawned == 1
# Find child campaign
child = (
db.query(Campaign)
.filter(Campaign.parent_campaign_id == campaign.id)
.first()
)
assert child is not None
assert "Run" in child.name
# Recurrence only automates spawning the run — it still needs a
# manager's approval like any other campaign, same as the manual
# draft -> submit -> approve path.
assert child.status == "pending_approval"
# Check child tests are fresh copies (new IDs, draft state)
child_cts = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == child.id)
.all()
)
assert len(child_cts) == len(original_tests)
child_test_ids = {ct.test_id for ct in child_cts}
original_test_ids = {t.id for t in original_tests}
assert child_test_ids.isdisjoint(original_test_ids) # All new IDs
for ct in child_cts:
test = db.query(Test).filter(Test.id == ct.test_id).first()
assert test.state == TestState.draft
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
"""Managers must hear about a spawned recurring run — it's sitting in
their approval queue just like a manually-submitted campaign."""
from app.models.notification import Notification
campaign = campaign_with_tests["campaign"]
campaign.is_recurring = True
campaign.recurrence_pattern = "monthly"
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
check_and_run_recurring_campaigns(db)
notif = (
db.query(Notification)
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
.first()
)
assert notif is not None
# Check parent was updated
db.refresh(campaign)
assert campaign.last_run_at is not None
assert campaign.next_run_at > datetime.utcnow()
class TestDueCampaignJiraSync:
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
campaigns approved with a future start_date whose Jira tickets were
deliberately skipped at approval time."""
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
from unittest.mock import patch
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
db.commit()
with patch(
"app.services.jira_service.get_campaign_jira_key", return_value=None
) as mock_get_key, patch(
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
) as mock_create_campaign, patch(
"app.services.jira_service.get_test_jira_key", return_value=None
), patch(
"app.services.jira_service.auto_create_test_issue"
) as mock_create_test:
processed = sync_due_campaign_jira_tickets(db)
assert processed == 1
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() + timedelta(days=30)
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
from app.models.jira_link import JiraLink, JiraLinkEntityType
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
db.add(JiraLink(
entity_type=JiraLinkEntityType.campaign,
entity_id=campaign.id,
jira_issue_key="PT-1",
jira_project_key="PT",
created_by=admin_user.id,
))
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
# ═══════════════════════════════════════════════════════════════════════
# Snapshot Tests
# ═══════════════════════════════════════════════════════════════════════
class TestSnapshots:
def test_create_snapshot(self, db, techniques, admin_user):
"""Snapshot captura estado actual correctamente."""
snapshot = create_snapshot(db, name="Test Snapshot", user_id=admin_user.id)
assert snapshot is not None
assert snapshot.name == "Test Snapshot"
assert snapshot.total_techniques == len(techniques)
assert snapshot.created_by == admin_user.id
assert snapshot.organization_score >= 0
# Verify per-technique states
states = (
db.query(SnapshotTechniqueState)
.filter(SnapshotTechniqueState.snapshot_id == snapshot.id)
.all()
)
assert len(states) == len(techniques)
mitre_ids = {s.mitre_id for s in states}
assert "T1059" in mitre_ids
assert "T1078" in mitre_ids
assert "T1053" in mitre_ids
def test_compare_snapshots_improvements(self, db, techniques, admin_user):
"""Comparación detecta técnicas que mejoraron."""
# Create snapshot A
snap_a = create_snapshot(db, name="Before")
# Improve a technique
tech = db.query(Technique).filter(Technique.mitre_id == "T1053").first()
tech.status_global = TechniqueStatus.validated
db.commit()
# Create snapshot B
snap_b = create_snapshot(db, name="After")
result = compare_snapshots(db, snap_a.id, snap_b.id)
assert result["score_delta"] is not None
assert result["summary"]["improved_count"] >= 0
assert isinstance(result["improved"], list)
assert isinstance(result["worsened"], list)
assert result["unchanged_count"] >= 0
def test_compare_snapshots_regressions(self, db, techniques, admin_user):
"""Comparación detecta técnicas que empeoraron."""
# Create snapshot A
snap_a = create_snapshot(db, name="Before Regression")
# Worsen a technique
tech = db.query(Technique).filter(Technique.mitre_id == "T1059").first()
tech.status_global = TechniqueStatus.not_covered
db.commit()
snap_b = create_snapshot(db, name="After Regression")
result = compare_snapshots(db, snap_a.id, snap_b.id)
assert result["summary"]["worsened_count"] >= 0
def test_snapshot_cleanup(self, db, techniques, admin_user):
"""Cleanup mantiene solo los últimos N snapshots."""
# Create 5 snapshots
for i in range(5):
create_snapshot(db, name=f"Snapshot {i}")
total_before = db.query(CoverageSnapshot).count()
assert total_before == 5
# Cleanup keeping only 3
deleted = cleanup_old_snapshots(db, keep_last=3)
assert deleted == 2
total_after = db.query(CoverageSnapshot).count()
assert total_after == 3
def test_snapshot_normalized_storage(self, db, techniques, admin_user):
"""Verificar que el almacenamiento normalizado funciona correctamente."""
snapshot = create_snapshot(db, name="Normalized Check")
# Each technique should have exactly one SnapshotTechniqueState row
for tech in techniques:
states = (
db.query(SnapshotTechniqueState)
.filter(
SnapshotTechniqueState.snapshot_id == snapshot.id,
SnapshotTechniqueState.technique_id == tech.id,
)
.all()
)
assert len(states) == 1
state = states[0]
assert state.mitre_id == tech.mitre_id
assert state.status is not None
# ═══════════════════════════════════════════════════════════════════════
# Re-testing Tests
# ═══════════════════════════════════════════════════════════════════════
class TestRetesting:
def test_retest_created_on_remediation(self, db, techniques, admin_user):
"""Completar remediación crea retest automáticamente."""
test = Test(
technique_id=techniques[0].id,
name="Original Test",
state=TestState.validated,
remediation_status="completed",
created_by=admin_user.id,
)
db.add(test)
db.commit()
db.refresh(test)
retest = handle_remediation_completed(db, test, admin_user)
assert retest is not None
assert retest.retest_of == test.id
assert retest.retest_count == 1
assert retest.state == TestState.draft
assert retest.technique_id == test.technique_id
def test_retest_points_to_original(self, db, techniques, admin_user):
"""Retest de un retest apunta al test original, no al intermedio."""
original = Test(
technique_id=techniques[0].id,
name="Original",
state=TestState.validated,
remediation_status="completed",
created_by=admin_user.id,
retest_count=0,
)
db.add(original)
db.commit()
db.refresh(original)
# First retest
retest1 = handle_remediation_completed(db, original, admin_user)
assert retest1 is not None
assert retest1.retest_of == original.id
# Simulate completing remediation on retest1
retest1.state = TestState.validated
retest1.remediation_status = "completed"
db.commit()
db.refresh(retest1)
# Second retest — should point to ORIGINAL, not retest1
retest2 = handle_remediation_completed(db, retest1, admin_user)
assert retest2 is not None
assert retest2.retest_of == original.id # Points to original!
assert retest2.retest_count == 2
def test_retest_max_limit(self, db, techniques, admin_user):
"""Al alcanzar MAX_RETEST_COUNT no se crea retest."""
from app.config import settings
test = Test(
technique_id=techniques[0].id,
name="Max Retests Test",
state=TestState.validated,
remediation_status="completed",
created_by=admin_user.id,
retest_count=settings.MAX_RETEST_COUNT, # Already at max
)
db.add(test)
db.commit()
db.refresh(test)
result = handle_remediation_completed(db, test, admin_user)
assert result is None # No retest created
def test_retest_chain_query(self, db, techniques, admin_user):
"""Endpoint /tests/{id}/retest-chain retorna cadena completa."""
original = Test(
technique_id=techniques[0].id,
name="Chain Original",
state=TestState.validated,
remediation_status="completed",
created_by=admin_user.id,
)
db.add(original)
db.commit()
db.refresh(original)
retest1 = handle_remediation_completed(db, original, admin_user)
assert retest1 is not None
# Complete retest1 and trigger another
retest1.state = TestState.validated
retest1.remediation_status = "completed"
db.commit()
db.refresh(retest1)
retest2 = handle_remediation_completed(db, retest1, admin_user)
assert retest2 is not None
# Get chain
chain = get_retest_chain(db, original.id)
assert len(chain) == 3 # original + retest1 + retest2
assert chain[0].id == original.id
assert chain[1].retest_count == 1
assert chain[2].retest_count == 2
def test_retest_has_correct_data(self, db, techniques, admin_user):
"""Retest tiene mismos datos base que el original."""
original = Test(
technique_id=techniques[0].id,
name="Data Check Original",
description="Test description",
platform="windows",
procedure_text="Run cmd /c whoami",
tool_used="cmd.exe",
state=TestState.validated,
remediation_status="completed",
created_by=admin_user.id,
)
db.add(original)
db.commit()
db.refresh(original)
retest = handle_remediation_completed(db, original, admin_user)
assert retest is not None
# Verify base data is copied
assert retest.technique_id == original.technique_id
assert retest.description == original.description
assert retest.platform == original.platform
assert retest.procedure_text == original.procedure_text
assert retest.tool_used == original.tool_used
assert retest.created_by == original.created_by
assert retest.state == TestState.draft