Skip to content
96 changes: 96 additions & 0 deletions backend/alembic/versions/k0l1m2n3o4p5_add_external_case_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""add external_case_results table

Revision ID: k0l1m2n3o4p5
Revises: i8j9k0l1m2n3
Create Date: 2026-05-08 12:50:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "k0l1m2n3o4p5"
down_revision: Union[str, None] = "i8j9k0l1m2n3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
columns = inspect(bind).get_columns(table_name)
return any(column["name"] == column_name for column in columns)


def upgrade() -> None:
if op.get_bind().dialect.name == "postgresql":
case_outcome_enum = postgresql.ENUM(
"started",
"passed",
"failed",
"skipped",
"flaky",
"aborted",
name="case_outcome",
create_type=False,
)
case_outcome_enum.create(op.get_bind(), checkfirst=True)
else:
case_outcome_enum = sa.Enum("started", "passed", "failed", "skipped", "flaky", "aborted", name="case_outcome")

op.create_table(
"external_case_results",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("external_run_sessions.id", name="fk_external_case_results_session_id"),
nullable=False,
),
sa.Column(
"test_case_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("test_cases.id", name="fk_external_case_results_test_case_id"),
nullable=True,
),
sa.Column("external_id", sa.String(length=500), nullable=True),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("outcome", case_outcome_enum, nullable=False),
sa.Column("duration_ms", sa.Integer(), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("auto_registered", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("duration_ms >= 0", name="ck_external_case_results_duration_ms_nonnegative"),
)
op.create_index("idx_external_case_results_session_id", "external_case_results", ["session_id"])
op.create_index(
"uq_external_case_results_session_external_id",
"external_case_results",
["session_id", "external_id"],
unique=True,
postgresql_where=sa.text("external_id IS NOT NULL"),
)

if not _has_column("test_cases", "auto_registered"):
op.add_column(
"test_cases",
sa.Column("auto_registered", sa.Boolean(), nullable=False, server_default=sa.false()),
)


def downgrade() -> None:
if _has_column("test_cases", "auto_registered"):
op.drop_column("test_cases", "auto_registered")

op.drop_index("uq_external_case_results_session_external_id", table_name="external_case_results")
op.drop_index("idx_external_case_results_session_id", table_name="external_case_results")
op.drop_table("external_case_results")

if op.get_bind().dialect.name == "postgresql":
op.execute("DROP TYPE IF EXISTS case_outcome")
228 changes: 185 additions & 43 deletions backend/app/api/external_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,26 @@

from uuid import UUID

from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import (
get_current_runner_token, # noqa: F401 — used inside _get_session_auth
get_runner_or_user_auth,
require_runner_scope,
)
from app.crud.audit_log import write_audit
from app.crud.external_case_results import create_case_result, get_case_result, update_case_result
from app.crud.external_results import create_session, finish_session_db, get_session
from app.db.session import get_db
from app.models.runner_token import RunnerToken
from app.schemas.external_results import SessionCreate, SessionFinish, SessionResponse
from app.schemas.external_results import (
CaseResultCreate,
CaseResultResponse,
CaseResultUpdate,
SessionCreate,
SessionFinish,
SessionResponse,
)

router = APIRouter()

Expand Down Expand Up @@ -52,6 +60,22 @@ def _session_to_response(session) -> SessionResponse:
)


def _case_result_to_response(case_result) -> CaseResultResponse:
return CaseResultResponse(
id=case_result.id,
session_id=case_result.session_id,
test_case_id=case_result.test_case_id,
external_id=case_result.external_id,
title=case_result.title,
outcome=case_result.outcome,
duration_ms=case_result.duration_ms,
error_message=case_result.error_message,
requirement_ids=getattr(case_result, "requirement_ids", []),
created_at=case_result.created_at,
auto_registered=case_result.auto_registered,
)


# ---------------------------------------------------------------------------
# POST /external-results/session — start a run
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -143,53 +167,14 @@ async def finish_external_session(
# ---------------------------------------------------------------------------


async def _get_session_auth(
authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db),
):
"""Accept either a runner token or a user JWT for read access.

We attempt runner-token resolution first; on failure we fall back to user
JWT. A 401 is raised only when both paths fail.
"""
# Try runner-token path
if authorization and authorization.lower().startswith("bearer bgstm_runner_"):
from app.auth.dependencies import get_current_runner_token as _get_runner

try:
return await _get_runner(authorization=authorization, db=db)
except HTTPException:
pass

# Fall back to user-JWT path via the bearer scheme

