diff --git a/backend/alembic/versions/n3o4p5q6r7s8_add_projects_table.py b/backend/alembic/versions/n3o4p5q6r7s8_add_projects_table.py new file mode 100644 index 0000000..a548e9e --- /dev/null +++ b/backend/alembic/versions/n3o4p5q6r7s8_add_projects_table.py @@ -0,0 +1,38 @@ +"""add projects table + +Revision ID: n3o4p5q6r7s8 +Revises: m2n3o4p5q6r7 +Create Date: 2026-05-08 19:10: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 = "n3o4p5q6r7s8" +down_revision: Union[str, None] = "m2n3o4p5q6r7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "projects", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + 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.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_projects_name", "projects", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_projects_name", table_name="projects") + op.drop_table("projects") diff --git a/backend/app/api/external_results.py b/backend/app/api/external_results.py index 4685546..6b7849d 100644 --- a/backend/app/api/external_results.py +++ b/backend/app/api/external_results.py @@ -145,7 +145,13 @@ async def create_external_session( normalized_payload = payload.model_copy( update={"runner": payload.runner if payload.runner is not None else _DEFAULT_RUNNER} ) - session = await create_session(db, payload=normalized_payload, runner_token_id=token.id) + try: + session = await create_session(db, payload=normalized_payload, runner_token_id=token.id) + except ValueError as exc: + detail = exc.args[0] + if isinstance(detail, dict) and detail.get("code") == "session.project_not_found": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc + raise await write_audit( db, actor_kind="runner_token", diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..1ae1108 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,102 @@ +"""API endpoints for Projects.""" + +import math +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user, require_reviewer_or_admin +from app.crud import project as crud +from app.crud.audit_log import write_audit +from app.db.session import get_db +from app.models.user import User +from app.schemas.pagination import PaginatedResponse +from app.schemas.project import ProjectCreate, ProjectResponse, ProjectUpdate + +router = APIRouter() + + +@router.post("/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_project( + payload: ProjectCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reviewer_or_admin), +) -> ProjectResponse: + project = await crud.create_project(db, payload) + await write_audit( + db, + actor_kind="user", + actor_id=current_user.id, + action="project.create", + resource_type="project", + resource_id=project.id, + details=payload.model_dump(), + ) + return project + + +@router.get("/projects", response_model=PaginatedResponse[ProjectResponse]) +async def list_projects( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +) -> PaginatedResponse[ProjectResponse]: + skip = (page - 1) * page_size + items, total = await crud.list_projects(db, skip=skip, limit=page_size) + return PaginatedResponse( + items=items, + total=total, + page=page, + page_size=page_size, + pages=math.ceil(total / page_size) if total > 0 else 0, + ) + + +@router.get("/projects/{project_id}", response_model=ProjectResponse) +async def get_project( + project_id: UUID, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +) -> ProjectResponse: + project = await crud.get_project(db, project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found") + return project + + +@router.patch("/projects/{project_id}", response_model=ProjectResponse) +async def update_project( + project_id: UUID, + payload: ProjectUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reviewer_or_admin), +) -> ProjectResponse: + existing = await crud.get_project(db, project_id) + if existing is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found") + + changed_fields = payload.model_dump(exclude_unset=True) + original_values = {field: getattr(existing, field) for field in changed_fields} + updated = await crud.update_project(db, project_id, payload) + if updated is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found") + + diff: dict[str, dict[str, Any]] = {} + for field, new_value in changed_fields.items(): + old_value = original_values[field] + if old_value != new_value: + diff[field] = {"from": old_value, "to": new_value} + + await write_audit( + db, + actor_kind="user", + actor_id=current_user.id, + action="project.update", + resource_type="project", + resource_id=updated.id, + details=diff, + ) + return updated diff --git a/backend/app/crud/external_results.py b/backend/app/crud/external_results.py index 0070268..164aefa 100644 --- a/backend/app/crud/external_results.py +++ b/backend/app/crud/external_results.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.external_results import ExternalRunSession, RunStatus +from app.models.project import Project from app.schemas.external_results import SessionCreate, SessionFinish # Terminal statuses — no further transitions allowed once reached. @@ -31,6 +32,17 @@ async def create_session( of creating a duplicate. """ + project_result = await db.execute(select(Project.id).where(Project.id == payload.project_id)) + project_id = project_result.scalar_one_or_none() + if project_id is None: + raise ValueError( + { + "code": "session.project_not_found", + "message": f"Project {payload.project_id} does not exist.", + "details": None, + } + ) + cutoff = datetime.now(tz=timezone.utc).replace(tzinfo=None) - timedelta(seconds=_IDEMPOTENCY_WINDOW_SECONDS) # Normalise ci_url to a plain string so we can compare it. diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py new file mode 100644 index 0000000..19b1da0 --- /dev/null +++ b/backend/app/crud/project.py @@ -0,0 +1,42 @@ +"""CRUD operations for Projects.""" + +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.project import Project +from app.schemas.project import ProjectCreate, ProjectUpdate + + +async def create_project(db: AsyncSession, payload: ProjectCreate) -> Project: + project = Project(**payload.model_dump()) + db.add(project) + await db.commit() + await db.refresh(project) + return project + + +async def get_project(db: AsyncSession, project_id: UUID) -> Project | None: + result = await db.execute(select(Project).where(Project.id == project_id)) + return result.scalar_one_or_none() + + +async def list_projects(db: AsyncSession, skip: int = 0, limit: int = 100) -> tuple[list[Project], int]: + count_result = await db.execute(select(func.count()).select_from(Project)) + total = count_result.scalar_one() + result = await db.execute(select(Project).offset(skip).limit(limit)) + return list(result.scalars().all()), total + + +async def update_project(db: AsyncSession, project_id: UUID, payload: ProjectUpdate) -> Project | None: + project = await get_project(db, project_id) + if project is None: + return None + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(project, field, value) + + await db.commit() + await db.refresh(project) + return project diff --git a/backend/app/main.py b/backend/app/main.py index 76fc1cc..c31eb2c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ external_results, links, notifications, + projects, requirements, suggestions, test_cases, @@ -44,6 +45,7 @@ app.include_router(users.router, prefix=settings.API_V1_PREFIX, tags=["users"]) app.include_router(notifications.router, prefix=settings.API_V1_PREFIX, tags=["notifications"]) app.include_router(external_results.router, prefix=settings.API_V1_PREFIX, tags=["external_results"]) +app.include_router(projects.router, prefix=settings.API_V1_PREFIX, tags=["projects"]) # Dev-only static route: serve local artifact files when BGSTM_STORAGE_BACKEND=local. # This is intentionally NOT mounted in production (S3 or other remote backends). diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 88d9308..d475280 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from .external_case_result import ExternalCaseResult from .link import LinkSource, LinkType, RequirementTestCaseLink from .notification import Notification, NotificationType +from .project import Project from .requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType from .runner_token import RunnerToken from .suggestion import LinkSuggestion, SuggestionMethod, SuggestionStatus @@ -23,6 +24,7 @@ "ExternalCaseResult", "Notification", "NotificationType", + "Project", "Requirement", "RequirementType", "PriorityLevel", diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..2abb765 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,17 @@ +import uuid + +from sqlalchemy import Column, String, Text + +from .base import Base, TimestampMixin +from .requirement import GUID + + +class Project(Base, TimestampMixin): + __tablename__ = "projects" + + id = Column(GUID(), primary_key=True, default=uuid.uuid4) + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..0ea6b97 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,26 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class ProjectBase(BaseModel): + name: str = Field(..., max_length=255) + description: str | None = None + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectUpdate(BaseModel): + name: str | None = Field(None, max_length=255) + description: str | None = None + + +class ProjectResponse(ProjectBase): + id: UUID + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/api/test_external_results_audit.py b/backend/tests/api/test_external_results_audit.py index be3b1c5..f49381d 100644 --- a/backend/tests/api/test_external_results_audit.py +++ b/backend/tests/api/test_external_results_audit.py @@ -16,8 +16,11 @@ from app.db.session import get_db from app.main import app from app.models.base import Base +from app.models.project import Project from app.models.user import User, UserRole +_PROJECT_ID = str(uuid.uuid4()) + def _make_user(role: UserRole = UserRole.admin) -> User: return User( @@ -37,7 +40,7 @@ def _auth_header(plaintext: str) -> dict[str, str]: def _session_payload() -> dict[str, str | dict[str, str]]: return { "runner": "pytest-bgstm@1.0.0", - "project_id": str(uuid.uuid4()), + "project_id": _PROJECT_ID, "git_sha": "abc123", "git_branch": "main", "ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}", @@ -53,6 +56,9 @@ async def db_session(): factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: + project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}") + session.add(project) + await session.commit() async def _override_get_db(): yield session diff --git a/backend/tests/integration/test_external_results_session.py b/backend/tests/integration/test_external_results_session.py index 4a32852..1ea4964 100644 --- a/backend/tests/integration/test_external_results_session.py +++ b/backend/tests/integration/test_external_results_session.py @@ -27,6 +27,7 @@ from app.db.session import get_db from app.main import app from app.models.base import Base +from app.models.project import Project from app.models.user import User, UserRole # --------------------------------------------------------------------------- @@ -94,15 +95,24 @@ async def read_only_token(db_session): ) -_PROJECT_ID = str(uuid.uuid4()) -_SESSION_PAYLOAD = { - "runner": "pytest-bgstm@1.0.0", - "project_id": _PROJECT_ID, - "git_sha": "abc123", - "git_branch": "main", - "ci_url": "https://ci.example.com/runs/1", - "metadata": {"os": "ubuntu-22.04"}, -} +@pytest_asyncio.fixture +async def project_id(db_session) -> str: + project = Project(id=uuid.uuid4(), name=f"project-{uuid.uuid4().hex[:6]}") + db_session.add(project) + await db_session.commit() + return str(project.id) + + +def _session_payload(project_id: str) -> dict[str, str | dict[str, str]]: + return { + "runner": "pytest-bgstm@1.0.0", + "project_id": project_id, + "git_sha": "abc123", + "git_branch": "main", + "ci_url": "https://ci.example.com/runs/1", + "metadata": {"os": "ubuntu-22.04"}, + } + # --------------------------------------------------------------------------- # Helper @@ -119,13 +129,13 @@ def _auth_header(plaintext: str) -> dict[str, str]: class TestSessionHappyPath: - def test_create_finish_fetch(self, db_session, write_token): + def test_create_finish_fetch(self, db_session, write_token, project_id): _model, plaintext = write_token headers = _auth_header(plaintext) with TestClient(app) as client: # 1. Create - resp = client.post("/api/v1/external-results/session", json=_SESSION_PAYLOAD, headers=headers) + resp = client.post("/api/v1/external-results/session", json=_session_payload(project_id), headers=headers) assert resp.status_code == 201, resp.text data = resp.json() assert data["status"] == "started" @@ -151,10 +161,10 @@ def test_create_finish_fetch(self, db_session, write_token): assert data3["status"] == "passed" assert data3["id"] == session_id - def test_create_without_runner_defaults_to_bgstm_playwright_core(self, db_session, write_token): + def test_create_without_runner_defaults_to_bgstm_playwright_core(self, db_session, write_token, project_id): _model, plaintext = write_token headers = _auth_header(plaintext) - payload = dict(_SESSION_PAYLOAD) + payload = _session_payload(project_id) payload.pop("runner") with TestClient(app) as client: @@ -171,9 +181,9 @@ def test_create_without_runner_defaults_to_bgstm_playwright_core(self, db_sessio class TestSessionAuth401: - def test_create_without_auth_returns_401(self, db_session): + def test_create_without_auth_returns_401(self, db_session, project_id): with TestClient(app) as client: - resp = client.post("/api/v1/external-results/session", json=_SESSION_PAYLOAD) + resp = client.post("/api/v1/external-results/session", json=_session_payload(project_id)) # get_current_runner_token uses Header(...) (required); FastAPI returns 422 when # the Authorization header is absent before the dependency can raise 401. assert resp.status_code in (401, 422) @@ -200,17 +210,17 @@ def test_get_without_auth_returns_401(self, db_session): class TestSessionAuth403: - def test_create_read_only_token_returns_403(self, db_session, read_only_token): + def test_create_read_only_token_returns_403(self, db_session, read_only_token, project_id): _model, plaintext = read_only_token with TestClient(app) as client: resp = client.post( "/api/v1/external-results/session", - json=_SESSION_PAYLOAD, + json=_session_payload(project_id), headers=_auth_header(plaintext), ) assert resp.status_code == 403 - def test_patch_read_only_token_returns_403(self, db_session, write_token, read_only_token): + def test_patch_read_only_token_returns_403(self, db_session, write_token, read_only_token, project_id): _wm, write_pt = write_token _rm, read_pt = read_only_token @@ -218,7 +228,7 @@ def test_patch_read_only_token_returns_403(self, db_session, write_token, read_o # Create with write token resp = client.post( "/api/v1/external-results/session", - json=_SESSION_PAYLOAD, + json=_session_payload(project_id), headers=_auth_header(write_pt), ) assert resp.status_code == 201 @@ -239,10 +249,10 @@ def test_patch_read_only_token_returns_403(self, db_session, write_token, read_o class TestSessionTransitions: - def _create_and_finish(self, client, headers, finish_status: str) -> str: + def _create_and_finish(self, client, headers, finish_status: str, project_id: str) -> str: """Helper: create a session and finish it; return session_id.""" # Use a unique ci_url to avoid idempotency collision across tests. - payload = dict(_SESSION_PAYLOAD, ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}") + payload = dict(_session_payload(project_id), ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}") resp = client.post("/api/v1/external-results/session", json=payload, headers=headers) assert resp.status_code == 201 session_id = resp.json()["id"] @@ -255,12 +265,12 @@ def _create_and_finish(self, client, headers, finish_status: str) -> str: assert resp2.status_code == 200 return session_id - def test_patch_aborted_session_returns_409(self, db_session, write_token): + def test_patch_aborted_session_returns_409(self, db_session, write_token, project_id): _model, plaintext = write_token headers = _auth_header(plaintext) with TestClient(app) as client: - session_id = self._create_and_finish(client, headers, "aborted") + session_id = self._create_and_finish(client, headers, "aborted", project_id) # Attempt to transition again resp = client.patch( @@ -270,12 +280,12 @@ def test_patch_aborted_session_returns_409(self, db_session, write_token): ) assert resp.status_code == 409 - def test_patch_passed_session_returns_409(self, db_session, write_token): + def test_patch_passed_session_returns_409(self, db_session, write_token, project_id): _model, plaintext = write_token headers = _auth_header(plaintext) with TestClient(app) as client: - session_id = self._create_and_finish(client, headers, "passed") + session_id = self._create_and_finish(client, headers, "passed", project_id) # Attempt to transition again (failed is also terminal → 409) resp = client.patch( @@ -285,13 +295,13 @@ def test_patch_passed_session_returns_409(self, db_session, write_token): ) assert resp.status_code == 409 - def test_patch_started_to_started_returns_422(self, db_session, write_token): + def test_patch_started_to_started_returns_422(self, db_session, write_token, project_id): """SessionFinish rejects non-terminal statuses at the Pydantic layer (422).""" _model, plaintext = write_token headers = _auth_header(plaintext) with TestClient(app) as client: - payload = dict(_SESSION_PAYLOAD, ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}") + payload = dict(_session_payload(project_id), ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}") resp = client.post("/api/v1/external-results/session", json=payload, headers=headers) assert resp.status_code == 201 session_id = resp.json()["id"] @@ -311,16 +321,16 @@ def test_patch_started_to_started_returns_422(self, db_session, write_token): class TestSessionIdempotency: - def test_duplicate_post_returns_same_session(self, db_session, write_token): + def test_duplicate_post_returns_same_session(self, db_session, write_token, project_id): _model, plaintext = write_token headers = _auth_header(plaintext) with TestClient(app) as client: - resp1 = client.post("/api/v1/external-results/session", json=_SESSION_PAYLOAD, headers=headers) + resp1 = client.post("/api/v1/external-results/session", json=_session_payload(project_id), headers=headers) assert resp1.status_code == 201 id1 = resp1.json()["id"] - resp2 = client.post("/api/v1/external-results/session", json=_SESSION_PAYLOAD, headers=headers) + resp2 = client.post("/api/v1/external-results/session", json=_session_payload(project_id), headers=headers) assert resp2.status_code == 201 id2 = resp2.json()["id"] diff --git a/backend/tests/integration/test_external_results_session_project_fk.py b/backend/tests/integration/test_external_results_session_project_fk.py new file mode 100644 index 0000000..de5a0b6 --- /dev/null +++ b/backend/tests/integration/test_external_results_session_project_fk.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.crud.runner_token import create_runner_token +from app.db.session import get_db +from app.main import app +from app.models.base import Base +from app.models.project import Project +from app.models.user import User, UserRole + + +def _auth_header(plaintext: str) -> dict[str, str]: + return {"Authorization": f"Bearer {plaintext}"} + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + + async def _override_get_db(): + yield session + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def write_token(db_session): + admin = User( + id=uuid.uuid4(), + email=f"admin-{uuid.uuid4().hex[:6]}@example.com", + hashed_password="hashed", + full_name="Admin", + role=UserRole.admin, + is_active=True, + ) + db_session.add(admin) + await db_session.commit() + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin.id, + ) + + +def _session_payload(project_id: str) -> dict: + return { + "runner": "pytest-bgstm@1.0.0", + "project_id": project_id, + "git_sha": "abc123", + "git_branch": "main", + "ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}", + "metadata": {}, + } + + +@pytest.mark.asyncio +async def test_create_session_unknown_project_returns_400(db_session, write_token): + _model, plaintext = write_token + + with TestClient(app) as client: + response = client.post( + "/api/v1/external-results/session", + json=_session_payload(str(uuid.uuid4())), + headers=_auth_header(plaintext), + ) + + assert response.status_code == 400, response.text + detail = response.json()["detail"] + assert detail["code"] == "session.project_not_found" + + +@pytest.mark.asyncio +async def test_create_session_valid_project_returns_201(db_session, write_token): + project = Project(id=uuid.uuid4(), name="fk-project") + db_session.add(project) + await db_session.commit() + + _model, plaintext = write_token + with TestClient(app) as client: + response = client.post( + "/api/v1/external-results/session", + json=_session_payload(str(project.id)), + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["project_id"] == str(project.id) diff --git a/backend/tests/integration/test_projects.py b/backend/tests/integration/test_projects.py new file mode 100644 index 0000000..b64c378 --- /dev/null +++ b/backend/tests/integration/test_projects.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.auth.dependencies import get_current_user +from app.db.session import get_db +from app.main import app +from app.models.audit_log import AuditLog +from app.models.base import Base +from app.models.project import Project +from app.models.user import User, UserRole + + +def _make_user(role: UserRole) -> User: + return User( + id=uuid.uuid4(), + email=f"{role.value}-{uuid.uuid4().hex[:6]}@example.com", + hashed_password="hashed", + full_name=f"{role.value.capitalize()} User", + role=role, + is_active=True, + ) + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + + async def _override_get_db(): + yield session + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def users(db_session): + admin = _make_user(UserRole.admin) + reviewer = _make_user(UserRole.reviewer) + viewer = _make_user(UserRole.viewer) + db_session.add_all([admin, reviewer, viewer]) + await db_session.commit() + return {"admin": admin, "reviewer": reviewer, "viewer": viewer} + + +def _set_current_user(user: User) -> None: + async def _override_user(): + return user + + app.dependency_overrides[get_current_user] = _override_user + + +def _clear_current_user() -> None: + app.dependency_overrides.pop(get_current_user, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("role", "expected_status"), + [("admin", 201), ("reviewer", 201), ("viewer", 403)], +) +async def test_create_project_auth_and_audit(db_session, users, role: str, expected_status: int): + _set_current_user(users[role]) + try: + with TestClient(app) as client: + response = client.post("/api/v1/projects", json={"name": "Project A", "description": "Created in test"}) + finally: + _clear_current_user() + + assert response.status_code == expected_status, response.text + + if expected_status == 201: + data = response.json() + assert data["name"] == "Project A" + assert data["description"] == "Created in test" + + created = await db_session.execute(select(Project).where(Project.id == uuid.UUID(data["id"]))) + assert created.scalar_one().name == "Project A" + + audit_result = await db_session.execute( + select(AuditLog).where(AuditLog.action == "project.create").order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert audit.actor_kind == "user" + assert audit.user_id == users[role].id + assert audit.actor_token_id is None + assert audit.resource_type == "project" + assert audit.resource_id == data["id"] + assert audit.details == {"name": "Project A", "description": "Created in test"} + + +@pytest.mark.asyncio +async def test_create_project_unauthenticated(db_session): + with TestClient(app) as client: + response = client.post("/api/v1/projects", json={"name": "Project A"}) + assert response.status_code in (401, 403) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("role", ["admin", "reviewer", "viewer"]) +async def test_list_and_get_projects_any_authenticated_role(db_session, users, role: str): + project = Project(id=uuid.uuid4(), name="Project B", description="Existing") + db_session.add(project) + await db_session.commit() + + _set_current_user(users[role]) + try: + with TestClient(app) as client: + list_response = client.get("/api/v1/projects") + get_response = client.get(f"/api/v1/projects/{project.id}") + missing_response = client.get(f"/api/v1/projects/{uuid.uuid4()}") + finally: + _clear_current_user() + + assert list_response.status_code == 200, list_response.text + list_data = list_response.json() + assert list_data["total"] >= 1 + assert list_data["page"] == 1 + assert isinstance(list_data["items"], list) + assert any(item["id"] == str(project.id) for item in list_data["items"]) + + assert get_response.status_code == 200, get_response.text + assert get_response.json()["id"] == str(project.id) + assert missing_response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_projects_unauthenticated(db_session): + with TestClient(app) as client: + response = client.get("/api/v1/projects") + assert response.status_code in (401, 403) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("role", "expected_status"), + [("admin", 200), ("reviewer", 200), ("viewer", 403)], +) +async def test_patch_project_auth_and_audit(db_session, users, role: str, expected_status: int): + project = Project(id=uuid.uuid4(), name="Before Name", description="Before Desc") + db_session.add(project) + await db_session.commit() + + _set_current_user(users[role]) + try: + with TestClient(app) as client: + response = client.patch( + f"/api/v1/projects/{project.id}", + json={"name": "After Name", "description": "After Desc"}, + ) + finally: + _clear_current_user() + + assert response.status_code == expected_status, response.text + + if expected_status == 200: + data = response.json() + assert data["name"] == "After Name" + assert data["description"] == "After Desc" + + audit_result = await db_session.execute( + select(AuditLog).where(AuditLog.action == "project.update").order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert audit.actor_kind == "user" + assert audit.user_id == users[role].id + assert audit.actor_token_id is None + assert audit.resource_type == "project" + assert audit.resource_id == str(project.id) + assert audit.details == { + "name": {"from": "Before Name", "to": "After Name"}, + "description": {"from": "Before Desc", "to": "After Desc"}, + } + + +@pytest.mark.asyncio +async def test_patch_project_not_found(db_session, users): + _set_current_user(users["admin"]) + try: + with TestClient(app) as client: + response = client.patch(f"/api/v1/projects/{uuid.uuid4()}", json={"name": "Nope"}) + finally: + _clear_current_user() + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_patch_project_unauthenticated(db_session): + with TestClient(app) as client: + response = client.patch(f"/api/v1/projects/{uuid.uuid4()}", json={"name": "No Auth"}) + assert response.status_code in (401, 403) diff --git a/backend/tests/test_external_results_artifact.py b/backend/tests/test_external_results_artifact.py index 1e6a412..b2d99e9 100644 --- a/backend/tests/test_external_results_artifact.py +++ b/backend/tests/test_external_results_artifact.py @@ -31,6 +31,7 @@ from app.models.audit_log import AuditLog from app.models.base import Base from app.models.external_case_artifact import ExternalCaseArtifact +from app.models.project import Project from app.models.user import User, UserRole from app.storage.s3 import S3Backend @@ -38,6 +39,8 @@ # Helpers # --------------------------------------------------------------------------- +_PROJECT_ID = str(uuid.uuid4()) + def _auth_header(plaintext: str) -> dict[str, str]: return {"Authorization": f"Bearer {plaintext}"} @@ -67,6 +70,9 @@ async def db_session(): factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: + project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}") + session.add(project) + await session.commit() async def _override_get_db(): yield session @@ -99,7 +105,7 @@ async def write_token(db_session, admin_user): def _session_payload() -> dict: return { "runner": "pytest-bgstm@1.0.0", - "project_id": str(uuid.uuid4()), + "project_id": _PROJECT_ID, "git_sha": "abc123", "git_branch": "main", "ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}", diff --git a/backend/tests/test_external_results_case.py b/backend/tests/test_external_results_case.py index f998f52..a6ccd23 100644 --- a/backend/tests/test_external_results_case.py +++ b/backend/tests/test_external_results_case.py @@ -17,10 +17,13 @@ from app.models.base import Base from app.models.external_case_result import CaseStatus, ExternalCaseResult from app.models.link import RequirementTestCaseLink +from app.models.project import Project from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType from app.models.test_case import TestCase, TestCaseStatus, TestCaseType from app.models.user import User, UserRole +_PROJECT_ID = str(uuid.uuid4()) + def _auth_header(plaintext: str) -> dict[str, str]: return {"Authorization": f"Bearer {plaintext}"} @@ -40,7 +43,7 @@ def _make_user(role: UserRole = UserRole.admin) -> User: def _session_payload() -> dict[str, str | dict[str, str]]: return { "runner": "pytest-bgstm@1.0.0", - "project_id": str(uuid.uuid4()), + "project_id": _PROJECT_ID, "git_sha": "abc123", "git_branch": "main", "ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}", @@ -56,6 +59,9 @@ async def db_session(): factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: + project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}") + session.add(project) + await session.commit() async def _override_get_db(): yield session diff --git a/docs/specs/external_results_v1.md b/docs/specs/external_results_v1.md index cb86c6d..14926e3 100644 --- a/docs/specs/external_results_v1.md +++ b/docs/specs/external_results_v1.md @@ -71,6 +71,14 @@ Read-only endpoints also accept a standard user JWT (`Authorization: Bearer dict[str, An return data -def _get_or_generate_project_id(client: httpx.Client, headers: dict[str, str]) -> str: - # NOTE: /api/v1/projects currently 404s on main as of v0.1, so this fallback - # is expected on every smoke run until the v0.2 follow-up (#315). - # A synthetic UUID is sufficient because session writes currently do not - # enforce a foreign-key relationship on project_id. +def _create_project_id(client: httpx.Client, headers: dict[str, str]) -> str: response = client.post("/api/v1/projects", headers=headers, json={"name": "smoke-project"}) if response.status_code < 300: payload = response.json() @@ -34,9 +29,6 @@ def _get_or_generate_project_id(client: httpx.Client, headers: dict[str, str]) - if isinstance(project_id, str) and project_id: return project_id raise RuntimeError("Project creation succeeded but no project id was returned.") - if response.status_code == 404: - print("Project creation endpoint not available; using generated project_id for external-results smoke run.") - return str(uuid.uuid4()) raise RuntimeError(f"Project creation failed: status={response.status_code}, body={response.text}") @@ -57,7 +49,7 @@ def main() -> None: admin_jwt = login["access_token"] headers = {"Authorization": f"Bearer {admin_jwt}"} - project_id = _get_or_generate_project_id(client, headers) + project_id = _create_project_id(client, headers) token_payload = { "label": "smoke",