Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""add external_case_artifacts table

Revision ID: l1m2n3o4p5q6
Revises: k0l1m2n3o4p5
Create Date: 2026-05-08 16:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

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


def upgrade() -> None:
if op.get_bind().dialect.name == "postgresql":
artifact_kind_enum = postgresql.ENUM(
"screenshot",
"video",
"trace",
"log",
"other",
name="artifact_kind",
create_type=False,
)
artifact_kind_enum.create(op.get_bind(), checkfirst=True)
else:
artifact_kind_enum = sa.Enum("screenshot", "video", "trace", "log", "other", name="artifact_kind")

op.create_table(
"external_case_artifacts",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"case_result_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(
"external_case_results.id",
ondelete="CASCADE",
name="fk_external_case_artifacts_case_result_id",
),
nullable=False,
),
sa.Column("kind", artifact_kind_enum, nullable=False),
sa.Column("filename", sa.String(length=500), nullable=False),
sa.Column("content_type", sa.String(length=200), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("storage_key", sa.String(length=1000), nullable=False),
sa.Column("url", sa.String(length=2000), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index(
"idx_external_case_artifacts_case_result_id",
"external_case_artifacts",
["case_result_id"],
)


def downgrade() -> None:
op.drop_index("idx_external_case_artifacts_case_result_id", table_name="external_case_artifacts")
op.drop_table("external_case_artifacts")

if op.get_bind().dialect.name == "postgresql":
artifact_kind_enum = postgresql.ENUM(
"screenshot",
"video",
"trace",
"log",
"other",
name="artifact_kind",
create_type=False,
)
artifact_kind_enum.drop(op.get_bind(), checkfirst=True)
244 changes: 240 additions & 4 deletions backend/app/api/external_results.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,88 @@
"""API router for External Results — session endpoints (BGSTM#300).
"""API router for External Results — session, case-result, and artifact endpoints.

Implements:
POST /external-results/session – start a run (201 Created)
PATCH /external-results/session/{id} – finish a run
GET /external-results/session/{id} – read a session (runner OR user JWT)
POST /external-results/case – create a case result (BGSTM#303)
PATCH /external-results/case/{id} – update a case result
GET /external-results/case/{id} – read a case result
POST /external-results/artifact – upload an artifact (BGSTM#298)

Case-result endpoints → BGSTM#303
Artifact endpoints → BGSTM#298
Audit-log integration → BGSTM#297
"""

import os
import re
import tempfile
import uuid as _uuid_module
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import (
get_runner_or_user_auth,
require_runner_scope,
)
from app.config import settings
from app.crud.audit_log import write_audit
from app.crud.external_case_artifacts import create_artifact
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.external_case_artifact import ArtifactKind
from app.models.runner_token import RunnerToken
from app.schemas.external_results import (
ArtifactResponse,
CaseResultCreate,
CaseResultResponse,
CaseResultUpdate,
SessionCreate,
SessionFinish,
SessionResponse,
)
from app.storage import get_storage

router = APIRouter()

_WRITE_SCOPE = "external_results:write"
_READ_SCOPE = "external_results:read"

# ---------------------------------------------------------------------------
# Artifact upload constants
# ---------------------------------------------------------------------------

# Read the upload stream in 64 KiB chunks. Tests may monkeypatch this value
# to a smaller number to exercise the streaming / partial-write path.
_ARTIFACT_CHUNK_SIZE: int = 65_536 # 64 KiB

# Content-type allowlist (global). ``artifact_kind.other`` bypasses this check.
_ALLOWED_CONTENT_TYPES: frozenset[str] = frozenset(
{
# Images (screenshot)
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
# Video
"video/webm",
"video/mp4",
"video/mpeg",
# Trace / zip archives
"application/zip",
"application/x-zip-compressed",
"application/octet-stream",
# Logs / structured data
"text/plain",
"application/json",
}
)

# Filename allowlist: must start with an alphanumeric character, only safe characters, max 255 chars.
# Requiring an initial alphanumeric char naturally blocks dot-only names like "." and "..".
_SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")


def _session_to_response(session) -> SessionResponse:
"""Map an ExternalRunSession ORM row to a SessionResponse.
Expand Down Expand Up @@ -346,3 +391,194 @@ async def get_external_case_result(
},
)
return _case_result_to_response(case_result)


# ---------------------------------------------------------------------------
# POST /external-results/artifact — upload an artifact
# ---------------------------------------------------------------------------


def _safe_unlink(path: str) -> None:
"""Remove *path*, silently ignoring missing-file errors."""
try:
os.unlink(path)
except OSError:
pass


@router.post(
"/external-results/artifact",
response_model=ArtifactResponse,
status_code=status.HTTP_201_CREATED,
)
async def upload_artifact(
case_result_id: str = Form(...),
kind: str = Form(...),
filename: str = Form(...),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
) -> ArtifactResponse:
"""Upload a binary artifact attached to an existing case result.

Multipart fields (contract locked at reporter SHA ``ab5d7c1``):
- ``case_result_id`` — UUID string of the owning case result.
- ``kind`` — one of ``screenshot``, ``video``, ``trace``, ``log``, ``other``.
- ``filename`` — original filename including extension.
- ``file`` — binary body; its ``Content-Type`` part header is used as the
artifact content-type.
"""
# --- Validate kind ---
try:
artifact_kind = ArtifactKind(kind)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "validation_error",
"message": f"Invalid artifact kind: {kind!r}. Must be one of {[k.value for k in ArtifactKind]}.",
"details": None,
},
)

# --- Sanitize and validate filename (path-traversal defense) ---
# Reject if the filename differs from its own basename OR fails the allowlist regex.
# This catches directory components (`../`, `subdir/`, `/etc/`) as well as
# dangerous characters (null bytes, backslashes, spaces, etc.).
safe_filename = os.path.basename(filename)
if safe_filename != filename or not _SAFE_FILENAME_RE.fullmatch(safe_filename):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "validation_error",
"message": (
f"filename {filename!r} is not safe. filename must contain only "
"[A-Za-z0-9._-] characters (1–255) with no path separators."
),
"details": None,
},
)