from app.auth.security import decode_access_token
from app.crud.user import get_user

if authorization and authorization.lower().startswith("bearer "):
raw_token = authorization.split(" ", 1)[1]
payload = decode_access_token(raw_token)
if payload is not None:
user_id = payload.get("sub")
if user_id:
user = await get_user(db, user_id)
if user and user.is_active:
return user

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "runner_token.invalid", "message": "Missing or invalid credentials.", "details": None},
)


@router.get(
"/external-results/session/{session_id}",
response_model=SessionResponse,
)
async def get_external_session(
session_id: UUID,
db: AsyncSession = Depends(get_db),
_auth=Depends(_get_session_auth),
_auth=Depends(get_runner_or_user_auth),
) -> SessionResponse:
"""Return a single session by ID.

Expand All @@ -204,3 +189,160 @@ async def get_external_session(
)

return _session_to_response(session)


# ---------------------------------------------------------------------------
# POST /external-results/case — create a case result
# ---------------------------------------------------------------------------


@router.post(
"/external-results/case",
response_model=CaseResultResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_external_case_result(
payload: CaseResultCreate,
response: Response,
db: AsyncSession = Depends(get_db),
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
) -> CaseResultResponse:
try:
case_result, created = await create_case_result(
db,
session_id=payload.session_id,
payload=payload,
runner_token_id=token.id,
)
except ValueError as exc:
detail = exc.args[0]
if isinstance(detail, dict) and detail.get("code") in {"case.session_not_found", "case.test_case_not_found"}:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
raise

if created:
await write_audit(
db,
actor_kind="runner_token",
actor_id=token.id,
action="external_results.case.create",
resource_type="external_case_result",
resource_id=case_result.id,
details={
"session_id": str(case_result.session_id),
"outcome": case_result.outcome.value,
"external_id": case_result.external_id,
"test_case_id": str(case_result.test_case_id) if case_result.test_case_id is not None else None,
"auto_registered": case_result.auto_registered,
"unresolved_requirement_ids": [
str(requirement_id) for requirement_id in getattr(case_result, "unresolved_requirement_ids", [])
],
},
)
else:
response.status_code = status.HTTP_200_OK
await write_audit(
db,
actor_kind="runner_token",
actor_id=token.id,
action="external_results.case.create.idempotent",
resource_type="external_case_result",
resource_id=case_result.id,
details={
"matched_case_result_id": str(case_result.id),
"reason": "external_id_collision",
},
)

return _case_result_to_response(case_result)


# ---------------------------------------------------------------------------
# PATCH /external-results/case/{case_result_id} — update a case result
# ---------------------------------------------------------------------------


@router.patch(
"/external-results/case/{case_result_id}",
response_model=CaseResultResponse,
)
async def patch_external_case_result(
case_result_id: UUID,
payload: CaseResultUpdate,
db: AsyncSession = Depends(get_db),
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
) -> CaseResultResponse:
previous = await get_case_result(db, case_result_id)
if previous is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "case.not_found",
"message": f"Case result {case_result_id} does not exist.",
"details": None,
},
)

previous_outcome = previous.outcome.value

try:
case_result = await update_case_result(db, case_result_id=case_result_id, payload=payload)
except ValueError as exc:
detail = exc.args[0]
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
if case_result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "case.not_found",
"message": f"Case result {case_result_id} does not exist.",
"details": None,
},
)

details = {
"previous_outcome": previous_outcome,
"new_outcome": case_result.outcome.value,
}
if payload.duration_ms is not None:
details["duration_ms"] = payload.duration_ms
if payload.error_message is not None:
details["error_message"] = payload.error_message

await write_audit(
db,
actor_kind="runner_token",
actor_id=token.id,
action="external_results.case.update",
resource_type="external_case_result",
resource_id=case_result.id,
details=details,
)
return _case_result_to_response(case_result)


# ---------------------------------------------------------------------------
# GET /external-results/case/{case_result_id} — read a case result
# ---------------------------------------------------------------------------


@router.get(
"/external-results/case/{case_result_id}",
response_model=CaseResultResponse,
)
async def get_external_case_result(
case_result_id: UUID,
db: AsyncSession = Depends(get_db),
_auth=Depends(get_runner_or_user_auth),
) -> CaseResultResponse:
case_result = await get_case_result(db, case_result_id)
if case_result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "case.not_found",
"message": f"Case result {case_result_id} does not exist.",
"details": None,
},
)
return _case_result_to_response(case_result)
Loading
Loading