feat(tests): require evidence upload before phase transitions
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend: - submit_red_evidence: raises InvalidOperationError if no Red Team evidence file has been uploaded for the test - submit_blue_evidence: raises InvalidOperationError if no Blue Team evidence file has been uploaded Frontend: - 'Submit to Blue Team' button: disabled + '⚠ Upload evidence first' hint when test.red_evidences is empty - 'Submit for Review' button: same for test.blue_evidences - Native tooltip on disabled buttons explains the requirement - Buttons re-enable automatically after the first file is uploaded Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
||||||
from app.domain.test_entity import TestEntity
|
from app.domain.test_entity import TestEntity
|
||||||
from app.models.enums import TestState
|
from app.models.enums import TestState, TeamSide
|
||||||
|
from app.models.evidence import Evidence
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
@@ -146,9 +147,21 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""Move from ``red_executing`` → ``blue_evaluating``.
|
"""Move from ``red_executing`` → ``blue_evaluating``.
|
||||||
|
|
||||||
Called by **red_tech** once they have finished documenting the attack.
|
Called by **red_tech** once they have finished documenting the attack.
|
||||||
|
Requires at least one Red Team evidence file to be uploaded.
|
||||||
Stops the Red Team timer and creates an automatic worklog.
|
Stops the Red Team timer and creates an automatic worklog.
|
||||||
Starts the Blue Team timer by recording ``blue_started_at``.
|
Starts the Blue Team timer by recording ``blue_started_at``.
|
||||||
"""
|
"""
|
||||||
|
# Evidence is mandatory before submitting
|
||||||
|
red_evidence_count = (
|
||||||
|
db.query(Evidence)
|
||||||
|
.filter(Evidence.test_id == test.id, Evidence.team == TeamSide.red)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if red_evidence_count == 0:
|
||||||
|
raise InvalidOperationError(
|
||||||
|
"Cannot submit to Blue Team: at least one Red Team evidence file must be uploaded first."
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
# Auto-resume if paused
|
# Auto-resume if paused
|
||||||
@@ -224,10 +237,22 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""Move from ``blue_evaluating`` → ``in_review``.
|
"""Move from ``blue_evaluating`` → ``in_review``.
|
||||||
|
|
||||||
Called by **blue_tech** once they have finished documenting detection.
|
Called by **blue_tech** once they have finished documenting detection.
|
||||||
|
Requires at least one Blue Team evidence file to be uploaded.
|
||||||
Stops the Blue Team timer and creates an automatic worklog.
|
Stops the Blue Team timer and creates an automatic worklog.
|
||||||
Uses blue_work_started_at as the phase start for Tempo if available,
|
Uses blue_work_started_at as the phase start for Tempo if available,
|
||||||
otherwise falls back to blue_started_at (queue-entry timestamp).
|
otherwise falls back to blue_started_at (queue-entry timestamp).
|
||||||
"""
|
"""
|
||||||
|
# Evidence is mandatory before submitting
|
||||||
|
blue_evidence_count = (
|
||||||
|
db.query(Evidence)
|
||||||
|
.filter(Evidence.test_id == test.id, Evidence.team == TeamSide.blue)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if blue_evidence_count == 0:
|
||||||
|
raise InvalidOperationError(
|
||||||
|
"Cannot submit for review: at least one Blue Team evidence file must be uploaded first."
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
# Auto-resume if paused
|
# Auto-resume if paused
|
||||||
|
|||||||
@@ -111,21 +111,27 @@ export default function TestDetailHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Red Team in red_executing -> Submit to Blue Team
|
// Red Team in red_executing -> Submit to Blue Team (requires ≥1 red evidence)
|
||||||
if (
|
if (
|
||||||
test.state === "red_executing" &&
|
test.state === "red_executing" &&
|
||||||
(role === "red_tech" || role === "red_lead" || role === "admin")
|
(role === "red_tech" || role === "red_lead" || role === "admin")
|
||||||
) {
|
) {
|
||||||
|
const hasRedEvidence = (test.red_evidences?.length ?? 0) > 0;
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<button
|
<div key="submit-red" className="flex flex-col items-end gap-1">
|
||||||
key="submit-red"
|
<button
|
||||||
onClick={onSubmitRed}
|
onClick={onSubmitRed}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning || !hasRedEvidence}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
|
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
|
||||||
>
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
>
|
||||||
Submit to Blue Team
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
</button>,
|
Submit to Blue Team
|
||||||
|
</button>
|
||||||
|
{!hasRedEvidence && (
|
||||||
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,16 +155,23 @@ export default function TestDetailHeader({
|
|||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Submit for Review requires ≥1 blue evidence
|
||||||
|
const hasBlueEvidence = (test.blue_evidences?.length ?? 0) > 0;
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<button
|
<div key="submit-blue" className="flex flex-col items-end gap-1">
|
||||||
key="submit-blue"
|
<button
|
||||||
onClick={onSubmitBlue}
|
onClick={onSubmitBlue}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning || !hasBlueEvidence}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
|
title={!hasBlueEvidence ? "Upload at least one Blue Team evidence file before submitting" : undefined}
|
||||||
>
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
>
|
||||||
Submit for Review
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
</button>,
|
Submit for Review
|
||||||
|
</button>
|
||||||
|
{!hasBlueEvidence && (
|
||||||
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user