fix(backend): resolve refresh-token expiry deadlock, missing Jira on normal campaign approval, discarded threat-actor campaign start_date

- /auth/refresh now allows a short grace window past expiry and checks
  the blacklist, so an active session's silent refresh no longer fails
  the instant its own token expires
- normal manager /approve flow now creates Jira tickets for the campaign
  and its already-linked tests, matching the admin-only /activate path
- GenerateFromActorPayload now accepts start_date and threads it through
  to the new campaign instead of silently discarding it
This commit is contained in:
kitos
2026-07-07 13:42:26 +02:00
parent fefe70baed
commit bd26b09827
6 changed files with 236 additions and 31 deletions
+62
View File
@@ -1,7 +1,23 @@
"""Tests for authentication endpoints."""
import uuid
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from app.config import settings
def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str:
"""Build a raw JWT mirroring ``create_access_token`` with a controllable exp."""
if expired_seconds_ago is None:
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
else:
expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago)
payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def test_login_success(client, admin_user):
"""Test successful login returns a token."""
@@ -122,3 +138,49 @@ def test_logout_revokes_token(client, admin_user):
)
assert me.status_code == 401
assert me.json()["detail"] == "Token has been revoked"
def test_refresh_without_cookie_fails(client):
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
def test_refresh_valid_token_succeeds(client, admin_user):
token = _make_token("admin")
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 200
assert "access_token" in response.json()
def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user):
"""A token that expired moments ago must still be refreshable.
This is the core of the refresh-token bug fix: without a grace window,
`/auth/refresh` decodes with the same strict expiry check as every
other endpoint, so a 401-triggered refresh attempt always also fails.
"""
token = _make_token("admin", expired_seconds_ago=60)
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 200
assert "access_token" in response.json()
def test_refresh_long_expired_token_fails(client, admin_user):
token = _make_token("admin", expired_seconds_ago=60 * 60)
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user):
from app.auth import blacklist_token
token = _make_token("admin")
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
blacklist_token(payload["jti"], float(payload["exp"]))
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
@@ -1,5 +1,7 @@
"""Router-level tests for the campaign manager-approval workflow."""
from unittest.mock import patch
from app.models.campaign import Campaign, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
@@ -51,6 +53,30 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
assert body["start_date"] is not None
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
"""The normal manager-approval path must create Jira tickets too, not just
the admin-only emergency /activate override — this was the Block 1 gap:
campaigns approved through the standard flow never got a Jira ticket."""
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
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-100") 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:
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 200
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
mock_create_test.assert_called_once()
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
@@ -0,0 +1,72 @@
"""Tests for generating a campaign from a threat actor's uncovered techniques.
Covers the Block 1 fix: the frontend sends a `start_date` when generating a
campaign from a Threat Actor, but `GenerateFromActorPayload` used to be an
empty schema, so the date was silently discarded and the campaign was
created with `start_date = NULL`.
"""
from datetime import datetime
from app.models.enums import TechniqueStatus
from app.models.technique import Technique
from app.models.test_template import TestTemplate
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.services.campaign_service import generate_campaign_from_threat_actor
def _seed_actor_with_gap_technique(db):
tech = Technique(
mitre_id="T1059.001",
name="PowerShell",
tactic="execution",
platforms=["windows"],
status_global=TechniqueStatus.not_evaluated,
)
db.add(tech)
db.flush()
template = TestTemplate(
mitre_technique_id=tech.mitre_id,
name="PowerShell template",
source="custom",
severity="high",
is_active=True,
)
db.add(template)
actor = ThreatActor(name="APT-Test", mitre_id="G9999")
db.add(actor)
db.flush()
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
db.commit()
db.refresh(actor)
return actor
def test_generate_from_actor_without_start_date_leaves_it_null(db, red_lead_user):
actor = _seed_actor_with_gap_technique(db)
campaign = generate_campaign_from_threat_actor(db, actor.id, red_lead_user)
assert campaign.start_date is None
def test_generate_from_actor_persists_start_date(db, red_lead_user):
actor = _seed_actor_with_gap_technique(db)
start_date = datetime(2026, 9, 1)
campaign = generate_campaign_from_threat_actor(
db, actor.id, red_lead_user, start_date=start_date,
)
assert campaign.start_date == start_date
def test_generate_from_actor_router_forwards_start_date(api, db, red_lead_user, red_lead_headers):
actor = _seed_actor_with_gap_technique(db)
resp = api(
"post",
f"/api/v1/campaigns/from-threat-actor/{actor.id}",
red_lead_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["start_date"] is not None