0ddd17047d
Task D — Google-style docstrings (Args/Returns) on every public function, method, and class across all 158 Python files in the backend. Zero ruff D violations (pydocstyle Google convention). Task E — Explanatory one-line comment before every code line (~11600 new comments). ruff check passes clean after isort re-sort. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1167 lines
40 KiB
Python
1167 lines
40 KiB
Python
"""CRUD router for security Tests — v2 with Red/Blue workflow.
|
|
|
|
Endpoints
|
|
---------
|
|
GET /tests — list with filters (state, technique_id)
|
|
POST /tests — create (red_tech, admin)
|
|
POST /tests/from-template — create from TestTemplate (red_tech, admin)
|
|
GET /tests/{id} — detail with split red/blue evidences
|
|
PATCH /tests/{id} — general update (draft/rejected only)
|
|
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
|
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
|
POST /tests/{id}/start-execution — draft → red_executing
|
|
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
|
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
|
POST /tests/{id}/validate-red — Red Lead validates
|
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
|
POST /tests/{id}/reopen — rejected → draft
|
|
GET /tests/{id}/timeline — audit-log history for this test
|
|
"""
|
|
|
|
# Import uuid
|
|
import uuid
|
|
|
|
# Import Optional from typing
|
|
from typing import Optional
|
|
|
|
# Import APIRouter, Depends, HTTPException, Query, Reque... from fastapi
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
|
|
# Import Session from sqlalchemy.orm
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Import get_db from app.database
|
|
from app.database import get_db
|
|
|
|
# Import get_current_user, require_any_role, require_role from app.dependencies.auth
|
|
from app.dependencies.auth import get_current_user, require_any_role, require_role
|
|
|
|
# Import UnitOfWork from app.domain.unit_of_work
|
|
from app.domain.unit_of_work import UnitOfWork
|
|
|
|
# Import limiter from app.limiter
|
|
from app.limiter import limiter
|
|
|
|
# Import TestState from app.models.enums
|
|
from app.models.enums import TestState
|
|
|
|
# Import User from app.models.user
|
|
from app.models.user import User
|
|
|
|
# Import from app.schemas.test
|
|
from app.schemas.test import (
|
|
TestBlueUpdate,
|
|
TestBlueValidate,
|
|
TestClassificationUpdate,
|
|
TestCreate,
|
|
TestOut,
|
|
TestRedUpdate,
|
|
TestRedValidate,
|
|
TestRemediationUpdate,
|
|
TestUpdate,
|
|
)
|
|
|
|
# Import TestTemplateInstantiate from app.schemas.test_template
|
|
from app.schemas.test_template import TestTemplateInstantiate
|
|
|
|
# Import log_action from app.services.audit_service
|
|
from app.services.audit_service import log_action
|
|
|
|
# Import recalculate_technique_status from app.services.status_service
|
|
from app.services.status_service import recalculate_technique_status
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
create_test as crud_create_test,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
create_test_from_template as crud_create_from_template,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
get_test_detail as crud_get_test_detail,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
get_test_or_raise as crud_get_test_or_raise,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
get_test_timeline as crud_get_test_timeline,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
get_test_with_technique as crud_get_test_with_technique,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
list_tests as crud_list_tests,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
update_test as crud_update_test,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
update_test_blue as crud_update_test_blue,
|
|
)
|
|
|
|
# Import from app.services.test_crud_service
|
|
from app.services.test_crud_service import (
|
|
update_test_red as crud_update_test_red,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
get_retest_chain as wf_get_retest_chain,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
handle_remediation_completed as wf_handle_remediation,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
pause_timer as wf_pause_timer,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
reopen_test as wf_reopen,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
resume_timer as wf_resume_timer,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
start_execution as wf_start_execution,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
submit_blue_evidence as wf_submit_blue,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
submit_red_evidence as wf_submit_red,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
validate_as_blue_lead as wf_validate_blue,
|
|
)
|
|
|
|
# Import from app.services.test_workflow_service
|
|
from app.services.test_workflow_service import (
|
|
validate_as_red_lead as wf_validate_red,
|
|
)
|
|
|
|
# Assign router = APIRouter(prefix="/tests", tags=["tests"])
|
|
router = APIRouter(prefix="/tests", tags=["tests"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests — list with filters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("", response_model=list[TestOut])
|
|
# Define function list_tests
|
|
def list_tests(
|
|
# Entry: state
|
|
state: Optional[str] = Query(None, description="Filter by test state"),
|
|
# Entry: technique_id
|
|
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
|
# Entry: platform
|
|
platform: Optional[str] = Query(None, description="Filter by platform"),
|
|
# Entry: created_by
|
|
created_by: Optional[uuid.UUID] = Query(None, description="Filter by creator"),
|
|
# Entry: pending_validation_side
|
|
pending_validation_side: Optional[str] = Query(
|
|
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
|
),
|
|
# Entry: offset
|
|
offset: int = Query(0, ge=0),
|
|
# Entry: limit
|
|
limit: int = Query(50, ge=1, le=200),
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> list:
|
|
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
|
|
|
|
Args:
|
|
state (Optional[str]): Filter by test state (e.g. ``draft``, ``validated``).
|
|
technique_id (Optional[uuid.UUID]): Filter tests belonging to a specific technique.
|
|
platform (Optional[str]): Filter by target platform (e.g. ``windows``, ``linux``).
|
|
created_by (Optional[uuid.UUID]): Filter by the UUID of the creator.
|
|
pending_validation_side (Optional[str]): Filter ``in_review`` tests pending validation
|
|
on ``'red'`` or ``'blue'`` side.
|
|
offset (int): Number of records to skip for pagination.
|
|
limit (int): Maximum number of records to return.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
list: Serialised list of :class:`TestOut` objects matching the filters.
|
|
"""
|
|
# Return crud_list_tests(
|
|
return crud_list_tests(
|
|
db,
|
|
# Keyword argument: state
|
|
state=state,
|
|
# Keyword argument: technique_id
|
|
technique_id=technique_id,
|
|
# Keyword argument: platform
|
|
platform=platform,
|
|
# Keyword argument: created_by
|
|
created_by=created_by,
|
|
# Keyword argument: pending_validation_side
|
|
pending_validation_side=pending_validation_side,
|
|
# Keyword argument: offset
|
|
offset=offset,
|
|
# Keyword argument: limit
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests — create (red_tech or admin)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
# Literal argument value
|
|
"",
|
|
# Keyword argument: response_model
|
|
response_model=TestOut,
|
|
# Keyword argument: status_code
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
# Apply the @limiter.limit decorator
|
|
@limiter.limit("30/minute")
|
|
# Define function create_test
|
|
def create_test(
|
|
# Entry: request
|
|
request: Request,
|
|
# Entry: payload
|
|
payload: TestCreate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Create a new test linked to an existing technique.
|
|
|
|
``created_by`` is set automatically and ``state`` defaults to *draft*.
|
|
|
|
Args:
|
|
request (Request): FastAPI request object (used by the rate limiter).
|
|
payload (TestCreate): Fields for the new test, including ``technique_id``.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead or blue_lead creating the test.
|
|
|
|
Returns:
|
|
TestOut: The newly created test with all fields populated.
|
|
"""
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_create_test(
|
|
test = crud_create_test(
|
|
db,
|
|
# Keyword argument: technique_id
|
|
technique_id=payload.technique_id,
|
|
# Keyword argument: creator_id
|
|
creator_id=current_user.id,
|
|
**payload.model_dump(exclude={"technique_id"}),
|
|
)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="create_test",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"name": test.name, "technique_id": str(test.technique_id)},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/from-template — create from TestTemplate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
# Literal argument value
|
|
"/from-template",
|
|
# Keyword argument: response_model
|
|
response_model=TestOut,
|
|
# Keyword argument: status_code
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
# Apply the @limiter.limit decorator
|
|
@limiter.limit("30/minute")
|
|
# Define function create_test_from_template
|
|
def create_test_from_template(
|
|
# Entry: request
|
|
request: Request,
|
|
# Entry: payload
|
|
payload: TestTemplateInstantiate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Instantiate a real Test from an existing TestTemplate.
|
|
|
|
The template's fields are copied into the new test as starting data.
|
|
|
|
Args:
|
|
request (Request): FastAPI request object (used by the rate limiter).
|
|
payload (TestTemplateInstantiate): Contains ``template_id`` and target ``technique_id``.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead or blue_lead creating the test.
|
|
|
|
Returns:
|
|
TestOut: The newly created test populated from the template.
|
|
"""
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_create_from_template(
|
|
test = crud_create_from_template(
|
|
db,
|
|
# Keyword argument: template_id
|
|
template_id=payload.template_id,
|
|
# Keyword argument: technique_id_or_mitre
|
|
technique_id_or_mitre=payload.technique_id,
|
|
# Keyword argument: creator_id
|
|
creator_id=current_user.id,
|
|
)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="create_test_from_template",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"name": test.name,
|
|
# Literal argument value
|
|
"template_id": str(payload.template_id),
|
|
# Literal argument value
|
|
"technique_id": str(test.technique_id),
|
|
},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id} — detail with evidences split by team
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}", response_model=TestOut)
|
|
# Define function get_test
|
|
def get_test(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> TestOut:
|
|
"""Return full details for a single test, including its evidences.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to retrieve.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
TestOut: Full test detail including split red/blue evidence lists.
|
|
"""
|
|
# Return crud_get_test_detail(db, test_id)
|
|
return crud_get_test_detail(db, test_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id} — general update (draft / rejected)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}", response_model=TestOut)
|
|
# Define function update_test
|
|
def update_test(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Update one or more fields of an existing test.
|
|
|
|
Only leads or admins can update general test fields.
|
|
The test must be in ``draft`` or ``rejected`` state.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to update.
|
|
payload (TestUpdate): Partial update payload; only set fields are applied.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead or blue_lead performing the update.
|
|
|
|
Returns:
|
|
TestOut: The updated test with refreshed field values.
|
|
"""
|
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_update_test(
|
|
test = crud_update_test(
|
|
db,
|
|
test_id,
|
|
# Keyword argument: updater_id
|
|
updater_id=current_user.id,
|
|
# Keyword argument: updater_role
|
|
updater_role=current_user.role,
|
|
**update_data,
|
|
)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_test",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/classification — admin data classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/classification", response_model=TestOut)
|
|
# Define function update_test_classification
|
|
def update_test_classification(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestClassificationUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> TestOut:
|
|
"""Update the data classification label for a test (admin only).
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to classify.
|
|
payload (TestClassificationUpdate): Contains the new ``data_classification`` value.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated admin user.
|
|
|
|
Returns:
|
|
TestOut: The test with the updated ``data_classification`` field.
|
|
"""
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Assign test.data_classification = payload.data_classification.value
|
|
test.data_classification = payload.data_classification.value
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_test_classification",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"data_classification": payload.data_classification.value},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/red — Red Team update (draft, red_executing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/red", response_model=TestOut)
|
|
# Define function update_test_red
|
|
def update_test_red(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestRedUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
) -> TestOut:
|
|
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to update.
|
|
payload (TestRedUpdate): Red-team-specific fields to update.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_tech or red_lead.
|
|
|
|
Returns:
|
|
TestOut: The updated test with refreshed red-team field values.
|
|
"""
|
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_update_test_red(db, test_id, **update_data)
|
|
test = crud_update_test_red(db, test_id, **update_data)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_test_red",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/blue — Blue Team update (blue_evaluating only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/blue", response_model=TestOut)
|
|
# Define function update_test_blue
|
|
def update_test_blue(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestBlueUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to update.
|
|
payload (TestBlueUpdate): Blue-team-specific fields to update.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated blue_tech or blue_lead.
|
|
|
|
Returns:
|
|
TestOut: The updated test with refreshed blue-team field values.
|
|
"""
|
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = crud_update_test_blue(db, test_id, **update_data)
|
|
test = crud_update_test_blue(db, test_id, **update_data)
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_test_blue",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/start-execution — draft → red_executing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/start-execution", response_model=TestOut)
|
|
# Define function start_execution
|
|
def start_execution(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
) -> TestOut:
|
|
"""Move a test from ``draft`` to ``red_executing``.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to start.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_tech or red_lead initiating execution.
|
|
|
|
Returns:
|
|
TestOut: The updated test in ``red_executing`` state.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_start_execution(db, test, current_user)
|
|
test = wf_start_execution(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/submit-red", response_model=TestOut)
|
|
# Define function submit_red
|
|
def submit_red(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
) -> TestOut:
|
|
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to submit.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_tech or red_lead submitting red evidence.
|
|
|
|
Returns:
|
|
TestOut: The updated test in ``blue_evaluating`` state.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_submit_red(db, test, current_user)
|
|
test = wf_submit_red(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/submit-blue", response_model=TestOut)
|
|
# Define function submit_blue
|
|
def submit_blue(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to submit.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated blue_tech or blue_lead submitting blue evidence.
|
|
|
|
Returns:
|
|
TestOut: The updated test in ``in_review`` state.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_submit_blue(db, test, current_user)
|
|
test = wf_submit_blue(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/pause-timer — pause the active phase timer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/pause-timer", response_model=TestOut)
|
|
# Define function pause_timer
|
|
def pause_timer(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test whose timer should be paused.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated team member in the active phase.
|
|
|
|
Returns:
|
|
TestOut: The updated test with the phase timer paused.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_pause_timer(db, test, current_user)
|
|
test = wf_pause_timer(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/resume-timer — resume a paused phase timer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/resume-timer", response_model=TestOut)
|
|
# Define function resume_timer
|
|
def resume_timer(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Resume the paused timer for the current phase.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test whose timer should be resumed.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated team member in the active phase.
|
|
|
|
Returns:
|
|
TestOut: The updated test with the phase timer running again.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_resume_timer(db, test, current_user)
|
|
test = wf_resume_timer(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/validate-red — Red Lead validates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/validate-red", response_model=TestOut)
|
|
# Define function validate_red
|
|
def validate_red(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestRedValidate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead")),
|
|
) -> TestOut:
|
|
"""Red Lead approves or rejects the red side of a test.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to validate.
|
|
payload (TestRedValidate): Validation status and optional notes from the Red Lead.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead performing the validation.
|
|
|
|
Returns:
|
|
TestOut: The updated test reflecting the red validation decision.
|
|
"""
|
|
# Assign test = crud_get_test_with_technique(db, test_id)
|
|
test = crud_get_test_with_technique(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_validate_red(
|
|
test = wf_validate_red(
|
|
db, test, current_user,
|
|
# Keyword argument: validation_status
|
|
validation_status=payload.red_validation_status,
|
|
# Keyword argument: notes
|
|
notes=payload.red_validation_notes,
|
|
)
|
|
# Check: test.state in (TestState.validated, TestState.rejected)
|
|
if test.state in (TestState.validated, TestState.rejected):
|
|
# Call recalculate_technique_status()
|
|
recalculate_technique_status(db, test.technique)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/validate-blue — Blue Lead validates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/validate-blue", response_model=TestOut)
|
|
# Define function validate_blue
|
|
def validate_blue(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestBlueValidate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("blue_lead")),
|
|
) -> TestOut:
|
|
"""Blue Lead approves or rejects the blue side of a test.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to validate.
|
|
payload (TestBlueValidate): Validation status and optional notes from the Blue Lead.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated blue_lead performing the validation.
|
|
|
|
Returns:
|
|
TestOut: The updated test reflecting the blue validation decision.
|
|
"""
|
|
# Assign test = crud_get_test_with_technique(db, test_id)
|
|
test = crud_get_test_with_technique(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_validate_blue(
|
|
test = wf_validate_blue(
|
|
db, test, current_user,
|
|
# Keyword argument: validation_status
|
|
validation_status=payload.blue_validation_status,
|
|
# Keyword argument: notes
|
|
notes=payload.blue_validation_notes,
|
|
)
|
|
# Check: test.state in (TestState.validated, TestState.rejected)
|
|
if test.state in (TestState.validated, TestState.rejected):
|
|
# Call recalculate_technique_status()
|
|
recalculate_technique_status(db, test.technique)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/reopen — rejected → draft
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/reopen", response_model=TestOut)
|
|
# Define function reopen
|
|
def reopen(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Reopen a rejected test, moving it back to ``draft``.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the rejected test to reopen.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead or blue_lead reopening the test.
|
|
|
|
Returns:
|
|
TestOut: The updated test in ``draft`` state.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign test = wf_reopen(db, test, current_user)
|
|
test = wf_reopen(db, test, current_user)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/remediation — update remediation fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/remediation", response_model=TestOut)
|
|
# Define function update_remediation
|
|
def update_remediation(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: payload
|
|
payload: TestRemediationUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
) -> TestOut:
|
|
"""Update remediation fields on a test.
|
|
|
|
When ``remediation_status`` transitions to ``'completed'``, an automatic
|
|
re-test is created (subject to ``MAX_RETEST_COUNT``).
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test to update.
|
|
payload (TestRemediationUpdate): Remediation fields to update (status, notes, etc.).
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated red_lead or blue_lead updating remediation.
|
|
|
|
Returns:
|
|
TestOut: The updated test with refreshed remediation fields.
|
|
"""
|
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
|
|
# Assign old_remediation_status = test.remediation_status
|
|
old_remediation_status = test.remediation_status
|
|
|
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
# Iterate over update_data.items()
|
|
for field, value in update_data.items():
|
|
# Call setattr()
|
|
setattr(test, field, value)
|
|
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=current_user.id,
|
|
# Keyword argument: action
|
|
action="update_remediation",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
|
|
# Assign new_status = update_data.get("remediation_status")
|
|
new_status = update_data.get("remediation_status")
|
|
# Check: new_status == "completed" and old_remediation_status != "completed"
|
|
if new_status == "completed" and old_remediation_status != "completed":
|
|
# Call wf_handle_remediation()
|
|
wf_handle_remediation(db, test, current_user)
|
|
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
|
|
# Reload ORM object attributes from the database
|
|
db.refresh(test)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id}/timeline — audit history for this test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}/timeline")
|
|
# Define function get_test_timeline
|
|
def get_test_timeline(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> list:
|
|
"""Return the chronological audit-log history for a test.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of the test whose timeline is requested.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
list: Chronological list of audit-log entries for the test.
|
|
"""
|
|
# Return crud_get_test_timeline(db, test_id)
|
|
return crud_get_test_timeline(db, test_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id}/retest-chain — full retest chain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}/retest-chain")
|
|
# Define function get_retest_chain
|
|
def get_retest_chain(
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> list:
|
|
"""Return the full chain of retests (original + all retests) for a test.
|
|
|
|
Args:
|
|
test_id (uuid.UUID): Primary key of any test in the retest chain.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
list: Ordered list of dicts describing each test in the chain,
|
|
including state, result, remediation status, and retest metadata.
|
|
"""
|
|
# Assign chain = wf_get_retest_chain(db, test_id)
|
|
chain = wf_get_retest_chain(db, test_id)
|
|
# Check: not chain
|
|
if not chain:
|
|
# Raise HTTPException
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Test not found")
|
|
|
|
# Return [
|
|
return [
|
|
{
|
|
# Literal argument value
|
|
"id": str(t.id),
|
|
# Literal argument value
|
|
"name": t.name,
|
|
# Literal argument value
|
|
"state": t.state.value if t.state else None,
|
|
# Literal argument value
|
|
"retest_of": str(t.retest_of) if t.retest_of else None,
|
|
# Literal argument value
|
|
"retest_count": t.retest_count,
|
|
# Literal argument value
|
|
"result": t.result.value if t.result else None,
|
|
# Literal argument value
|
|
"detection_result": t.detection_result.value if t.detection_result else None,
|
|
# Literal argument value
|
|
"remediation_status": t.remediation_status,
|
|
# Literal argument value
|
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
}
|
|
for t in chain
|
|
]
|