# --- Validate case_result_id ---
try:
case_result_uuid = _uuid_module.UUID(case_result_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "validation_error",
"message": f"case_result_id {case_result_id!r} is not a valid UUID.",
"details": None,
},
)

# --- Verify the case result exists ---
case_result = await get_case_result(db, case_result_uuid)
if case_result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "case_result.not_found",
"message": f"Case result {case_result_id} does not exist.",
"details": None,
},
)

# --- Derive content-type from the upload part header ---
content_type: str = (file.content_type or "application/octet-stream").split(";")[0].strip().lower()

# --- Validate content-type (bypass for kind=other) ---
if artifact_kind != ArtifactKind.other and content_type not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail={
"code": "artifact.unsupported_type",
"message": (
f"Content-Type {content_type!r} is not allowed for kind {kind!r}. "
f"Allowed types: {sorted(_ALLOWED_CONTENT_TYPES)}."
),
"details": None,
},
)

# --- Stream to a temp file, enforcing max size ---
max_bytes: int = settings.BGSTM_ARTIFACT_MAX_BYTES
fd, tmp_path = tempfile.mkstemp(prefix="bgstm_artifact_")
total_bytes = 0
try:
with os.fdopen(fd, "wb") as fp:
while True:
chunk = await file.read(_ARTIFACT_CHUNK_SIZE)
if not chunk:
break
fp.write(chunk)
total_bytes += len(chunk)
if total_bytes > max_bytes:
# Partial data written — clean up and reject
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail={
"code": "artifact.too_large",
"message": (f"Artifact exceeds the maximum allowed size of {max_bytes} bytes."),
"details": None,
},
)
except HTTPException:
_safe_unlink(tmp_path)
raise
except Exception:
_safe_unlink(tmp_path)
raise

# --- Persist via storage backend ---
storage = get_storage()
storage_key = f"{case_result_id}/{_uuid_module.uuid4().hex}/{safe_filename}"

try:
with open(tmp_path, "rb") as fp:
result = storage.save(fp, key=storage_key, content_type=content_type)
finally:
_safe_unlink(tmp_path)

# --- Create DB record ---
artifact = await create_artifact(
db,
case_result_id=case_result_uuid,
kind=artifact_kind,
filename=safe_filename,
content_type=content_type,
size_bytes=result.size_bytes,
storage_key=result.key,
url=result.url,
)

# --- Audit log (all five fields required by smoke/assert.py) ---
await write_audit(
db,
actor_kind="runner_token",
actor_id=token.id,
action="external_results.artifact.upload",
resource_type="external_case_artifact",
resource_id=artifact.id,
details={
"case_result_id": str(case_result_uuid),
"kind": artifact_kind.value,
"size_bytes": result.size_bytes,
"filename": safe_filename,
"content_type": content_type,
},
)

await db.commit()
await db.refresh(artifact)

return ArtifactResponse(
id=artifact.id,
case_result_id=artifact.case_result_id,
kind=artifact.kind,
filename=artifact.filename,
content_type=artifact.content_type,
size_bytes=artifact.size_bytes,
url=artifact.url,
created_at=artifact.created_at,
)
6 changes: 6 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ALGORITHM: str = "HS256"

# Artifact storage
BGSTM_STORAGE_BACKEND: str = "local" # "local" or "s3"
BGSTM_ARTIFACTS_DIR: str = "./artifacts"
BGSTM_ARTIFACT_MAX_BYTES: int = 52_428_800 # 50 MiB
BGSTM_ARTIFACT_URL_PREFIX: str = "http://localhost:8000/artifacts"

class Config:
env_file = ".env"

Expand Down
Loading
Loading