feat(test-catalog): add template creation and lead-approval workflow
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

Leads get a Create Template button that adds directly to the catalog
(existing POST /test-templates). Operators (red_tech/blue_tech) get the
same button, but their proposal now lands in a new template_suggestions
review queue instead — a lead on their team can approve it as-is, edit
fields before approving, or discard it. Modeled on the existing
ProcedureSuggestion review workflow.
This commit is contained in:
kitos
2026-07-16 12:08:23 +02:00
parent cf4a6c3cde
commit c753797019
12 changed files with 1009 additions and 4 deletions
@@ -0,0 +1,54 @@
"""Add template_suggestions table for operator template proposals.
Leads can already create a TestTemplate directly (POST /test-templates).
Operators (red_tech/blue_tech) get the same "create template" action, but
their proposal lands here pending a lead's review instead of the live
catalog.
Revision ID: b064
Revises: b063
Create Date: 2026-07-16
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b064"
down_revision = "b063"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"template_suggestions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("mitre_technique_id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("source", sa.String(), nullable=False, server_default="custom"),
sa.Column("source_url", sa.String(), nullable=True),
sa.Column("attack_procedure", sa.Text(), nullable=True),
sa.Column("expected_detection", sa.Text(), nullable=True),
sa.Column("platform", sa.String(), nullable=True),
sa.Column("tool_suggested", sa.String(), nullable=True),
sa.Column("severity", sa.String(), nullable=True),
sa.Column("atomic_test_id", sa.String(), nullable=True),
sa.Column("suggested_remediation", sa.Text(), nullable=True),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("created_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_template_suggestions_status", "template_suggestions", ["status"])
op.create_index("ix_template_suggestions_team", "template_suggestions", ["team"])
def downgrade() -> None:
op.drop_index("ix_template_suggestions_team", table_name="template_suggestions")
op.drop_index("ix_template_suggestions_status", table_name="template_suggestions")
op.drop_table("template_suggestions")
+2
View File
@@ -44,6 +44,7 @@ from app.routers import tests as tests_router
from app.routers import evidence as evidence_router
from app.routers import test_templates as test_templates_router
from app.routers import procedure_suggestions as procedure_suggestions_router
from app.routers import template_suggestions as template_suggestions_router
from app.routers import system as system_router
from app.routers import metrics as metrics_router
from app.routers import users as users_router
@@ -215,6 +216,7 @@ app.include_router(evidence_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(test_templates_router.router, prefix="/api/v1")
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
app.include_router(template_suggestions_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(system_router.router, prefix="/api/v1")
# Call app.include_router()
+2
View File
@@ -46,6 +46,7 @@ from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User
# Assign __all__ = [
@@ -90,4 +91,5 @@ __all__ = [
"AlertInstance",
"TestRoundHistory",
"ProcedureSuggestion",
"TemplateSuggestion",
]
+53
View File
@@ -0,0 +1,53 @@
"""SQLAlchemy model for operator-proposed test templates, pending lead review.
Leads create templates directly (see TestTemplateCreate router). An
operator (red_tech/blue_tech) gets the same "create template" action, but
their submission lands here instead of the live catalog — a lead on their
team reviews it, optionally edits any field, and either approves it (which
creates the real TestTemplate) or discards it.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TemplateSuggestion(Base):
"""A proposed new TestTemplate submitted by an operator, pending lead review."""
__tablename__ = "template_suggestions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Proposed TestTemplate fields — mirrors TestTemplateCreate.
mitre_technique_id = Column(String, nullable=False)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
source = Column(String, nullable=False, default="custom", server_default="custom")
source_url = Column(String, nullable=True)
attack_procedure = Column(Text, nullable=True)
expected_detection = Column(Text, nullable=True)
platform = Column(String, nullable=True)
tool_suggested = Column(String, nullable=True)
severity = Column(String, nullable=True)
atomic_test_id = Column(String, nullable=True)
suggested_remediation = Column(Text, nullable=True)
# "red" or "blue" — derived from the submitter's role, determines which
# lead reviews it (admins can review both).
team = Column(String(10), nullable=False)
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
created_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
submitter = relationship("User", foreign_keys=[submitted_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
created_template = relationship("TestTemplate", foreign_keys=[created_template_id])
+166
View File
@@ -0,0 +1,166 @@
"""Router for operator template-proposal review.
Endpoints
---------
POST /template-suggestions — propose a new template (red_tech/blue_tech)
GET /template-suggestions — list pending suggestions (lead/admin)
POST /template-suggestions/{id}/approve — create the template, with optional edits (lead/admin)
POST /template-suggestions/{id}/reject — discard the suggestion (lead/admin)
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
"blue" — admins can see and act on both.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_any_role_strict
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.domain.unit_of_work import UnitOfWork
from app.models.technique import Technique
from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User
from app.schemas.template_suggestion import (
TemplateSuggestionApprove,
TemplateSuggestionCreate,
TemplateSuggestionOut,
)
from app.services.audit_service import log_action
from app.services.template_suggestion_service import (
approve_template_suggestion as approve_suggestion_svc,
)
from app.services.template_suggestion_service import (
create_template_suggestion as create_suggestion_svc,
)
from app.services.template_suggestion_service import (
list_pending_template_suggestions,
)
from app.services.template_suggestion_service import (
reject_template_suggestion as reject_suggestion_svc,
)
router = APIRouter(prefix="/template-suggestions", tags=["template-suggestions"])
def _team_for_lead(user: User) -> str | None:
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
if user.role == "red_lead":
return "red"
if user.role == "blue_lead":
return "blue"
return None
def _to_out(suggestion: TemplateSuggestion) -> TemplateSuggestionOut:
out = TemplateSuggestionOut.model_validate(suggestion)
if suggestion.submitter:
out.submitter_name = suggestion.submitter.username or suggestion.submitter.email
return out
@router.post("", response_model=TemplateSuggestionOut, status_code=201)
def propose_template(
payload: TemplateSuggestionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
) -> TemplateSuggestionOut:
"""Propose a new test template. Lands in the review queue, not the live catalog."""
with UnitOfWork(db) as uow:
suggestion = create_suggestion_svc(db, current_user, **payload.model_dump())
log_action(
db, user_id=current_user.id, action="propose_test_template",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"name": suggestion.name, "mitre_technique_id": suggestion.mitre_technique_id},
)
uow.commit()
db.refresh(suggestion)
return _to_out(suggestion)
@router.get("", response_model=list[TemplateSuggestionOut])
def list_suggestions(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[TemplateSuggestionOut]:
"""List pending template suggestions, scoped to the caller's team (admins see both)."""
team = _team_for_lead(current_user)
suggestions = list_pending_template_suggestions(db, team=team)
return [_to_out(s) for s in suggestions]
@router.post("/{suggestion_id}/approve", response_model=TemplateSuggestionOut)
def approve_suggestion(
suggestion_id: uuid.UUID,
payload: TemplateSuggestionApprove | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> TemplateSuggestionOut:
"""Approve a suggestion, optionally editing fields, creating the real template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
overrides = payload.model_dump(exclude_unset=True) if payload else {}
try:
with UnitOfWork(db) as uow:
suggestion, template = approve_suggestion_svc(db, suggestion_id, current_user, overrides)
if template.mitre_technique_id:
technique = (
db.query(Technique)
.filter(Technique.mitre_id == template.mitre_technique_id)
.first()
)
if technique:
technique.review_required = True
log_action(
db, user_id=current_user.id, action="approve_template_suggestion",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"created_template_id": str(template.id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion)
@router.post("/{suggestion_id}/reject", response_model=TemplateSuggestionOut)
def reject_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> TemplateSuggestionOut:
"""Discard a suggestion without creating a template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="reject_template_suggestion",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion)
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
"""Look up just the team of a suggestion, for a permission check before mutating it."""
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
if suggestion is None:
raise HTTPException(status_code=404, detail="Template suggestion not found")
return suggestion.team
def _check_team_permission(user: User, team: str) -> None:
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
scoped_team = _team_for_lead(user)
if scoped_team is not None and scoped_team != team:
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
@@ -0,0 +1,72 @@
"""Pydantic schemas for the TemplateSuggestion (operator template proposal) workflow."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class TemplateSuggestionCreate(BaseModel):
"""Payload for an operator proposing a new test template."""
mitre_technique_id: str
name: str
description: str | None = None
source: str = "custom"
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
class TemplateSuggestionApprove(BaseModel):
"""Optional field overrides a lead can apply while approving a suggestion.
Any field left unset falls back to what the operator originally
submitted — the lead only needs to pass the fields they want to change.
"""
mitre_technique_id: str | None = None
name: str | None = None
description: str | None = None
source: str | None = None
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
class TemplateSuggestionOut(BaseModel):
"""Full representation of a pending/reviewed template suggestion."""
id: uuid.UUID
mitre_technique_id: str
name: str
description: str | None = None
source: str
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
team: str
submitted_by: uuid.UUID | None = None
submitter_name: str | None = None
status: str
reviewed_by: uuid.UUID | None = None
reviewed_at: datetime | None = None
created_template_id: uuid.UUID | None = None
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,118 @@
"""Operator template-proposal workflow.
Leads create test templates directly (see test_template_service.create_template,
gated to red_lead/blue_lead in the router). An operator (red_tech/blue_tech)
gets the same "propose a template" action, but nothing is written to the
live catalog until a lead on their team reviews it — the lead can approve
as-is, approve with edits, or discard it outright.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.models.template_suggestion import TemplateSuggestion
from app.models.test_template import TestTemplate
from app.models.user import User
from app.services.notification_service import notify_role
from app.services.test_template_service import create_template
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
_TEMPLATE_FIELDS = (
"mitre_technique_id", "name", "description", "source", "source_url",
"attack_procedure", "expected_detection", "platform", "tool_suggested",
"severity", "atomic_test_id", "suggested_remediation",
)
def team_for_submitter(user: User) -> str:
"""Which team a submission belongs to, based on the submitter's role."""
team = _TEAM_BY_ROLE.get(user.role)
if team is None:
raise InvalidOperationError("Only red_tech/blue_tech operators can propose templates")
return team
def create_template_suggestion(db: Session, submitter: User, **fields: object) -> TemplateSuggestion:
"""Create a pending template suggestion and notify the submitter's lead.
Does not commit; caller uses UnitOfWork.
"""
team = team_for_submitter(submitter)
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
db.add(suggestion)
db.flush()
notify_role(
db,
role=_LEAD_ROLE[team],
type="template_suggestion",
title="New test template proposed for review",
message=f'A new template, "{suggestion.name}", was proposed and needs your review.',
entity_type="template_suggestion",
entity_id=suggestion.id,
)
return suggestion
def list_pending_template_suggestions(db: Session, *, team: str | None = None) -> list[TemplateSuggestion]:
"""List pending template suggestions, optionally filtered to one team."""
query = db.query(TemplateSuggestion).filter(TemplateSuggestion.status == "pending")
if team is not None:
query = query.filter(TemplateSuggestion.team == team)
return query.order_by(TemplateSuggestion.created_at.asc()).all()
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> TemplateSuggestion:
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
if suggestion is None:
raise EntityNotFoundError("TemplateSuggestion", str(suggestion_id))
if suggestion.status != "pending":
raise InvalidOperationError("This template suggestion has already been reviewed.")
return suggestion
def approve_template_suggestion(
db: Session,
suggestion_id: uuid.UUID,
user: User,
overrides: dict | None = None,
) -> tuple[TemplateSuggestion, TestTemplate]:
"""Approve a suggestion: create the real TestTemplate and mark it reviewed.
*overrides* lets the reviewing lead adjust any field before it goes
live — unset/None fields fall back to what the operator submitted.
Does not commit; caller uses UnitOfWork.
"""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
fields = {name: getattr(suggestion, name) for name in _TEMPLATE_FIELDS}
for key, value in (overrides or {}).items():
if value is not None and key in fields:
fields[key] = value
template = create_template(db, **fields)
db.flush()
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
suggestion.created_template_id = template.id
db.flush()
return suggestion, template
def reject_template_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> TemplateSuggestion:
"""Discard a suggestion without creating a template. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
suggestion.status = "rejected"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
+145
View File
@@ -0,0 +1,145 @@
"""End-to-end coverage for the operator template-proposal review workflow.
Leads create test templates directly via POST /test-templates. An operator
(red_tech/blue_tech) gets the same "propose a template" action, but their
submission lands in the review queue instead of the live catalog — a lead
on their team approves (optionally editing fields) or discards it.
"""
import pytest
def _payload(**overrides):
base = {
"mitre_technique_id": "T1059.300",
"name": "Operator-proposed template",
"attack_procedure": "Run a discovery command.",
"expected_detection": "Check process creation logs.",
}
base.update(overrides)
return base
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.300", "name": "Command Line Proposals"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_red_tech_can_propose_a_template(api, technique, red_tech_headers):
resp = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "pending"
assert body["team"] == "red"
assert body["name"] == "Operator-proposed template"
def test_blue_tech_can_propose_a_template(api, technique, blue_tech_headers):
resp = api("post", "/api/v1/template-suggestions", blue_tech_headers, json=_payload())
assert resp.status_code == 201, resp.text
assert resp.json()["team"] == "blue"
def test_lead_cannot_propose_a_template_via_suggestion_endpoint(api, technique, red_lead_headers):
"""Leads use the direct POST /test-templates endpoint instead."""
resp = api("post", "/api/v1/template-suggestions", red_lead_headers, json=_payload())
assert resp.status_code == 403
def test_proposed_template_does_not_appear_in_catalog(api, technique, red_tech_headers, red_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
assert catalog.json() == []
def test_red_lead_sees_pending_red_suggestion(api, technique, red_tech_headers, red_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.status_code == 200, listed.text
assert len(listed.json()) == 1
assert listed.json()[0]["status"] == "pending"
def test_blue_lead_does_not_see_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
listed = api("get", "/api/v1/template-suggestions", blue_lead_headers)
assert listed.json() == []
def test_blue_lead_cannot_approve_a_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", blue_lead_headers)
assert resp.status_code == 403
def test_lead_approves_suggestion_as_is_creates_template(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "approved"
assert body["created_template_id"] is not None
template = api("get", f"/api/v1/test-templates/{body['created_template_id']}", red_lead_headers)
assert template.status_code == 200
assert template.json()["name"] == "Operator-proposed template"
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.json() == []
def test_lead_approves_suggestion_with_edits(api, technique, red_tech_headers, red_lead_headers):
"""The lead can add/adjust information before it goes live — the created
template reflects the lead's edits, not just the operator's original text."""
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api(
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
json={"attack_procedure": "Run a discovery command, then pivot via WMI.", "severity": "high"},
)
assert resp.status_code == 200, resp.text
template_id = resp.json()["created_template_id"]
template = api("get", f"/api/v1/test-templates/{template_id}", red_lead_headers)
assert template.json()["attack_procedure"] == "Run a discovery command, then pivot via WMI."
assert template.json()["severity"] == "high"
# Untouched fields still come from the operator's original submission.
assert template.json()["expected_detection"] == "Check process creation logs."
def test_lead_discards_suggestion_without_creating_template(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "rejected"
assert resp.json()["created_template_id"] is None
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
assert catalog.json() == []
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.json() == []
def test_cannot_review_an_already_reviewed_suggestion_twice(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
assert resp.status_code == 400