refactor(evidence): extract permission validation and queries to evidence_service, use domain exceptions
This commit is contained in:
@@ -24,52 +24,32 @@ import os
|
|||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies.auth import get_current_user
|
from app.dependencies.auth import get_current_user
|
||||||
from app.models.enums import TeamSide, TestState
|
from app.models.enums import TeamSide
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
from app.models.test import Test
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.evidence import EvidenceOut
|
from app.schemas.evidence import EvidenceOut
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
|
from app.services.evidence_service import (
|
||||||
|
get_evidence_or_raise,
|
||||||
|
get_test_or_raise,
|
||||||
|
list_evidence_for_test,
|
||||||
|
MAX_UPLOAD_SIZE,
|
||||||
|
validate_delete_permission,
|
||||||
|
validate_file,
|
||||||
|
validate_upload_permission,
|
||||||
|
)
|
||||||
from app.storage import get_presigned_url, upload_file
|
from app.storage import get_presigned_url, upload_file
|
||||||
|
|
||||||
router = APIRouter(tags=["evidence"])
|
router = APIRouter(tags=["evidence"])
|
||||||
|
|
||||||
# States where red evidence can be uploaded / deleted
|
|
||||||
_RED_EDITABLE_STATES = (TestState.draft, TestState.red_executing)
|
|
||||||
# States where blue evidence can be uploaded / deleted
|
|
||||||
_BLUE_EDITABLE_STATES = (TestState.blue_evaluating,)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Upload safety limits
|
# Helpers (router-specific: infrastructure / HTTP concerns)
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Maximum upload size in bytes (default 50 MB)
|
|
||||||
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
|
||||||
|
|
||||||
# Allowed file extensions (lowercase, with leading dot)
|
|
||||||
_ALLOWED_EXTENSIONS: set[str] = {
|
|
||||||
# Images / screenshots
|
|
||||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
|
|
||||||
# Documents
|
|
||||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt",
|
|
||||||
".md", ".rtf", ".odt", ".ods",
|
|
||||||
# Logs & captures
|
|
||||||
".log", ".pcap", ".pcapng", ".evtx", ".json", ".xml",
|
|
||||||
".yaml", ".yml", ".toml",
|
|
||||||
# Archives (for bundled evidence)
|
|
||||||
".zip", ".tar", ".gz", ".7z",
|
|
||||||
# Other common evidence types
|
|
||||||
".har", ".eml", ".msg",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
||||||
@@ -87,85 +67,6 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_upload_permission(
|
|
||||||
test: Test,
|
|
||||||
team: TeamSide,
|
|
||||||
user: User,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 403 if the user/team combination is not allowed in the current state."""
|
|
||||||
# Admins bypass all checks
|
|
||||||
if user.role == "admin":
|
|
||||||
return
|
|
||||||
|
|
||||||
if team == TeamSide.red:
|
|
||||||
if user.role not in ("red_tech", "red_lead"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Only red_tech, red_lead or admin can upload red evidence",
|
|
||||||
)
|
|
||||||
if test.state not in _RED_EDITABLE_STATES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Cannot upload red evidence in '{test.state.value}' state "
|
|
||||||
f"(allowed in: draft, red_executing)",
|
|
||||||
)
|
|
||||||
elif team == TeamSide.blue:
|
|
||||||
if user.role not in ("blue_tech", "blue_lead"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Only blue_tech, blue_lead or admin can upload blue evidence",
|
|
||||||
)
|
|
||||||
if test.state not in _BLUE_EDITABLE_STATES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Cannot upload blue evidence in '{test.state.value}' state "
|
|
||||||
f"(allowed in: blue_evaluating)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_delete_permission(
|
|
||||||
test: Test,
|
|
||||||
evidence: Evidence,
|
|
||||||
user: User,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 403 if the user cannot delete this evidence in the current state."""
|
|
||||||
# No deletions in review / validated / rejected
|
|
||||||
if test.state in (TestState.in_review, TestState.validated, TestState.rejected):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Cannot delete evidence when test is in '{test.state.value}' state",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Admin can delete in editable states
|
|
||||||
if user.role == "admin":
|
|
||||||
return
|
|
||||||
|
|
||||||
ev_team = evidence.team
|
|
||||||
|
|
||||||
if ev_team == TeamSide.red:
|
|
||||||
if test.state not in _RED_EDITABLE_STATES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Cannot delete red evidence outside draft/red_executing",
|
|
||||||
)
|
|
||||||
if user.role not in ("red_tech", "red_lead") and evidence.uploaded_by != user.id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Not enough permissions to delete this evidence",
|
|
||||||
)
|
|
||||||
elif ev_team == TeamSide.blue:
|
|
||||||
if test.state not in _BLUE_EDITABLE_STATES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Cannot delete blue evidence outside blue_evaluating",
|
|
||||||
)
|
|
||||||
if user.role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user.id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Not enough permissions to delete this evidence",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{test_id}/evidence — upload with team
|
# POST /tests/{test_id}/evidence — upload with team
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -189,36 +90,14 @@ async def upload_evidence(
|
|||||||
The ``team`` field (sent as form data) determines whether this is
|
The ``team`` field (sent as form data) determines whether this is
|
||||||
Red Team (attack) or Blue Team (detection) evidence.
|
Red Team (attack) or Blue Team (detection) evidence.
|
||||||
"""
|
"""
|
||||||
test = db.query(Test).filter(Test.id == test_id).first()
|
test = get_test_or_raise(db, test_id)
|
||||||
if test is None:
|
validate_upload_permission(test, team, current_user.role)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Test not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate permissions
|
|
||||||
_validate_upload_permission(test, team, current_user)
|
|
||||||
|
|
||||||
# 1. Validate file extension
|
|
||||||
file_name = file.filename or "unnamed"
|
file_name = file.filename or "unnamed"
|
||||||
_, ext = os.path.splitext(file_name)
|
content = await file.read(MAX_UPLOAD_SIZE + 1)
|
||||||
if ext.lower() not in _ALLOWED_EXTENSIONS:
|
validate_file(file_name, len(content))
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"File type '{ext}' is not allowed. "
|
|
||||||
f"Permitted types: {', '.join(sorted(_ALLOWED_EXTENSIONS))}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Read content with size limit
|
# Hash
|
||||||
content = await file.read(_MAX_UPLOAD_SIZE + 1)
|
|
||||||
if len(content) > _MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
||||||
detail=f"File exceeds maximum upload size of "
|
|
||||||
f"{_MAX_UPLOAD_SIZE // (1024 * 1024)} MB",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Hash
|
|
||||||
sha256 = hashlib.sha256(content).hexdigest()
|
sha256 = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
# 4. Object key (sanitise filename to prevent path traversal in storage)
|
# 4. Object key (sanitise filename to prevent path traversal in storage)
|
||||||
@@ -273,19 +152,8 @@ def list_evidence(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List all evidences for a test, optionally filtered by team."""
|
"""List all evidences for a test, optionally filtered by team."""
|
||||||
test = db.query(Test).filter(Test.id == test_id).first()
|
get_test_or_raise(db, test_id)
|
||||||
if test is None:
|
evidences = list_evidence_for_test(db, test_id, team=team)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Test not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
query = db.query(Evidence).filter(Evidence.test_id == test_id)
|
|
||||||
|
|
||||||
if team:
|
|
||||||
query = query.filter(Evidence.team == team)
|
|
||||||
|
|
||||||
evidences = query.order_by(Evidence.uploaded_at.desc()).all()
|
|
||||||
return [_evidence_to_out(e) for e in evidences]
|
return [_evidence_to_out(e) for e in evidences]
|
||||||
|
|
||||||
|
|
||||||
@@ -301,13 +169,7 @@ def get_evidence(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return evidence metadata together with a presigned download URL."""
|
"""Return evidence metadata together with a presigned download URL."""
|
||||||
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
|
evidence = get_evidence_or_raise(db, evidence_id)
|
||||||
if evidence is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Evidence not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
return _evidence_to_out(evidence)
|
return _evidence_to_out(evidence)
|
||||||
|
|
||||||
|
|
||||||
@@ -329,22 +191,9 @@ def delete_evidence(
|
|||||||
- Blue evidence: ``blue_evaluating``
|
- Blue evidence: ``blue_evaluating``
|
||||||
- No deletions in ``in_review``, ``validated``, ``rejected``
|
- No deletions in ``in_review``, ``validated``, ``rejected``
|
||||||
"""
|
"""
|
||||||
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
|
evidence = get_evidence_or_raise(db, evidence_id)
|
||||||
if evidence is None:
|
test = get_test_or_raise(db, evidence.test_id)
|
||||||
raise HTTPException(
|
validate_delete_permission(test, evidence, current_user.role, current_user.id)
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Evidence not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
test = db.query(Test).filter(Test.id == evidence.test_id).first()
|
|
||||||
if test is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Parent test not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Permission checks
|
|
||||||
_validate_delete_permission(test, evidence, current_user)
|
|
||||||
|
|
||||||
# Audit before deletion
|
# Audit before deletion
|
||||||
log_action(
|
log_action(
|
||||||
|
|||||||
167
backend/app/services/evidence_service.py
Normal file
167
backend/app/services/evidence_service.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Evidence service — permission validation, file validation, and query logic.
|
||||||
|
|
||||||
|
Framework-agnostic; uses domain exceptions from app.domain.errors.
|
||||||
|
The router is responsible for HTTP concerns, file I/O, MinIO upload,
|
||||||
|
audit logging, and response formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.domain.errors import (
|
||||||
|
BusinessRuleViolation,
|
||||||
|
EntityNotFoundError,
|
||||||
|
PermissionViolation,
|
||||||
|
)
|
||||||
|
from app.models.enums import TeamSide, TestState
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.test import Test
|
||||||
|
|
||||||
|
# States where red evidence can be uploaded / deleted
|
||||||
|
RED_EDITABLE_STATES = (TestState.draft, TestState.red_executing)
|
||||||
|
# States where blue evidence can be uploaded / deleted
|
||||||
|
BLUE_EDITABLE_STATES = (TestState.blue_evaluating,)
|
||||||
|
|
||||||
|
# Maximum upload size in bytes (50 MB)
|
||||||
|
MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
# Allowed file extensions (lowercase, with leading dot)
|
||||||
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
|
||||||
|
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt",
|
||||||
|
".md", ".rtf", ".odt", ".ods",
|
||||||
|
".log", ".pcap", ".pcapng", ".evtx", ".json", ".xml",
|
||||||
|
".yaml", ".yml", ".toml",
|
||||||
|
".zip", ".tar", ".gz", ".7z",
|
||||||
|
".har", ".eml", ".msg",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def validate_upload_permission(
|
||||||
|
test: Test,
|
||||||
|
team: TeamSide,
|
||||||
|
user_role: str,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that the user can upload evidence for the given team in the current state.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionViolation: If user lacks role to upload for this team.
|
||||||
|
BusinessRuleViolation: If test state does not allow uploading for this team.
|
||||||
|
"""
|
||||||
|
if user_role == "admin":
|
||||||
|
return
|
||||||
|
|
||||||
|
if team == TeamSide.red:
|
||||||
|
if user_role not in ("red_tech", "red_lead"):
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Only red_tech, red_lead or admin can upload red evidence"
|
||||||
|
)
|
||||||
|
if test.state not in RED_EDITABLE_STATES:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"Cannot upload red evidence in '{test.state.value}' state "
|
||||||
|
"(allowed in: draft, red_executing)"
|
||||||
|
)
|
||||||
|
elif team == TeamSide.blue:
|
||||||
|
if user_role not in ("blue_tech", "blue_lead"):
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Only blue_tech, blue_lead or admin can upload blue evidence"
|
||||||
|
)
|
||||||
|
if test.state not in BLUE_EDITABLE_STATES:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"Cannot upload blue evidence in '{test.state.value}' state "
|
||||||
|
"(allowed in: blue_evaluating)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_delete_permission(
|
||||||
|
test: Test,
|
||||||
|
evidence: Evidence,
|
||||||
|
user_role: str,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that the user can delete this evidence in the current state.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionViolation: If user cannot delete in this state or lacks permission.
|
||||||
|
"""
|
||||||
|
if test.state in (TestState.in_review, TestState.validated, TestState.rejected):
|
||||||
|
raise PermissionViolation(
|
||||||
|
f"Cannot delete evidence when test is in '{test.state.value}' state"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_role == "admin":
|
||||||
|
return
|
||||||
|
|
||||||
|
ev_team = evidence.team
|
||||||
|
|
||||||
|
if ev_team == TeamSide.red:
|
||||||
|
if test.state not in RED_EDITABLE_STATES:
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Cannot delete red evidence outside draft/red_executing"
|
||||||
|
)
|
||||||
|
if user_role not in ("red_tech", "red_lead") and evidence.uploaded_by != user_id:
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Not enough permissions to delete this evidence"
|
||||||
|
)
|
||||||
|
elif ev_team == TeamSide.blue:
|
||||||
|
if test.state not in BLUE_EDITABLE_STATES:
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Cannot delete blue evidence outside blue_evaluating"
|
||||||
|
)
|
||||||
|
if user_role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user_id:
|
||||||
|
raise PermissionViolation(
|
||||||
|
"Not enough permissions to delete this evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file(file_name: str, content_size: int) -> None:
|
||||||
|
"""Validate file extension and size.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BusinessRuleViolation: If extension is not allowed or file exceeds size limit.
|
||||||
|
"""
|
||||||
|
_, ext = os.path.splitext(file_name)
|
||||||
|
ext_lower = ext.lower() if ext else ""
|
||||||
|
if ext_lower not in ALLOWED_EXTENSIONS:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"File type '{ext}' is not allowed. "
|
||||||
|
f"Permitted types: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
|
||||||
|
)
|
||||||
|
if content_size > MAX_UPLOAD_SIZE:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"File exceeds maximum upload size of {MAX_UPLOAD_SIZE // (1024 * 1024)} MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_evidence_for_test(
|
||||||
|
db: Session,
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
team: TeamSide | str | None = None,
|
||||||
|
) -> list[Evidence]:
|
||||||
|
"""Return evidence for a test, optionally filtered by team."""
|
||||||
|
query = db.query(Evidence).filter(Evidence.test_id == test_id)
|
||||||
|
if team is not None:
|
||||||
|
team_enum = TeamSide(team) if isinstance(team, str) else team
|
||||||
|
query = query.filter(Evidence.team == team_enum)
|
||||||
|
return query.order_by(Evidence.uploaded_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_evidence_or_raise(db: Session, evidence_id: uuid.UUID) -> Evidence:
|
||||||
|
"""Fetch evidence by ID. Raises EntityNotFoundError if not found."""
|
||||||
|
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
|
||||||
|
if evidence is None:
|
||||||
|
raise EntityNotFoundError("Evidence", str(evidence_id))
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
|
def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
|
||||||
|
"""Fetch test by ID. Raises EntityNotFoundError if not found."""
|
||||||
|
test = db.query(Test).filter(Test.id == test_id).first()
|
||||||
|
if test is None:
|
||||||
|
raise EntityNotFoundError("Test", str(test_id))
|
||||||
|
return test
|
||||||
@@ -89,12 +89,12 @@ for mod_name in [
|
|||||||
# Imports
|
# Imports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from app.domain.errors import PermissionViolation
|
||||||
from app.models.enums import TeamSide, TestState
|
from app.models.enums import TeamSide, TestState
|
||||||
from app.routers.evidence import (
|
from app.routers.evidence import router
|
||||||
router,
|
from app.services.evidence_service import (
|
||||||
_validate_upload_permission,
|
validate_delete_permission,
|
||||||
_validate_delete_permission,
|
validate_upload_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ def test_red_tech_upload_red_in_red_executing():
|
|||||||
test = _make_test(TestState.red_executing)
|
test = _make_test(TestState.red_executing)
|
||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
# Should not raise
|
# Should not raise
|
||||||
_validate_upload_permission(test, TeamSide.red, user)
|
validate_upload_permission(test, TeamSide.red, user.role)
|
||||||
print(" [PASS] red_tech can upload team=red in red_executing")
|
print(" [PASS] red_tech can upload team=red in red_executing")
|
||||||
|
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ def test_red_tech_upload_red_in_red_executing():
|
|||||||
def test_red_tech_upload_red_in_draft():
|
def test_red_tech_upload_red_in_draft():
|
||||||
test = _make_test(TestState.draft)
|
test = _make_test(TestState.draft)
|
||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
_validate_upload_permission(test, TeamSide.red, user)
|
validate_upload_permission(test, TeamSide.red, user.role)
|
||||||
print(" [PASS] red_tech can upload team=red in draft")
|
print(" [PASS] red_tech can upload team=red in draft")
|
||||||
|
|
||||||
|
|
||||||
@@ -157,10 +157,10 @@ def test_red_tech_cannot_upload_blue():
|
|||||||
test = _make_test(TestState.red_executing)
|
test = _make_test(TestState.red_executing)
|
||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.blue, user)
|
validate_upload_permission(test, TeamSide.blue, user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised PermissionViolation"
|
||||||
except HTTPException as exc:
|
except PermissionViolation:
|
||||||
assert exc.status_code == 403
|
pass
|
||||||
print(" [PASS] red_tech CANNOT upload team=blue (403)")
|
print(" [PASS] red_tech CANNOT upload team=blue (403)")
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ def test_red_tech_cannot_upload_blue():
|
|||||||
def test_blue_tech_upload_blue_in_blue_evaluating():
|
def test_blue_tech_upload_blue_in_blue_evaluating():
|
||||||
test = _make_test(TestState.blue_evaluating)
|
test = _make_test(TestState.blue_evaluating)
|
||||||
user = _make_user("blue_tech")
|
user = _make_user("blue_tech")
|
||||||
_validate_upload_permission(test, TeamSide.blue, user)
|
validate_upload_permission(test, TeamSide.blue, user.role)
|
||||||
print(" [PASS] blue_tech can upload team=blue in blue_evaluating")
|
print(" [PASS] blue_tech can upload team=blue in blue_evaluating")
|
||||||
|
|
||||||
|
|
||||||
@@ -185,10 +185,10 @@ def test_blue_tech_cannot_upload_red():
|
|||||||
test = _make_test(TestState.blue_evaluating)
|
test = _make_test(TestState.blue_evaluating)
|
||||||
user = _make_user("blue_tech")
|
user = _make_user("blue_tech")
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.red, user)
|
validate_upload_permission(test, TeamSide.red, user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised PermissionViolation"
|
||||||
except HTTPException as exc:
|
except PermissionViolation:
|
||||||
assert exc.status_code == 403
|
pass
|
||||||
print(" [PASS] blue_tech CANNOT upload team=red (403)")
|
print(" [PASS] blue_tech CANNOT upload team=red (403)")
|
||||||
|
|
||||||
|
|
||||||
@@ -223,10 +223,10 @@ def test_delete_in_review_fails():
|
|||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
evidence = _make_evidence(TeamSide.red, uploaded_by=user.id)
|
evidence = _make_evidence(TeamSide.red, uploaded_by=user.id)
|
||||||
try:
|
try:
|
||||||
_validate_delete_permission(test, evidence, user)
|
validate_delete_permission(test, evidence, user.role, user.id)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised PermissionViolation"
|
||||||
except HTTPException as exc:
|
except PermissionViolation:
|
||||||
assert exc.status_code == 403
|
pass
|
||||||
print(" [PASS] DELETE in in_review -> 403")
|
print(" [PASS] DELETE in in_review -> 403")
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ def test_delete_red_evidence_in_red_executing():
|
|||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
evidence = _make_evidence(TeamSide.red, uploaded_by=user.id)
|
evidence = _make_evidence(TeamSide.red, uploaded_by=user.id)
|
||||||
# Should not raise
|
# Should not raise
|
||||||
_validate_delete_permission(test, evidence, user)
|
validate_delete_permission(test, evidence, user.role, user.id)
|
||||||
print(" [PASS] DELETE red evidence in red_executing -> allowed")
|
print(" [PASS] DELETE red evidence in red_executing -> allowed")
|
||||||
|
|
||||||
|
|
||||||
@@ -254,11 +254,11 @@ def test_admin_bypass():
|
|||||||
|
|
||||||
# Red in blue_evaluating (normally blocked)
|
# Red in blue_evaluating (normally blocked)
|
||||||
test1 = _make_test(TestState.blue_evaluating)
|
test1 = _make_test(TestState.blue_evaluating)
|
||||||
_validate_upload_permission(test1, TeamSide.red, admin)
|
validate_upload_permission(test1, TeamSide.red, admin.role)
|
||||||
|
|
||||||
# Blue in draft (normally blocked)
|
# Blue in draft (normally blocked)
|
||||||
test2 = _make_test(TestState.draft)
|
test2 = _make_test(TestState.draft)
|
||||||
_validate_upload_permission(test2, TeamSide.blue, admin)
|
validate_upload_permission(test2, TeamSide.blue, admin.role)
|
||||||
|
|
||||||
print(" [PASS] Admin can upload any team in any state")
|
print(" [PASS] Admin can upload any team in any state")
|
||||||
|
|
||||||
|
|||||||
@@ -419,56 +419,57 @@ def test_dual_validation_red_approves_blue_rejects(mock_log):
|
|||||||
|
|
||||||
def test_evidence_team_separation():
|
def test_evidence_team_separation():
|
||||||
"""Verify evidence router logic separates red and blue evidence correctly."""
|
"""Verify evidence router logic separates red and blue evidence correctly."""
|
||||||
from app.routers.evidence import _validate_upload_permission, _RED_EDITABLE_STATES, _BLUE_EDITABLE_STATES
|
from app.domain.errors import BusinessRuleViolation, PermissionViolation
|
||||||
|
from app.models.enums import TeamSide
|
||||||
|
from app.services.evidence_service import validate_upload_permission
|
||||||
|
|
||||||
# Red tech can upload red evidence in draft
|
# Red tech can upload red evidence in draft
|
||||||
test = _make_test(TestState.draft)
|
test = _make_test(TestState.draft)
|
||||||
red_user = _make_user("red_tech")
|
red_user = _make_user("red_tech")
|
||||||
red_user.role = "red_tech"
|
red_user.role = "red_tech"
|
||||||
from app.models.enums import TeamSide
|
validate_upload_permission(test, TeamSide.red, red_user.role) # should not raise
|
||||||
_validate_upload_permission(test, TeamSide.red, red_user) # should not raise
|
|
||||||
|
|
||||||
# Red tech can upload red evidence in red_executing
|
# Red tech can upload red evidence in red_executing
|
||||||
test.state = TestState.red_executing
|
test.state = TestState.red_executing
|
||||||
_validate_upload_permission(test, TeamSide.red, red_user) # should not raise
|
validate_upload_permission(test, TeamSide.red, red_user.role) # should not raise
|
||||||
|
|
||||||
# Red tech CANNOT upload red evidence in blue_evaluating
|
# Red tech CANNOT upload red evidence in blue_evaluating (state violation -> 400)
|
||||||
test.state = TestState.blue_evaluating
|
test.state = TestState.blue_evaluating
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.red, red_user)
|
validate_upload_permission(test, TeamSide.red, red_user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised BusinessRuleViolation"
|
||||||
except HTTPException as exc:
|
except BusinessRuleViolation:
|
||||||
assert exc.status_code == 400
|
pass
|
||||||
|
|
||||||
# Red tech CANNOT upload blue evidence
|
# Red tech CANNOT upload blue evidence (role violation -> 403)
|
||||||
test.state = TestState.blue_evaluating
|
test.state = TestState.blue_evaluating
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.blue, red_user)
|
validate_upload_permission(test, TeamSide.blue, red_user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised PermissionViolation"
|
||||||
except HTTPException as exc:
|
except PermissionViolation:
|
||||||
assert exc.status_code == 403
|
pass
|
||||||
|
|
||||||
# Blue tech can upload blue evidence in blue_evaluating
|
# Blue tech can upload blue evidence in blue_evaluating
|
||||||
test.state = TestState.blue_evaluating
|
test.state = TestState.blue_evaluating
|
||||||
blue_user = _make_user("blue_tech")
|
blue_user = _make_user("blue_tech")
|
||||||
blue_user.role = "blue_tech"
|
blue_user.role = "blue_tech"
|
||||||
_validate_upload_permission(test, TeamSide.blue, blue_user) # should not raise
|
validate_upload_permission(test, TeamSide.blue, blue_user.role) # should not raise
|
||||||
|
|
||||||
# Blue tech CANNOT upload blue evidence in draft
|
# Blue tech CANNOT upload blue evidence in draft (state violation -> 400)
|
||||||
test.state = TestState.draft
|
test.state = TestState.draft
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.blue, blue_user)
|
validate_upload_permission(test, TeamSide.blue, blue_user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised BusinessRuleViolation"
|
||||||
except HTTPException as exc:
|
except BusinessRuleViolation:
|
||||||
assert exc.status_code == 400
|
pass
|
||||||
|
|
||||||
# Blue tech CANNOT upload red evidence
|
# Blue tech CANNOT upload red evidence (role violation -> 403)
|
||||||
test.state = TestState.draft
|
test.state = TestState.draft
|
||||||
try:
|
try:
|
||||||
_validate_upload_permission(test, TeamSide.red, blue_user)
|
validate_upload_permission(test, TeamSide.red, blue_user.role)
|
||||||
assert False, "Should have raised HTTPException"
|
assert False, "Should have raised PermissionViolation"
|
||||||
except HTTPException as exc:
|
except PermissionViolation:
|
||||||
assert exc.status_code == 403
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user