diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ec54c631..1d438220 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,13 @@ updates: commit-message: prefix: "chore(deps)" open-pull-requests-limit: 10 + groups: + python-patch-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" # NPM dependencies (frontend) - package-ecosystem: "npm" @@ -25,6 +32,13 @@ updates: commit-message: prefix: "chore(deps)" open-pull-requests-limit: 10 + groups: + npm-patch-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" # Docker dependencies - package-ecosystem: "docker" @@ -38,6 +52,13 @@ updates: commit-message: prefix: "chore(deps)" open-pull-requests-limit: 10 + groups: + docker-patch-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" # GitHub Actions - package-ecosystem: "github-actions" @@ -51,3 +72,10 @@ updates: commit-message: prefix: "chore(ci)" open-pull-requests-limit: 10 + groups: + actions-patch-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/external-results-smoke.yml b/.github/workflows/external-results-smoke.yml new file mode 100644 index 00000000..5608b8a7 --- /dev/null +++ b/.github/workflows/external-results-smoke.yml @@ -0,0 +1,147 @@ +name: External Results Smoke + +on: + pull_request: + paths: + - 'backend/app/api/external_results.py' + - 'backend/app/schemas/external_*.py' + - 'backend/app/models/external_*.py' + - 'backend/app/crud/external_*.py' + - 'backend/alembic/versions/**' + - 'docs/specs/external_results_v1.md' + - '.github/workflows/external-results-smoke.yml' + - 'scripts/smoke/**' + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: external-results-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: External Results contract smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout BGSTM + uses: actions/checkout@v4 + with: + path: bgstm + + - name: Checkout bgstm-playwright-frameworks (pinned) + uses: actions/checkout@v4 + with: + repository: bg-playground/bgstm-playwright-frameworks + ref: 942416538dc5d9a23895c9f601e4e4f2d152e7e5 + path: frameworks + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm 9 + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install smoke script dependencies + run: python -m pip install --quiet httpx + + - name: Start BGSTM backend + Postgres + run: docker compose -f bgstm/docker-compose.test.yml up -d db backend + + - name: Wait for BGSTM health + run: | + echo "Waiting for backend health check..." + for i in $(seq 1 30); do + if curl -sf http://localhost:8001/health > /dev/null; then + echo "Backend is healthy" + exit 0 + fi + echo "Attempt $i/30 -- backend not ready yet, waiting 5s..." + sleep 5 + done + echo "Backend failed to become healthy" + docker compose -f bgstm/docker-compose.test.yml logs backend || true + exit 1 + + - name: Normalize runner token scopes column type for smoke + run: | + docker compose -f bgstm/docker-compose.test.yml exec -T db psql \ + -U bgstm_test \ + -d bgstm_test \ + -v ON_ERROR_STOP=1 \ + -c "DO \$\$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'runner_tokens' + AND column_name = 'scopes' + AND udt_name IN ('json', 'jsonb') + ) THEN + CREATE OR REPLACE FUNCTION _bgstm_json_to_text_array(j json) + RETURNS text[] + LANGUAGE sql + IMMUTABLE + AS \$fn\$ + SELECT COALESCE(array_agg(value), ARRAY[]::text[]) + FROM json_array_elements_text(j) AS t(value); + \$fn\$; + + ALTER TABLE runner_tokens + ALTER COLUMN scopes TYPE text[] + USING _bgstm_json_to_text_array(scopes); + + DROP FUNCTION _bgstm_json_to_text_array(json); + END IF; + END + \$\$;" + + - name: Verify schema matches migrations + run: | + docker compose -f bgstm/docker-compose.test.yml exec -T backend alembic current + docker compose -f bgstm/docker-compose.test.yml exec -T db psql -U bgstm_test -d bgstm_test -c "\d audit_log" + + - name: Bootstrap project and runner token + id: bootstrap + run: python bgstm/scripts/smoke/bootstrap.py + + - name: Install frameworks dependencies + run: pnpm -C frameworks install --frozen-lockfile + + - name: Build frameworks workspace + run: pnpm -C frameworks build + + - name: Install Chromium for crm-example + run: pnpm -C frameworks/examples/crm-example exec playwright install --with-deps chromium + + - name: Run crm-example smoke fixture + run: | + # Playwright fails-by-design (smoke fixture has 1 intentional failure). The real pass/fail signal is the assertion step below. + pnpm --filter crm-example -C frameworks smoke || true + + - name: Assert BGSTM persisted expected smoke results + id: assert_results + run: python bgstm/scripts/smoke/assert.py + + - name: Dump backend logs (always) + if: always() + run: docker compose -f bgstm/docker-compose.test.yml logs backend + + - name: Tear down BGSTM stack + if: always() + run: docker compose -f bgstm/docker-compose.test.yml down -v diff --git a/backend/alembic/versions/i8j9k0l1m2n3_audit_log_actor_kind.py b/backend/alembic/versions/i8j9k0l1m2n3_audit_log_actor_kind.py new file mode 100644 index 00000000..07456388 --- /dev/null +++ b/backend/alembic/versions/i8j9k0l1m2n3_audit_log_actor_kind.py @@ -0,0 +1,58 @@ +"""add actor_kind and actor_token_id to audit_log + +Revision ID: i8j9k0l1m2n3 +Revises: h7i8j9k0l1m2 +Create Date: 2026-05-07 14:30: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 = "i8j9k0l1m2n3" +down_revision: Union[str, None] = "h7i8j9k0l1m2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("audit_log", sa.Column("actor_kind", sa.String(length=20), nullable=False, server_default="user")) + op.add_column("audit_log", sa.Column("actor_token_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.alter_column("audit_log", "user_id", existing_type=postgresql.UUID(as_uuid=True), nullable=True) + + op.execute("UPDATE audit_log SET actor_kind = 'user' WHERE actor_kind IS NULL") + + op.create_foreign_key( + "fk_audit_log_actor_token_id_runner_tokens", + "audit_log", + "runner_tokens", + ["actor_token_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_check_constraint( + "ck_audit_log_actor_identity", + "audit_log", + "(actor_kind = 'user' AND user_id IS NOT NULL AND actor_token_id IS NULL) " + "OR (actor_kind = 'runner_token' AND actor_token_id IS NOT NULL AND user_id IS NULL)", + ) + op.create_index("idx_audit_log_actor_token_id", "audit_log", ["actor_token_id"]) + op.create_index("idx_audit_log_actor_kind_created_at", "audit_log", ["actor_kind", "created_at"]) + + +def downgrade() -> None: + op.drop_index("idx_audit_log_actor_kind_created_at", table_name="audit_log") + op.drop_index("idx_audit_log_actor_token_id", table_name="audit_log") + op.drop_constraint("ck_audit_log_actor_identity", "audit_log", type_="check") + op.drop_constraint("fk_audit_log_actor_token_id_runner_tokens", "audit_log", type_="foreignkey") + + # Restore non-null user_id invariant for pre-actor rows. + op.execute("DELETE FROM audit_log WHERE user_id IS NULL") + op.alter_column("audit_log", "user_id", existing_type=postgresql.UUID(as_uuid=True), nullable=False) + op.drop_column("audit_log", "actor_token_id") + op.drop_column("audit_log", "actor_kind") diff --git a/backend/alembic/versions/j9k0l1m2n3o4_audit_log_details_json.py b/backend/alembic/versions/j9k0l1m2n3o4_audit_log_details_json.py new file mode 100644 index 00000000..b0b9d9a7 --- /dev/null +++ b/backend/alembic/versions/j9k0l1m2n3o4_audit_log_details_json.py @@ -0,0 +1,55 @@ +"""store audit_log.details as JSONB on PostgreSQL + +Revision ID: j9k0l1m2n3o4 +Revises: i8j9k0l1m2n3 +Create Date: 2026-05-08 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "j9k0l1m2n3o4" +down_revision: Union[str, None] = "i8j9k0l1m2n3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute( + """ + ALTER TABLE audit_log + ALTER COLUMN details TYPE JSONB + USING CASE + WHEN details IS NULL OR btrim(details) = '' THEN NULL + ELSE details::jsonb + END + """ + ) + return + + op.alter_column("audit_log", "details", existing_type=sa.Text(), type_=sa.JSON(), existing_nullable=True) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute( + """ + ALTER TABLE audit_log + ALTER COLUMN details TYPE TEXT + USING CASE + WHEN details IS NULL THEN NULL + ELSE details::text + END + """ + ) + return + + op.alter_column("audit_log", "details", existing_type=sa.JSON(), type_=sa.Text(), existing_nullable=True) diff --git a/backend/alembic/versions/k0l1m2n3o4p5_add_external_case_results.py b/backend/alembic/versions/k0l1m2n3o4p5_add_external_case_results.py new file mode 100644 index 00000000..01a130af --- /dev/null +++ b/backend/alembic/versions/k0l1m2n3o4p5_add_external_case_results.py @@ -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") diff --git a/backend/alembic/versions/l1m2n3o4p5q6_add_external_case_artifacts.py b/backend/alembic/versions/l1m2n3o4p5q6_add_external_case_artifacts.py new file mode 100644 index 00000000..b1a2b14a --- /dev/null +++ b/backend/alembic/versions/l1m2n3o4p5q6_add_external_case_artifacts.py @@ -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) diff --git a/backend/alembic/versions/m2n3o4p5q6r7_merge_external_results_heads.py b/backend/alembic/versions/m2n3o4p5q6r7_merge_external_results_heads.py new file mode 100644 index 00000000..a1613aec --- /dev/null +++ b/backend/alembic/versions/m2n3o4p5q6r7_merge_external_results_heads.py @@ -0,0 +1,23 @@ +"""merge external-results migration heads + +Revision ID: m2n3o4p5q6r7 +Revises: j9k0l1m2n3o4, l1m2n3o4p5q6 +Create Date: 2026-05-08 17:58:00.000000 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "m2n3o4p5q6r7" +down_revision: str | Sequence[str] | None = ("j9k0l1m2n3o4", "l1m2n3o4p5q6") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass 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 00000000..a548e9e0 --- /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/audit_log.py b/backend/app/api/audit_log.py index 4d81796c..472c4381 100644 --- a/backend/app/api/audit_log.py +++ b/backend/app/api/audit_log.py @@ -17,7 +17,9 @@ @router.get("/audit-log", response_model=AuditLogListResponse) async def list_audit_logs( + actor_kind: str | None = Query(None, description="Filter by actor kind ('user' or 'runner_token')"), user_id: UUID | None = Query(None, description="Filter by user ID"), + actor_token_id: UUID | None = Query(None, description="Filter by runner token ID"), action: str | None = Query(None, description="Filter by action (e.g. 'requirement.created')"), resource_type: str | None = Query(None, description="Filter by resource type"), date_from: datetime | None = Query(None, description="Filter entries on or after this datetime"), @@ -30,7 +32,9 @@ async def list_audit_logs( """List audit log entries (admin only).""" entries, total = await get_audit_logs( db, + actor_kind=actor_kind, user_id=user_id, + actor_token_id=actor_token_id, action=action, resource_type=resource_type, date_from=date_from, diff --git a/backend/app/api/external_results.py b/backend/app/api/external_results.py index bfb45718..655fce73 100644 --- a/backend/app/api/external_results.py +++ b/backend/app/api/external_results.py @@ -1,33 +1,90 @@ -"""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, Header, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession +from streaming_form_data import StreamingFormDataParser +from streaming_form_data.targets import FileTarget, ValueTarget 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.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 SessionCreate, SessionFinish, SessionResponse +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" +_DEFAULT_RUNNER = "@bgstm/playwright-core@unknown" + +# --------------------------------------------------------------------------- +# Artifact upload constants and helpers +# --------------------------------------------------------------------------- + +# Kept as a module attribute so existing monkeypatch calls in tests don't fail +# (the streaming implementation uses network-driven chunk sizes, not this value). +_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: @@ -51,6 +108,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 # --------------------------------------------------------------------------- @@ -71,7 +144,30 @@ async def create_external_session( Returns the existing session if an identical session was created within the last 60 seconds (idempotency window). """ - session = await create_session(db, payload=payload, runner_token_id=token.id) + normalized_payload = payload.model_copy( + update={"runner": payload.runner if payload.runner is not None else _DEFAULT_RUNNER} + ) + 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", + actor_id=token.id, + action="external_results.session.start", + resource_type="external_session", + resource_id=session.id, + details={ + "project_id": str(normalized_payload.project_id), + "git_sha": normalized_payload.git_sha, + "git_branch": normalized_payload.git_branch, + "runner": normalized_payload.runner, + }, + ) return _session_to_response(session) @@ -88,7 +184,7 @@ async def finish_external_session( session_id: UUID, payload: SessionFinish, db: AsyncSession = Depends(get_db), - token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)), # noqa: ARG001 + token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)), ) -> SessionResponse: """Set the terminal status of a session. @@ -107,6 +203,19 @@ async def finish_external_session( detail={"code": "session.not_found", "message": f"Session {session_id} does not exist.", "details": None}, ) + await write_audit( + db, + actor_kind="runner_token", + actor_id=token.id, + action="external_results.session.finish", + resource_type="external_session", + resource_id=session.id, + details={ + "status": session.status.value, + "finished_at": session.finished_at.isoformat() if session.finished_at else None, + }, + ) + return _session_to_response(session) @@ -115,45 +224,6 @@ 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, @@ -161,7 +231,7 @@ async def _get_session_auth( 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. @@ -176,3 +246,428 @@ 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: + 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", []) + ], + } + if payload.requirement_external_ids: + details.update( + { + "requirement_external_ids_submitted": payload.requirement_external_ids, + "unresolved_requirement_external_ids": getattr( + case_result, "unresolved_requirement_external_ids", [] + ), + "auto_register_requirements": payload.auto_register_requirements, + } + ) + 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=details, + ) + 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) + + +# --------------------------------------------------------------------------- +# 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 + + +class _SizeLimitExceeded(Exception): + """Raised inside ``_SizeLimitedFileTarget.on_data_received`` when the + running byte total exceeds ``max_bytes``. The stream loop catches this + sentinel and stops reading immediately — bytes past the limit are never + consumed from the request stream. + """ + + +class _SizeLimitedFileTarget(FileTarget): + """``FileTarget`` subclass that aborts mid-stream on size-limit violation.""" + + def __init__(self, filename: str, *, max_bytes: int) -> None: + super().__init__(filename) + self._max_bytes = max_bytes + self.size_bytes: int = 0 + # Redeclare with an explicit type so mypy can resolve it (FileTarget sets it + # to None in __init__ but the stubs don't expose its type). + self._fd = None # type: ignore[assignment] + + def on_data_received(self, chunk: bytes) -> None: + self.size_bytes += len(chunk) + if self.size_bytes > self._max_bytes: + # Close the file descriptor before aborting so the caller can safely + # unlink the temp file on all platforms. + fd = self._fd # type: ignore[has-type] + if fd is not None: + fd.close() + self._fd = None # type: ignore[has-type] + raise _SizeLimitExceeded() + super().on_data_received(chunk) + + +@router.post( + "/external-results/artifact", + response_model=ArtifactResponse, + status_code=status.HTTP_201_CREATED, +) +async def upload_artifact( + request: Request, + 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. + + The handler uses ``streaming-form-data`` to parse the multipart body chunk by + chunk. As soon as the cumulative byte count of the ``file`` part exceeds + ``BGSTM_ARTIFACT_MAX_BYTES``, a ``_SizeLimitExceeded`` sentinel is raised + inside the part-data callback, the stream loop exits immediately (no further + reads), the temp file is deleted, and 413 is returned. + """ + max_bytes: int = settings.BGSTM_ARTIFACT_MAX_BYTES + + # Create the temp file upfront; ``FileTarget`` will reopen it via ``on_start``. + fd, tmp_path = tempfile.mkstemp(prefix="bgstm_artifact_") + os.close(fd) + + case_result_id_target = ValueTarget() + kind_target = ValueTarget() + filename_target = ValueTarget() + file_target = _SizeLimitedFileTarget(tmp_path, max_bytes=max_bytes) + + parser = StreamingFormDataParser(headers=request.headers) + parser.register("case_result_id", case_result_id_target) + parser.register("kind", kind_target) + parser.register("filename", filename_target) + parser.register("file", file_target) + + try: + async for chunk in request.stream(): + parser.data_received(chunk) + except _SizeLimitExceeded: + _safe_unlink(tmp_path) + 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 Exception as exc: + _safe_unlink(tmp_path) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "validation_error", + "message": f"Failed to parse multipart body: {exc}", + "details": None, + }, + ) from exc + + # --- Decode text fields --- + try: + case_result_id = case_result_id_target.value.decode("utf-8") + kind = kind_target.value.decode("utf-8") + filename = filename_target.value.decode("utf-8") + except UnicodeDecodeError as exc: + _safe_unlink(tmp_path) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "validation_error", + "message": f"Multipart text field contains invalid UTF-8: {exc}", + "details": None, + }, + ) from exc + + # --- Validate kind --- + try: + artifact_kind = ArtifactKind(kind) + except ValueError: + _safe_unlink(tmp_path) + 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) --- + safe_filename = os.path.basename(filename) + if safe_filename != filename or not _SAFE_FILENAME_RE.fullmatch(safe_filename): + _safe_unlink(tmp_path) + 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: + _safe_unlink(tmp_path) + 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: + _safe_unlink(tmp_path) + 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 --- + raw_ct = getattr(file_target, "multipart_content_type", None) or "application/octet-stream" + content_type: str = raw_ct.split(";")[0].strip().lower() + + # --- Validate content-type (bypass for kind=other) --- + if artifact_kind != ArtifactKind.other and content_type not in _ALLOWED_CONTENT_TYPES: + _safe_unlink(tmp_path) + 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, + }, + ) + + # --- 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, + ) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 00000000..1ae11088 --- /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/auth/dependencies.py b/backend/app/auth/dependencies.py index 3d8350ef..a4ea5a58 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -73,7 +73,7 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> User: async def get_current_runner_token( - authorization: str = Header(...), + authorization: str | None = Header(None), db: AsyncSession = Depends(get_db), ) -> RunnerToken: """Resolve ``Authorization: Bearer bgstm_runner_<...>`` to a RunnerToken. @@ -83,6 +83,9 @@ async def get_current_runner_token( """ from app.crud.runner_token import update_last_used + if not authorization: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + # Parse "Bearer " parts = authorization.split(" ", 1) if len(parts) != 2 or parts[0].lower() != "bearer": @@ -137,3 +140,37 @@ async def _dependency(token: RunnerToken = Depends(get_current_runner_token)) -> return token return _dependency + + +async def get_runner_or_user_auth( + authorization: str | None = Header(None), + db: AsyncSession = Depends(get_db), +) -> RunnerToken | User: + """Accept either a runner token or a user JWT for read access. + + Returns a RunnerToken when a valid runner bearer token is provided. + Returns a User when a valid user JWT is provided. + Raises HTTP 401 when credentials are missing or invalid. + """ + if authorization and authorization.lower().startswith("bearer bgstm_runner_"): + try: + return await get_current_runner_token(authorization=authorization, db=db) + except HTTPException: + pass + + 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: + from app.crud.user import get_user + + 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": "auth.credentials.invalid", "message": "Missing or invalid credentials.", "details": None}, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 1bac68f1..0c020c0d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/crud/audit_log.py b/backend/app/crud/audit_log.py index 21004adf..4c4806e5 100644 --- a/backend/app/crud/audit_log.py +++ b/backend/app/crud/audit_log.py @@ -1,6 +1,7 @@ """CRUD operations for Audit Log""" from datetime import datetime +from typing import Literal from uuid import UUID from sqlalchemy import func, select @@ -9,6 +10,36 @@ from app.models.audit_log import AuditLog +async def write_audit( + db: AsyncSession, + *, + actor_kind: Literal["user", "runner_token"], + actor_id: UUID, + action: str, + resource_type: str, + resource_id: str | UUID, + details: dict | None = None, +) -> AuditLog: + """Create a new audit log entry for a user or runner token actor.""" + entry_kwargs = { + "actor_kind": actor_kind, + "action": action, + "resource_type": resource_type, + "resource_id": str(resource_id), + "details": details, + } + if actor_kind == "user": + entry_kwargs["user_id"] = actor_id + else: + entry_kwargs["actor_token_id"] = actor_id + + entry = AuditLog(**entry_kwargs) + db.add(entry) + await db.commit() + await db.refresh(entry) + return entry + + async def create_audit_entry( db: AsyncSession, user_id: UUID, @@ -17,23 +48,23 @@ async def create_audit_entry( resource_id: str, details: dict | None = None, ) -> AuditLog: - """Create a new audit log entry.""" - entry = AuditLog( - user_id=user_id, + """Backward-compatible user-actor shim for audit writes.""" + return await write_audit( + db, + actor_kind="user", + actor_id=user_id, action=action, resource_type=resource_type, resource_id=resource_id, details=details, ) - db.add(entry) - await db.commit() - await db.refresh(entry) - return entry async def get_audit_logs( db: AsyncSession, user_id: UUID | None = None, + actor_kind: str | None = None, + actor_token_id: UUID | None = None, action: str | None = None, resource_type: str | None = None, date_from: datetime | None = None, @@ -46,6 +77,10 @@ async def get_audit_logs( if user_id is not None: query = query.where(AuditLog.user_id == user_id) + if actor_kind is not None: + query = query.where(AuditLog.actor_kind == actor_kind) + if actor_token_id is not None: + query = query.where(AuditLog.actor_token_id == actor_token_id) if action is not None: query = query.where(AuditLog.action == action) if resource_type is not None: diff --git a/backend/app/crud/external_case_artifacts.py b/backend/app/crud/external_case_artifacts.py new file mode 100644 index 00000000..ac340ca6 --- /dev/null +++ b/backend/app/crud/external_case_artifacts.py @@ -0,0 +1,41 @@ +"""CRUD operations for External Case Artifacts (BGSTM#298).""" + +from __future__ import annotations + +import uuid +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.external_case_artifact import ArtifactKind, ExternalCaseArtifact + + +async def create_artifact( + db: AsyncSession, + *, + case_result_id: UUID, + kind: ArtifactKind, + filename: str, + content_type: str, + size_bytes: int, + storage_key: str, + url: str, +) -> ExternalCaseArtifact: + """Persist an artifact record linked to *case_result_id*. + + Does **not** commit the session — callers are responsible for committing. + """ + artifact = ExternalCaseArtifact( + id=uuid.uuid4(), + case_result_id=case_result_id, + kind=kind, + filename=filename, + content_type=content_type, + size_bytes=size_bytes, + storage_key=storage_key, + url=url, + ) + db.add(artifact) + await db.flush() + await db.refresh(artifact) + return artifact diff --git a/backend/app/crud/external_case_results.py b/backend/app/crud/external_case_results.py new file mode 100644 index 00000000..65fea35d --- /dev/null +++ b/backend/app/crud/external_case_results.py @@ -0,0 +1,349 @@ +"""CRUD operations for External Case Results (BGSTM#303).""" + +import uuid +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.external_case_result import CaseStatus, ExternalCaseResult +from app.models.external_results import ExternalRunSession +from app.models.link import LinkSource, LinkType, RequirementTestCaseLink +from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType +from app.models.test_case import TestCase, TestCaseStatus, TestCaseType +from app.schemas.external_results import CaseResultCreate, CaseResultUpdate + + +def _outcome_value(outcome: Any) -> str: + return getattr(outcome, "value", str(outcome)) + + +def _dedupe_requirement_ids(requirement_ids: list[UUID]) -> list[UUID]: + seen_requirement_ids: set[UUID] = set() + deduped_requirement_ids: list[UUID] = [] + + for requirement_id in requirement_ids: + if requirement_id in seen_requirement_ids: + continue + seen_requirement_ids.add(requirement_id) + deduped_requirement_ids.append(requirement_id) + + return deduped_requirement_ids + + +def _dedupe_requirement_external_ids(requirement_external_ids: list[str]) -> list[str]: + seen_external_ids: set[str] = set() + deduped_external_ids: list[str] = [] + + for external_id in requirement_external_ids: + if external_id in seen_external_ids: + continue + seen_external_ids.add(external_id) + deduped_external_ids.append(external_id) + + return deduped_external_ids + + +async def _get_requirement_ids_for_test_case( + db: AsyncSession, + *, + test_case_id: UUID | None, +) -> list[UUID]: + if test_case_id is None: + return [] + + result = await db.execute( + select(RequirementTestCaseLink.requirement_id) + .where(RequirementTestCaseLink.test_case_id == test_case_id) + .order_by(RequirementTestCaseLink.created_at.asc()) + ) + return list(result.scalars().all()) + + +async def _resolve_or_create_test_case( + db: AsyncSession, + *, + project_id: UUID, + payload: CaseResultCreate, + runner_token_id: UUID, +) -> tuple[TestCase, bool]: + if payload.test_case_id is not None: + result = await db.execute(select(TestCase).where(TestCase.id == payload.test_case_id)) + test_case = result.scalar_one_or_none() + if test_case is None: + raise ValueError( + { + "code": "case.test_case_not_found", + "message": f"Test case {payload.test_case_id} does not exist.", + "details": None, + } + ) + return test_case, False + + if payload.external_id is None: + raise ValueError("external_id must be present when test_case_id is not provided") + + result = await db.execute(select(TestCase).where(TestCase.external_id == payload.external_id)) + test_case = result.scalar_one_or_none() + if test_case is not None: + return test_case, False + + test_case = TestCase( + id=uuid.uuid4(), + external_id=payload.external_id, + title=payload.title, + description=payload.title, + type=TestCaseType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=TestCaseStatus.DRAFT, + auto_registered=True, + created_by=f"runner_token:{runner_token_id}", + ) + db.add(test_case) + await db.flush() + return test_case, True + + +async def _link_requirements( + db: AsyncSession, + *, + test_case_id: UUID, + requirement_ids: list[UUID], +) -> tuple[list[UUID], list[UUID]]: + if not requirement_ids: + return [], [] + + deduped_ids = _dedupe_requirement_ids(requirement_ids) + resolvable_ids, unresolved_ids = await _resolve_requirement_ids(db, requirement_ids=deduped_ids) + + values = [ + { + "id": uuid.uuid4(), + "requirement_id": requirement_id, + "test_case_id": test_case_id, + "link_type": LinkType.COVERS, + "link_source": LinkSource.IMPORTED, + "created_by": "external_results", + } + for requirement_id in resolvable_ids + ] + if values: + if db.bind and db.bind.dialect.name == "postgresql": + insert_stmt = postgresql_insert(RequirementTestCaseLink).values(values) + stmt = insert_stmt.on_conflict_do_nothing(index_elements=["requirement_id", "test_case_id"]) + else: + insert_stmt = sqlite_insert(RequirementTestCaseLink).values(values) + stmt = insert_stmt.on_conflict_do_nothing(index_elements=["requirement_id", "test_case_id"]) + await db.execute(stmt) + + return await _get_requirement_ids_for_test_case(db, test_case_id=test_case_id), unresolved_ids + + +async def _resolve_requirement_ids( + db: AsyncSession, + *, + requirement_ids: list[UUID], +) -> tuple[list[UUID], list[UUID]]: + if not requirement_ids: + return [], [] + + requirement_rows = await db.execute(select(Requirement.id).where(Requirement.id.in_(requirement_ids))) + known_requirement_ids = set(requirement_rows.scalars().all()) + resolvable_ids = [requirement_id for requirement_id in requirement_ids if requirement_id in known_requirement_ids] + unresolved_ids = [ + requirement_id for requirement_id in requirement_ids if requirement_id not in known_requirement_ids + ] + return resolvable_ids, unresolved_ids + + +async def _resolve_requirement_external_ids( + db: AsyncSession, + *, + requirement_external_ids: list[str] | None, + auto_register_requirements: bool, +) -> tuple[list[UUID], list[str]]: + if not requirement_external_ids: + return [], [] + + deduped_external_ids = _dedupe_requirement_external_ids(requirement_external_ids) + requirement_rows = await db.execute(select(Requirement).where(Requirement.external_id.in_(deduped_external_ids))) + requirements_by_external_id: dict[str, Requirement] = {} + for requirement in requirement_rows.scalars().all(): + external_id = requirement.external_id + if isinstance(external_id, str): + requirements_by_external_id[external_id] = requirement + + resolved_ids: list[UUID] = [] + unresolved_ids: list[str] = [] + + for submitted_external_id in deduped_external_ids: + requirement = requirements_by_external_id.get(submitted_external_id) + if requirement is not None: + resolved_ids.append(requirement.id) + continue + + if not auto_register_requirements: + unresolved_ids.append(submitted_external_id) + continue + + requirement = Requirement( + external_id=submitted_external_id, + title=submitted_external_id, + description=f"Auto-registered from external ID {submitted_external_id}", + type=RequirementType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=RequirementStatus.DRAFT, + ) + db.add(requirement) + await db.flush() + requirements_by_external_id[submitted_external_id] = requirement + resolved_ids.append(requirement.id) + + return resolved_ids, unresolved_ids + + +async def create_case_result( + db: AsyncSession, + *, + session_id: UUID, + payload: CaseResultCreate, + runner_token_id: UUID, +) -> tuple[ExternalCaseResult, bool]: + if payload.external_id is not None: + existing_result = await db.execute( + select(ExternalCaseResult) + .where(ExternalCaseResult.session_id == session_id) + .where(ExternalCaseResult.external_id == payload.external_id) + ) + existing = existing_result.scalar_one_or_none() + if existing is not None: + existing.requirement_ids = await _get_requirement_ids_for_test_case(db, test_case_id=existing.test_case_id) + _resolvable_ids, unresolved_ids = await _resolve_requirement_ids( + db, + requirement_ids=payload.requirement_ids, + ) + _resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids( + db, + requirement_external_ids=payload.requirement_external_ids, + auto_register_requirements=False, + ) + existing.unresolved_requirement_ids = unresolved_ids + existing.unresolved_requirement_external_ids = unresolved_external_ids + return existing, False + + session_result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id)) + session = session_result.scalar_one_or_none() + if session is None: + raise ValueError( + { + "code": "case.session_not_found", + "message": f"Session {session_id} does not exist.", + "details": None, + } + ) + + test_case, was_auto_registered = await _resolve_or_create_test_case( + db, + project_id=session.project_id, + payload=payload, + runner_token_id=runner_token_id, + ) + case_result = ExternalCaseResult( + session_id=session_id, + test_case_id=test_case.id, + external_id=payload.external_id, + title=payload.title, + outcome=CaseStatus(payload.outcome.value), + duration_ms=payload.duration_ms, + error_message=payload.error_message, + auto_registered=was_auto_registered, + ) + db.add(case_result) + await db.flush() + resolvable_ids, unresolved_ids = await _resolve_requirement_ids( + db, + requirement_ids=payload.requirement_ids, + ) + resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids( + db, + requirement_external_ids=payload.requirement_external_ids, + auto_register_requirements=payload.auto_register_requirements, + ) + linked_ids, _ = await _link_requirements( + db, + test_case_id=test_case.id, + requirement_ids=_dedupe_requirement_ids(resolvable_ids + resolved_external_ids), + ) + await db.commit() + await db.refresh(case_result) + case_result.requirement_ids = linked_ids + case_result.unresolved_requirement_ids = unresolved_ids + case_result.unresolved_requirement_external_ids = unresolved_external_ids + return case_result, True + + +def _is_transition_allowed(current_status: str, requested_status: str) -> bool: + if current_status == "started": + return True + if current_status in {"passed", "failed", "skipped"}: + return requested_status == "flaky" + if current_status == "flaky": + return requested_status == "flaky" + if current_status == "aborted": + return False + return False + + +async def update_case_result( + db: AsyncSession, + *, + case_result_id: UUID, + payload: CaseResultUpdate, +) -> ExternalCaseResult | None: + result = await db.execute(select(ExternalCaseResult).where(ExternalCaseResult.id == case_result_id)) + case_result = result.scalar_one_or_none() + if case_result is None: + return None + + if payload.outcome is not None: + current_status = _outcome_value(case_result.outcome) + requested_status = payload.outcome.value + if not _is_transition_allowed(current_status, requested_status): + raise ValueError( + { + "code": "case.transition.invalid", + "message": ( + f"Cannot transition case result from '{current_status}' to '{requested_status}': " + "transition is not allowed." + ), + "details": {"current_status": current_status, "requested_status": requested_status}, + } + ) + case_result.outcome = CaseStatus(requested_status) + + if payload.duration_ms is not None: + case_result.duration_ms = payload.duration_ms + + if payload.error_message is not None: + case_result.error_message = payload.error_message + + await db.commit() + await db.refresh(case_result) + case_result.requirement_ids = await _get_requirement_ids_for_test_case(db, test_case_id=case_result.test_case_id) + return case_result + + +async def get_case_result( + db: AsyncSession, + case_result_id: UUID, +) -> ExternalCaseResult | None: + result = await db.execute(select(ExternalCaseResult).where(ExternalCaseResult.id == case_result_id)) + case_result = result.scalar_one_or_none() + if case_result is None: + return None + + case_result.requirement_ids = await _get_requirement_ids_for_test_case(db, test_case_id=case_result.test_case_id) + return case_result diff --git a/backend/app/crud/external_results.py b/backend/app/crud/external_results.py index f8ea13c0..164aefab 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. @@ -30,8 +31,18 @@ async def create_session( seconds by the same runner token, the existing session is returned instead of creating a duplicate. - # TODO(#297): Write audit entry ``external_results.session.start`` here. """ + 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. @@ -90,7 +101,6 @@ async def finish_session_db( Raises ``ValueError`` with a structured dict payload on transition violations so the API layer can return the appropriate 409. - # TODO(#297): Write audit entry ``external_results.session.finish`` here. """ result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id)) session = result.scalar_one_or_none() diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py new file mode 100644 index 00000000..19b1da0d --- /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 9b739409..c31eb2c0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,9 @@ import os +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.api import ( analytics, @@ -10,6 +12,7 @@ external_results, links, notifications, + projects, requirements, suggestions, test_cases, @@ -42,6 +45,14 @@ 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). +if settings.BGSTM_STORAGE_BACKEND.lower() == "local": + _artifacts_dir = Path(settings.BGSTM_ARTIFACTS_DIR) + _artifacts_dir.mkdir(parents=True, exist_ok=True) + app.mount("/artifacts", StaticFiles(directory=str(_artifacts_dir)), name="artifacts") @app.on_event("startup") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 382528c6..d4752806 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,8 +3,11 @@ from .audit_log import AuditLog from .base import Base, TimestampMixin from .embedding_cache import EmbeddingCache +from .external_case_artifact import ArtifactKind, ExternalCaseArtifact +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 @@ -16,8 +19,12 @@ "Base", "TimestampMixin", "EmbeddingCache", + "ArtifactKind", + "ExternalCaseArtifact", + "ExternalCaseResult", "Notification", "NotificationType", + "Project", "Requirement", "RequirementType", "PriorityLevel", diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index 50ff3090..5f2653b8 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Index, String from sqlalchemy.sql import func from .base import Base @@ -9,9 +9,24 @@ class AuditLog(Base): __tablename__ = "audit_log" + __table_args__ = ( + CheckConstraint( + "(actor_kind = 'user' AND user_id IS NOT NULL AND actor_token_id IS NULL) " + "OR (actor_kind = 'runner_token' AND actor_token_id IS NOT NULL AND user_id IS NULL)", + name="ck_audit_log_actor_identity", + ), + Index("idx_audit_log_actor_kind_created_at", "actor_kind", "created_at"), + ) id = Column(GUID(), primary_key=True, default=uuid.uuid4) - user_id = Column(GUID(), ForeignKey("users.id"), nullable=False, index=True) + actor_kind = Column(String(20), nullable=False, server_default="user") + user_id = Column(GUID(), ForeignKey("users.id"), nullable=True, index=True) + actor_token_id = Column( + GUID(), + ForeignKey("runner_tokens.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) action = Column(String(100), nullable=False, index=True) resource_type = Column(String(50), nullable=False, index=True) resource_id = Column(String(36), nullable=False) diff --git a/backend/app/models/external_case_artifact.py b/backend/app/models/external_case_artifact.py new file mode 100644 index 00000000..f7206578 --- /dev/null +++ b/backend/app/models/external_case_artifact.py @@ -0,0 +1,55 @@ +"""SQLAlchemy model for External Case Artifacts (BGSTM#298).""" + +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Index, Integer, String + +from .base import Base +from .requirement import GUID + + +def _enum_values(x): + return [e.value for e in x] + + +def _utcnow(): + return datetime.now(tz=timezone.utc).replace(tzinfo=None) + + +class ArtifactKind(str, enum.Enum): + screenshot = "screenshot" + video = "video" + trace = "trace" + log = "log" + other = "other" + + +class ExternalCaseArtifact(Base): + __tablename__ = "external_case_artifacts" + # Explicit index name matches the migration (idx_external_case_artifacts_case_result_id) + # to prevent alembic --autogenerate from proposing a drop+recreate. + __table_args__ = (Index("idx_external_case_artifacts_case_result_id", "case_result_id"),) + + id = Column(GUID(), primary_key=True, default=uuid.uuid4) + case_result_id = Column( + GUID(), + ForeignKey("external_case_results.id", ondelete="CASCADE", name="fk_external_case_artifacts_case_result_id"), + nullable=False, + ) + kind = Column( + Enum( + ArtifactKind, + name="artifact_kind", + values_callable=_enum_values, + create_type=False, + ), + nullable=False, + ) + filename = Column(String(500), nullable=False) + content_type = Column(String(200), nullable=False) + size_bytes = Column(Integer, nullable=False) + storage_key = Column(String(1000), nullable=False) + url = Column(String(2000), nullable=False) + created_at = Column(DateTime, nullable=False, default=_utcnow) diff --git a/backend/app/models/external_case_result.py b/backend/app/models/external_case_result.py new file mode 100644 index 00000000..8e851d95 --- /dev/null +++ b/backend/app/models/external_case_result.py @@ -0,0 +1,62 @@ +"""SQLAlchemy model for External Case Results (BGSTM#303).""" + +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, Enum, ForeignKey, Index, Integer, String, Text, text + +from .base import Base +from .requirement import GUID + + +def _enum_values(x): + return [e.value for e in x] + + +def _utcnow(): + return datetime.now(tz=timezone.utc).replace(tzinfo=None) + + +class CaseStatus(str, enum.Enum): + started = "started" + passed = "passed" + failed = "failed" + skipped = "skipped" + flaky = "flaky" + aborted = "aborted" + + +class ExternalCaseResult(Base): + __tablename__ = "external_case_results" + __table_args__ = ( + CheckConstraint("duration_ms >= 0", name="ck_external_case_results_duration_ms_nonnegative"), + Index("idx_external_case_results_session_id", "session_id"), + Index( + "uq_external_case_results_session_external_id", + "session_id", + "external_id", + unique=True, + postgresql_where=text("external_id IS NOT NULL"), + ), + ) + + id = Column(GUID(), primary_key=True, default=uuid.uuid4) + session_id = Column(GUID(), ForeignKey("external_run_sessions.id"), nullable=False) + test_case_id = Column(GUID(), ForeignKey("test_cases.id"), nullable=True) + external_id = Column(String(500), nullable=True) + title = Column(String(500), nullable=False) + outcome = Column( + Enum( + CaseStatus, + name="case_outcome", + values_callable=_enum_values, + create_type=False, + ), + nullable=False, + ) + duration_ms = Column(Integer, nullable=False) + error_message = Column(Text, nullable=True) + auto_registered = Column(Boolean, nullable=False, default=False, server_default="false") + created_at = Column(DateTime, nullable=False, default=_utcnow) + updated_at = Column(DateTime, nullable=False, default=_utcnow, onupdate=_utcnow) diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 00000000..2abb765c --- /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/models/test_case.py b/backend/app/models/test_case.py index c32db2c4..acc88476 100644 --- a/backend/app/models/test_case.py +++ b/backend/app/models/test_case.py @@ -1,7 +1,7 @@ import enum import uuid -from sqlalchemy import Column, Enum, Integer, String, Text +from sqlalchemy import Boolean, Column, Enum, Integer, String, Text from sqlalchemy.orm import relationship from .base import Base, TimestampMixin @@ -44,6 +44,7 @@ class TestCase(Base, TimestampMixin): id = Column(GUID(), primary_key=True, default=uuid.uuid4) external_id = Column(String(100), unique=True, nullable=True, index=True) + auto_registered = Column(Boolean, nullable=False, default=False, server_default="false") title = Column(String(500), nullable=False, index=True) description = Column(Text, nullable=False) type = Column(Enum(TestCaseType, values_callable=_enum_values), nullable=False) diff --git a/backend/app/schemas/audit_log.py b/backend/app/schemas/audit_log.py index f61b8b46..2a76fc5a 100644 --- a/backend/app/schemas/audit_log.py +++ b/backend/app/schemas/audit_log.py @@ -1,13 +1,16 @@ +import json from datetime import datetime from typing import Any from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator class AuditLogResponse(BaseModel): id: UUID - user_id: UUID + actor_kind: str + user_id: UUID | None + actor_token_id: UUID | None action: str resource_type: str resource_id: str @@ -16,6 +19,28 @@ class AuditLogResponse(BaseModel): model_config = ConfigDict(from_attributes=True) + @field_validator("details", mode="before") + @classmethod + def _normalize_details(cls, value: Any) -> dict[str, Any] | None: + if value is None or isinstance(value, dict): + return value + + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + # Preserve invalid historical payloads as structured data instead of + # failing audit-log reads for the entire response page. + return {"raw": value} + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + + return {"value": value} + class AuditLogListResponse(BaseModel): entries: list[AuditLogResponse] diff --git a/backend/app/schemas/external_results.py b/backend/app/schemas/external_results.py index 987b7b1d..5f362a46 100644 --- a/backend/app/schemas/external_results.py +++ b/backend/app/schemas/external_results.py @@ -15,7 +15,7 @@ from typing import Any from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator, model_validator # --------------------------------------------------------------------------- # Enumerations @@ -72,7 +72,7 @@ class SessionCreate(BaseModel): json_schema_extra={"example": _SESSION_EXAMPLE}, ) - runner: str = Field(..., description="Identifier of the test runner (name + version).") + runner: str | None = Field(None, description="Identifier of the test runner (name + version).") project_id: UUID = Field(..., description="BGSTM project this session belongs to.") git_sha: str | None = Field(None, description="Full or short commit SHA being tested.") git_branch: str | None = Field(None, description="Branch name under test.") @@ -143,6 +143,31 @@ class CaseResultCreate(BaseModel): default_factory=list, description="Requirement UUIDs to link; duplicate insertion is a no-op.", ) + requirement_external_ids: list[str] | None = Field( + default=None, + description="Reporter-supplied external IDs. Each is resolved against requirements.external_id; " + "unresolved IDs are dropped (or auto-registered if auto_register_requirements=True) and " + "recorded in the audit-log details.", + ) + auto_register_requirements: bool = Field( + default=False, + description="If true, unknown requirement_external_ids cause stub Requirement rows to be created " + "and linked. Default false — unknown IDs are dropped silently (with audit-log diagnostics).", + ) + + @field_validator("requirement_external_ids") + @classmethod + def _normalize_requirement_external_ids(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None + + normalized_ids: list[str] = [] + for external_id in value: + stripped_external_id = external_id.strip() + if not stripped_external_id: + raise ValueError("requirement_external_ids entries must not be empty.") + normalized_ids.append(stripped_external_id) + return normalized_ids @model_validator(mode="after") def _require_at_least_one_id(self) -> CaseResultCreate: diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 00000000..0ea6b973 --- /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/app/storage/__init__.py b/backend/app/storage/__init__.py new file mode 100644 index 00000000..d9aa1694 --- /dev/null +++ b/backend/app/storage/__init__.py @@ -0,0 +1,45 @@ +"""Storage package for BGSTM artifact binaries (BGSTM#298). + +Public API +---------- +``get_storage()`` + Returns a :class:`~app.storage.base.StorageBackend` instance configured + from the current application settings. This is a **function**, not a + module-level singleton, so tests can swap :attr:`app.config.settings` + without import-time side effects. + +Backend selection +----------------- +Driven by ``BGSTM_STORAGE_BACKEND`` (``"local"`` or ``"s3"``). +""" + +from __future__ import annotations + +from app.config import settings +from app.storage.base import StorageBackend, StorageResult +from app.storage.local import LocalFsBackend +from app.storage.s3 import S3Backend + +__all__ = [ + "StorageBackend", + "StorageResult", + "LocalFsBackend", + "S3Backend", + "get_storage", +] + + +def get_storage() -> StorageBackend: + """Return a :class:`StorageBackend` based on the current settings. + + Called fresh on each request so tests can swap settings safely. + """ + backend = settings.BGSTM_STORAGE_BACKEND.lower() + if backend == "local": + return LocalFsBackend( + root=settings.BGSTM_ARTIFACTS_DIR, + url_prefix=settings.BGSTM_ARTIFACT_URL_PREFIX, + ) + if backend == "s3": + return S3Backend() + raise ValueError(f"Unknown BGSTM_STORAGE_BACKEND={backend!r}; expected 'local' or 's3'.") diff --git a/backend/app/storage/base.py b/backend/app/storage/base.py new file mode 100644 index 00000000..213e0769 --- /dev/null +++ b/backend/app/storage/base.py @@ -0,0 +1,33 @@ +"""Storage backend ABC and shared types (BGSTM#298).""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class StorageResult: + """Returned by :meth:`StorageBackend.save` after a successful write.""" + + key: str + url: str + size_bytes: int + content_type: str + + +class StorageBackend(ABC): + """Pluggable storage abstraction for artifact binaries.""" + + @abstractmethod + def save(self, stream, *, key: str, content_type: str) -> StorageResult: + """Persist *stream* under *key* and return a :class:`StorageResult`. + + ``stream`` must be a file-like object opened in binary mode, + seeked to the beginning. The implementation is responsible for + reading and closing/discarding the stream. + """ + + @abstractmethod + def url_for(self, key: str) -> str: + """Return the public download URL for *key*.""" diff --git a/backend/app/storage/local.py b/backend/app/storage/local.py new file mode 100644 index 00000000..a305bda8 --- /dev/null +++ b/backend/app/storage/local.py @@ -0,0 +1,59 @@ +"""Local filesystem storage backend (BGSTM#298). + +Writes artifacts under a configurable root directory and returns URLs +served by the dev-only static-files route mounted in ``main.py``. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from .base import StorageBackend, StorageResult + + +class LocalFsBackend(StorageBackend): + """Stores artifacts on the local filesystem. + + Args: + root: Directory under which all artifact files are written. + Created on demand if it does not exist. + url_prefix: Base URL prefix used to construct download URLs. + E.g. ``http://localhost:8000/artifacts``. + """ + + def __init__(self, root: str | Path, url_prefix: str) -> None: + self._root = Path(root) + self._url_prefix = url_prefix.rstrip("/") + + def save(self, stream, *, key: str, content_type: str) -> StorageResult: + dest = (self._root / key).resolve() + # Second line of defense: reject keys that escape the artifact root. + if not dest.is_relative_to(self._root.resolve()): + raise ValueError(f"storage key {key!r} escapes artifact root") + dest.parent.mkdir(parents=True, exist_ok=True) + with dest.open("wb") as fp: + shutil.copyfileobj(stream, fp) + size_bytes = dest.stat().st_size + return StorageResult( + key=key, + url=self.url_for(key), + size_bytes=size_bytes, + content_type=content_type, + ) + + def url_for(self, key: str) -> str: + return f"{self._url_prefix}/{key}" + + @property + def root(self) -> Path: + return self._root + + def delete(self, key: str) -> None: + """Remove an artifact file. No-op if it does not exist.""" + target = self._root / key + try: + os.unlink(target) + except FileNotFoundError: + pass diff --git a/backend/app/storage/s3.py b/backend/app/storage/s3.py new file mode 100644 index 00000000..381271f8 --- /dev/null +++ b/backend/app/storage/s3.py @@ -0,0 +1,22 @@ +"""S3 storage backend stub (BGSTM#298). + +Real implementation is out of scope for this milestone; set +``BGSTM_STORAGE_BACKEND=local`` to use the local filesystem backend. +""" + +from __future__ import annotations + +from .base import StorageBackend, StorageResult + + +class S3Backend(StorageBackend): + """Stub S3 backend — raises :class:`NotImplementedError` on every call. + + Set ``BGSTM_STORAGE_BACKEND=local`` to use the local filesystem backend. + """ + + def save(self, stream, *, key: str, content_type: str) -> StorageResult: + raise NotImplementedError("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local") + + def url_for(self, key: str) -> str: + raise NotImplementedError("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local") diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 5483da7c..3798e2db 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -13,7 +13,7 @@ if [ -n "${E2E_SEED_SQL}" ] && [ -f "${E2E_SEED_SQL}" ]; then echo "Seeding E2E test data from ${E2E_SEED_SQL}..." # Convert asyncpg URL to standard psql URL PSQL_URL=$(echo "${DATABASE_URL}" | sed 's|postgresql+asyncpg://|postgresql://|') - if ! psql "${PSQL_URL}" -f "${E2E_SEED_SQL}"; then + if ! psql "${PSQL_URL}" -v ON_ERROR_STOP=1 -f "${E2E_SEED_SQL}"; then echo "ERROR: Seed script failed! Backend will not start with incomplete test data." echo "Check the SQL for type mismatches or missing tables." exit 1 diff --git a/backend/requirements.txt b/backend/requirements.txt index ce46274b..26045fdd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ pydantic-settings==2.13.1 psycopg2-binary==2.9.11 asyncpg==0.31.0 python-dotenv==1.2.1 +python-multipart==0.0.27 pytest==9.0.2 pytest-asyncio==1.3.0 httpx==0.28.1 @@ -16,6 +17,9 @@ PyJWT==2.11.0 bcrypt>=4.0.0 email-validator==2.3.0 reportlab==4.4.10 +# Streaming multipart parser — used by upload_artifact for true mid-wire size-limit abort. +# Pinned to <2.0 to avoid breaking API changes; Dependabot will bump the minor. +streaming-form-data>=1.16,<2.0 # Optional dependencies for LLM embeddings # Uncomment the lines below if using LLM-based similarity diff --git a/backend/tests/api/test_external_results_audit.py b/backend/tests/api/test_external_results_audit.py new file mode 100644 index 00000000..e6d12b61 --- /dev/null +++ b/backend/tests/api/test_external_results_audit.py @@ -0,0 +1,567 @@ +"""Integration tests for External Results audit wiring.""" + +from __future__ import annotations + +import io +import uuid +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.api.external_results import router as external_results_router +from app.auth.dependencies import get_current_user +from app.crud.audit_log import create_audit_entry +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 +from app.storage.base import StorageResult + +_PROJECT_ID = str(uuid.uuid4()) + + +def _make_user(role: UserRole = UserRole.admin) -> 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, + ) + + +def _auth_header(plaintext: str) -> dict[str, str]: + return {"Authorization": f"Bearer {plaintext}"} + + +def _session_payload() -> 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": f"https://ci.example.com/runs/{uuid.uuid4()}", + "metadata": {"os": "ubuntu-22.04"}, + } + + +@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: + 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 + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def admin_user(db_session): + admin = _make_user(UserRole.admin) + db_session.add(admin) + await db_session.commit() + return admin + + +@pytest_asyncio.fixture +async def write_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin_user.id, + ) + + +def test_session_start_writes_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + token_model, plaintext = write_token + with TestClient(app) as client: + response = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert any(c["action"] == "external_results.session.start" for c in captured) + assert captured[0]["actor_kind"] == "runner_token" + assert captured[0]["actor_id"] == token_model.id + + +def test_session_finish_writes_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + _token_model, plaintext = write_token + with TestClient(app) as client: + create_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert create_resp.status_code == 201, create_resp.text + session_id = create_resp.json()["id"] + finish_resp = client.patch( + f"/api/v1/external-results/session/{session_id}", + json={"status": "passed", "summary": {"total": 1, "passed": 1}}, + headers=_auth_header(plaintext), + ) + + assert finish_resp.status_code == 200, finish_resp.text + assert any(c["action"] == "external_results.session.finish" for c in captured) + + +def test_session_start_idempotent_calls_still_write_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + _token_model, plaintext = write_token + payload = _session_payload() + with TestClient(app) as client: + first = client.post("/api/v1/external-results/session", json=payload, headers=_auth_header(plaintext)) + second = client.post("/api/v1/external-results/session", json=payload, headers=_auth_header(plaintext)) + + assert first.status_code == 201 + assert second.status_code == 201 + assert len([c for c in captured if c["action"] == "external_results.session.start"]) == 2 + + +@pytest.mark.asyncio +async def test_audit_log_filter_runner_token_returns_external_results_entries(db_session, admin_user, write_token): + _token_model, plaintext = write_token + with TestClient(app) as client: + create_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert create_resp.status_code == 201, create_resp.text + + async def override_admin(): + return admin_user + + app.dependency_overrides[get_current_user] = override_admin + try: + response = client.get("/api/v1/audit-log?actor_kind=runner_token") + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert any(entry["action"] == "external_results.session.start" for entry in data["entries"]) + + +@pytest.mark.asyncio +async def test_audit_log_filter_user_still_returns_user_entries(db_session, admin_user): + await create_audit_entry( + db_session, + user_id=admin_user.id, + action="requirement.created", + resource_type="requirement", + resource_id=str(uuid.uuid4()), + ) + + async def override_admin(): + return admin_user + + app.dependency_overrides[get_current_user] = override_admin + try: + with TestClient(app) as client: + response = client.get("/api/v1/audit-log?actor_kind=user") + finally: + app.dependency_overrides.pop(get_current_user, None) + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert any(entry["actor_kind"] == "user" for entry in data["entries"]) + + +# --------------------------------------------------------------------------- +# Per-endpoint audit-emission tests +# --------------------------------------------------------------------------- + + +def test_case_create_writes_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + token_model, plaintext = write_token + with TestClient(app) as client: + session_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert session_resp.status_code == 201, session_resp.text + session_id = session_resp.json()["id"] + + resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"TC-{uuid.uuid4()}", + "title": "Audit test case", + "outcome": "passed", + "duration_ms": 50, + "requirement_ids": [], + "requirement_external_ids": ["REQ-001"], + }, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 201, resp.text + create_audit = next((c for c in captured if c["action"] == "external_results.case.create"), None) + assert create_audit is not None + assert create_audit["actor_kind"] == "runner_token" + assert create_audit["actor_id"] == token_model.id + details = create_audit["details"] + assert "session_id" in details + assert "outcome" in details + assert "external_id" in details + assert "auto_registered" in details + + +def test_case_create_idempotent_writes_separate_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + _token_model, plaintext = write_token + with TestClient(app) as client: + session_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert session_resp.status_code == 201, session_resp.text + session_id = session_resp.json()["id"] + + case_payload = { + "session_id": session_id, + "external_id": f"TC-idem-{uuid.uuid4()}", + "title": "Idempotent test case", + "outcome": "passed", + "duration_ms": 30, + "requirement_ids": [], + } + first = client.post("/api/v1/external-results/case", json=case_payload, headers=_auth_header(plaintext)) + second = client.post("/api/v1/external-results/case", json=case_payload, headers=_auth_header(plaintext)) + + assert first.status_code == 201, first.text + assert second.status_code == 200, second.text + create_audits = [c for c in captured if c["action"] == "external_results.case.create"] + idempotent_audits = [c for c in captured if c["action"] == "external_results.case.create.idempotent"] + assert len(create_audits) == 1 + assert len(idempotent_audits) == 1 + + +def test_case_update_writes_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + _token_model, plaintext = write_token + with TestClient(app) as client: + session_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert session_resp.status_code == 201, session_resp.text + session_id = session_resp.json()["id"] + + create_resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"TC-upd-{uuid.uuid4()}", + "title": "Update test case", + "outcome": "passed", + "duration_ms": 30, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + assert create_resp.status_code == 201, create_resp.text + case_id = create_resp.json()["id"] + + patch_resp = client.patch( + f"/api/v1/external-results/case/{case_id}", + json={"outcome": "flaky"}, + headers=_auth_header(plaintext), + ) + + assert patch_resp.status_code == 200, patch_resp.text + update_audit = next((c for c in captured if c["action"] == "external_results.case.update"), None) + assert update_audit is not None + details = update_audit["details"] + assert "previous_outcome" in details + assert "new_outcome" in details + assert details["previous_outcome"] == "passed" + assert details["new_outcome"] == "flaky" + + +def test_artifact_upload_writes_audit(monkeypatch, db_session, write_token): + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + fake_storage_result = StorageResult( + key="test/artifact/test.png", + url="http://example.com/test.png", + size_bytes=72, + content_type="image/png", + ) + monkeypatch.setattr( + "app.api.external_results.get_storage", + lambda: SimpleNamespace(save=lambda stream, *, key, content_type: fake_storage_result), + ) + + _token_model, plaintext = write_token + with TestClient(app) as client: + session_resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert session_resp.status_code == 201, session_resp.text + session_id = session_resp.json()["id"] + + create_resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"TC-art-{uuid.uuid4()}", + "title": "Artifact test case", + "outcome": "passed", + "duration_ms": 30, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + assert create_resp.status_code == 201, create_resp.text + case_id = create_resp.json()["id"] + + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + upload_resp = client.post( + "/api/v1/external-results/artifact", + data={"case_result_id": case_id, "kind": "screenshot", "filename": "test.png"}, + files={"file": ("test.png", io.BytesIO(png_bytes), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert upload_resp.status_code == 201, upload_resp.text + artifact_audit = next((c for c in captured if c["action"] == "external_results.artifact.upload"), None) + assert artifact_audit is not None + details = artifact_audit["details"] + assert "case_result_id" in details + assert "kind" in details + assert "filename" in details + assert "content_type" in details + assert "size_bytes" in details + + +# --------------------------------------------------------------------------- +# Regression-proof enforcement test +# --------------------------------------------------------------------------- + +# Endpoints that intentionally do NOT audit (reads): +_READ_ONLY_OPS = frozenset( + { + ("GET", "/external-results/session/{session_id}"), + ("GET", "/external-results/case/{case_result_id}"), + } +) + + +def _state_changing_routes(): + """Yield (method, path, route) for every non-GET endpoint in the router.""" + for route in external_results_router.routes: + if not hasattr(route, "methods"): + continue + for method in route.methods: + if method == "HEAD": + continue + if (method, route.path) in _READ_ONLY_OPS: + continue + if method == "GET": + continue + yield method, route.path, route + + +@pytest.mark.parametrize("method,path,_route", list(_state_changing_routes())) +def test_state_changing_endpoint_emits_audit(monkeypatch, db_session, write_token, method, path, _route): + """Every state-changing endpoint MUST call write_audit at least once. + + If a new endpoint is added without an audit call, this test fails by default + — that's the point. To intentionally skip auditing on a new read endpoint, + add it to _READ_ONLY_OPS. + """ + captured: list[dict] = [] + + async def fake_write_audit(db, **kwargs): + captured.append(kwargs) + return SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr("app.api.external_results.write_audit", fake_write_audit) + + # Mock storage for the artifact upload endpoint. + fake_storage_result = StorageResult( + key="enf/test.png", + url="http://example.com/enf/test.png", + size_bytes=72, + content_type="image/png", + ) + monkeypatch.setattr( + "app.api.external_results.get_storage", + lambda: SimpleNamespace(save=lambda stream, *, key, content_type: fake_storage_result), + ) + + token_model, plaintext = write_token + headers = _auth_header(plaintext) + + with TestClient(app) as client: + + def _create_session_id() -> str: + r = client.post("/api/v1/external-results/session", json=_session_payload(), headers=headers) + assert r.status_code == 201, r.text + return r.json()["id"] + + def _create_case_id(session_id: str) -> str: + r = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"enf-{uuid.uuid4()}", + "title": "enforcement test case", + "outcome": "passed", + "duration_ms": 5, + "requirement_ids": [], + }, + headers=headers, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + # Drive the endpoint with a happy-path call. + # captured.clear() is called immediately before each actual endpoint call so that + # prerequisite setup calls (which also invoke fake_write_audit) do not mask a + # missing write_audit on the endpoint under test. + if (method, path) == ("POST", "/external-results/session"): + captured.clear() + resp = client.post("/api/v1/external-results/session", json=_session_payload(), headers=headers) + + elif (method, path) == ("PATCH", "/external-results/session/{session_id}"): + session_id = _create_session_id() + captured.clear() + resp = client.patch( + f"/api/v1/external-results/session/{session_id}", + json={"status": "passed", "summary": {"total": 1, "passed": 1}}, + headers=headers, + ) + + elif (method, path) == ("POST", "/external-results/case"): + session_id = _create_session_id() + captured.clear() + resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"enf-{uuid.uuid4()}", + "title": "enforcement test case", + "outcome": "passed", + "duration_ms": 5, + "requirement_ids": [], + }, + headers=headers, + ) + + elif (method, path) == ("PATCH", "/external-results/case/{case_result_id}"): + session_id = _create_session_id() + case_id = _create_case_id(session_id) + captured.clear() + resp = client.patch( + f"/api/v1/external-results/case/{case_id}", + json={"outcome": "flaky"}, + headers=headers, + ) + + elif (method, path) == ("POST", "/external-results/artifact"): + session_id = _create_session_id() + case_id = _create_case_id(session_id) + captured.clear() + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + resp = client.post( + "/api/v1/external-results/artifact", + data={"case_result_id": case_id, "kind": "screenshot", "filename": "test.png"}, + files={"file": ("test.png", io.BytesIO(png_bytes), "image/png")}, + headers=headers, + ) + + else: + raise AssertionError( + f"No dispatch driver for {method} {path}. " + "Add a happy-path driver to test_state_changing_endpoint_emits_audit." + ) + + assert resp.status_code in (200, 201), f"{method} {path} returned {resp.status_code}: {resp.text}" + assert len(captured) >= 1, ( + f"{method} {path} did not call write_audit. " + f"Every state-changing External Results endpoint must emit an audit entry. " + f"If this endpoint is intentionally read-only, add it to _READ_ONLY_OPS." + ) diff --git a/backend/tests/integration/test_external_results_session.py b/backend/tests/integration/test_external_results_session.py index 206bd7dd..1ea49647 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,6 +161,19 @@ 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, project_id): + _model, plaintext = write_token + headers = _auth_header(plaintext) + payload = _session_payload(project_id) + payload.pop("runner") + + with TestClient(app) as client: + resp = client.post("/api/v1/external-results/session", json=payload, headers=headers) + + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["runner"].startswith("@bgstm/playwright-core@") + # --------------------------------------------------------------------------- # Auth: 401 without credentials @@ -158,9 +181,9 @@ def test_create_finish_fetch(self, db_session, write_token): 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) @@ -187,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 @@ -205,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 @@ -226,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"] @@ -242,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( @@ -257,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( @@ -272,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"] @@ -298,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 00000000..de5a0b66 --- /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 00000000..b64c378a --- /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_audit_log.py b/backend/tests/test_audit_log.py index 09013ac8..5238e692 100644 --- a/backend/tests/test_audit_log.py +++ b/backend/tests/test_audit_log.py @@ -1,6 +1,8 @@ """Tests for Audit Log functionality""" import uuid +from datetime import datetime, timezone +from types import SimpleNamespace import pytest import pytest_asyncio @@ -8,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.auth.dependencies import get_current_user -from app.crud.audit_log import create_audit_entry, get_audit_logs +from app.crud.audit_log import create_audit_entry, get_audit_logs, write_audit +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 @@ -79,6 +82,9 @@ async def test_create_audit_entry(db_session): ) assert entry.id is not None + assert entry.actor_kind == "user" + assert entry.user_id == admin.id + assert entry.actor_token_id is None assert entry.action == "requirement.created" assert entry.resource_type == "requirement" assert entry.details == {"title": "My Req"} @@ -139,6 +145,47 @@ async def test_get_audit_logs_filter_by_resource_type(db_session): assert entries[0].resource_type == "link" +@pytest.mark.asyncio +async def test_get_audit_logs_filter_by_actor_kind(db_session): + """get_audit_logs filters by actor kind.""" + admin = _make_admin() + db_session.add(admin) + await db_session.commit() + token_model, _plaintext = await create_runner_token( + db_session, + label="audit-filter-token", + scopes=["external_results:write"], + created_by_user_id=admin.id, + ) + + await create_audit_entry( + db_session, + user_id=admin.id, + action="requirement.created", + resource_type="requirement", + resource_id="1", + ) + await write_audit( + db_session, + actor_kind="runner_token", + actor_id=token_model.id, + action="external_results.session.start", + resource_type="external_session", + resource_id=uuid.uuid4(), + ) + + user_entries, user_total = await get_audit_logs(db_session, actor_kind="user") + runner_entries, runner_total = await get_audit_logs(db_session, actor_kind="runner_token") + token_entries, token_total = await get_audit_logs(db_session, actor_token_id=token_model.id) + + assert user_total == 1 + assert user_entries[0].actor_kind == "user" + assert runner_total == 1 + assert runner_entries[0].actor_kind == "runner_token" + assert token_total == 1 + assert token_entries[0].actor_token_id == token_model.id + + # ── API endpoint tests ──────────────────────────────────────────────────────── @@ -238,3 +285,43 @@ async def override_admin(): assert data["entries"][0]["resource_type"] == "link" finally: app.dependency_overrides.pop(get_current_user, None) + + +@pytest.mark.asyncio +async def test_audit_log_endpoint_parses_string_details(db_session, monkeypatch): + """Audit log endpoint normalizes JSON-string details into dictionaries.""" + admin = _make_admin() + db_session.add(admin) + await db_session.commit() + + async def fake_get_audit_logs(*_args, **_kwargs): + return ( + [ + SimpleNamespace( + id=uuid.uuid4(), + actor_kind="runner_token", + user_id=None, + actor_token_id=uuid.uuid4(), + action="external_results.session.start", + resource_type="external_session", + resource_id=str(uuid.uuid4()), + details='{"project_id":"smoke-project"}', + created_at=datetime.now(timezone.utc), + ) + ], + 1, + ) + + async def override_admin(): + return admin + + app.dependency_overrides[get_current_user] = override_admin + monkeypatch.setattr("app.api.audit_log.get_audit_logs", fake_get_audit_logs) + try: + with TestClient(app) as client: + response = client.get("/api/v1/audit-log?actor_kind=runner_token") + assert response.status_code == 200, response.text + data = response.json() + assert data["entries"][0]["details"]["project_id"] == "smoke-project" + finally: + app.dependency_overrides.pop(get_current_user, None) diff --git a/backend/tests/test_external_results_artifact.py b/backend/tests/test_external_results_artifact.py new file mode 100644 index 00000000..6e1b5200 --- /dev/null +++ b/backend/tests/test_external_results_artifact.py @@ -0,0 +1,646 @@ +"""Tests for the artifact upload endpoint (BGSTM#298). + +Covers: +- Happy path (201, DB record, audit log, file written to local backend) +- Oversized file → 413, partial write detected, temp file cleaned up +- Bad content-type → 415 +- Unknown kind → 422 +- Missing case_result_id (FK violation, case not found) → 404 +- Bad UUID for case_result_id → 422 +- S3 stub raises NotImplementedError +""" + +from __future__ import annotations + +import io +import os +import tempfile +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 + +import app.api.external_results as _api_module +from app.config import settings +from app.crud.runner_token import create_runner_token +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.external_case_artifact import ExternalCaseArtifact +from app.models.project import Project +from app.models.user import User, UserRole +from app.storage.s3 import S3Backend + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PROJECT_ID = str(uuid.uuid4()) + + +def _auth_header(plaintext: str) -> dict[str, str]: + return {"Authorization": f"Bearer {plaintext}"} + + +def _make_user(role: UserRole = UserRole.admin) -> 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, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@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: + 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 + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def admin_user(db_session): + admin = _make_user(UserRole.admin) + db_session.add(admin) + await db_session.commit() + return admin + + +@pytest_asyncio.fixture +async def write_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin_user.id, + ) + + +def _session_payload() -> 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": {}, + } + + +def _create_session(client: TestClient, plaintext: str) -> str: + resp = client.post( + "/api/v1/external-results/session", + json=_session_payload(), + headers=_auth_header(plaintext), + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def _create_case_result(client: TestClient, plaintext: str, session_id: str) -> str: + resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"ext-{uuid.uuid4()}", + "title": "test case", + "outcome": "passed", + "duration_ms": 10, + }, + headers=_auth_header(plaintext), + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestHappyPath: + @pytest.mark.asyncio + async def test_upload_screenshot_returns_201(self, db_session, write_token, tmp_path, monkeypatch): + """201 Created with full response body; DB record + audit log written.""" + _token_model, plaintext = write_token + + # Point local backend at a temp directory + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_URL_PREFIX", "http://testserver/artifacts") + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + file_data = b"\x89PNG\r\n" + b"A" * 100 + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "screenshot", + "filename": "failure-state.png", + }, + files={"file": ("failure-state.png", io.BytesIO(file_data), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 201, resp.text + body = resp.json() + + # Response fields + assert body["case_result_id"] == case_result_id + assert body["kind"] == "screenshot" + assert body["filename"] == "failure-state.png" + assert body["content_type"] == "image/png" + assert body["size_bytes"] == len(file_data) + assert "testserver/artifacts" in body["url"] + assert uuid.UUID(body["id"]) # valid UUID + + # File on disk + artifact_row = await db_session.execute( + select(ExternalCaseArtifact).where(ExternalCaseArtifact.id == uuid.UUID(body["id"])) + ) + artifact = artifact_row.scalar_one() + assert artifact.size_bytes == len(file_data) + stored_file = tmp_path / artifact.storage_key + assert stored_file.exists() + assert stored_file.read_bytes() == file_data + + # Audit log — all five required fields + audit_rows = await db_session.execute( + select(AuditLog).where(AuditLog.action == "external_results.artifact.upload") + ) + audit = audit_rows.scalar_one() + assert audit.details["case_result_id"] == case_result_id + assert audit.details["kind"] == "screenshot" + assert audit.details["size_bytes"] == len(file_data) + assert audit.details["filename"] == "failure-state.png" + assert audit.details["content_type"] == "image/png" + + +# --------------------------------------------------------------------------- +# Size enforcement (413 + cleanup + partial-write assertion) +# --------------------------------------------------------------------------- + + +class TestSizeEnforcement: + @pytest.mark.asyncio + async def test_oversized_file_returns_413_and_cleans_up(self, db_session, write_token, tmp_path, monkeypatch): + """413 on oversized upload; streaming abort is now the actual behavior. + + The handler uses streaming-form-data to parse the multipart body and raises + _SizeLimitExceeded mid-stream as soon as cumulative bytes exceed + BGSTM_ARTIFACT_MAX_BYTES — bytes past the limit are never read from the + connection. + + This test exercises the cleanup / DB-row / artifact-dir guarantees post-abort: + - The handler's own temp file (bgstm_artifact_*) is cleaned up on 413. + - No artifact row is written to the DB. + - No file is left in the artifacts directory. + """ + _token_model, plaintext = write_token + + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_URL_PREFIX", "http://testserver/artifacts") + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_MAX_BYTES", 10) + # Small chunk size so we process the 20-byte file in multiple iterations + monkeypatch.setattr(_api_module, "_ARTIFACT_CHUNK_SIZE", 8) + + # Track temp files created to verify cleanup + created_temps: list[str] = [] + real_mkstemp = tempfile.mkstemp + + def _fake_mkstemp(*args, **kwargs): + fd, path = real_mkstemp(*args, **kwargs) + created_temps.append(path) + return fd, path + + monkeypatch.setattr("tempfile.mkstemp", _fake_mkstemp) + + file_data = b"X" * 20 # 20 bytes > max_bytes=10 + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "screenshot", + "filename": "big.png", + }, + files={"file": ("big.png", io.BytesIO(file_data), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 413, resp.text + assert resp.json()["detail"]["code"] == "artifact.too_large" + + # Temp file must be cleaned up after 413 + for path in created_temps: + assert not os.path.exists(path), f"Temp file {path!r} was not cleaned up after 413" + + # No artifact row in DB + artifact_rows = await db_session.execute(select(ExternalCaseArtifact)) + assert artifact_rows.scalars().all() == [] + + # No file in artifacts dir + artifact_files = list(tmp_path.rglob("*")) + assert artifact_files == [], f"Unexpected files in artifacts dir: {artifact_files}" + + @pytest.mark.asyncio + async def test_oversized_upload_aborts_stream_without_reading_full_body( + self, db_session, write_token, tmp_path, monkeypatch + ): + """Verify that bytes past BGSTM_ARTIFACT_MAX_BYTES are NEVER read from the + request stream. This is the load-bearing test for #320 — it fails against + the old buffer-then-reject implementation and passes only when streaming + abort is correctly wired. + """ + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_MAX_BYTES", 1024) + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_URL_PREFIX", "http://testserver/artifacts") + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as sync_client: + session_id = _create_session(sync_client, plaintext) + case_result_id = _create_case_result(sync_client, plaintext, session_id) + + # Build a multipart body: small text fields + 100 KiB file (100x the 1024 limit). + LIMIT = 1024 + FILE_SIZE = 100 * LIMIT # 100 KiB — well past the limit + boundary = b"bgstmtestboundary" + file_data = b"X" * FILE_SIZE + + def _field_part(name: str, value: str) -> bytes: + return ( + b"--" + + boundary + + b"\r\n" + + b'Content-Disposition: form-data; name="' + + name.encode() + + b'"\r\n' + + b"\r\n" + + value.encode() + + b"\r\n" + ) + + full_body = ( + _field_part("case_result_id", case_result_id) + + _field_part("kind", "screenshot") + + _field_part("filename", "big.png") + + b"--" + + boundary + + b"\r\n" + + b'Content-Disposition: form-data; name="file"; filename="big.png"\r\n' + + b"Content-Type: image/png\r\n" + + b"\r\n" + + file_data + + b"\r\n" + + b"--" + + boundary + + b"--\r\n" + ) + body_size = len(full_body) + + # Track how many bytes our generator has yielded — each yield corresponds + # to one receive() call from the ASGI server (via ASGITransport). + bytes_yielded = 0 + CHUNK_SIZE = 4096 + + async def streaming_body(): + nonlocal bytes_yielded + for i in range(0, len(full_body), CHUNK_SIZE): + chunk = full_body[i : i + CHUNK_SIZE] + bytes_yielded += len(chunk) + yield chunk + + from httpx import ASGITransport, AsyncClient + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + resp = await client.post( + "/api/v1/external-results/artifact", + content=streaming_body(), + headers={ + "Authorization": f"Bearer {plaintext}", + "Content-Type": f"multipart/form-data; boundary={boundary.decode()}", + }, + ) + + assert resp.status_code == 413, resp.text + assert resp.json()["detail"]["code"] == "artifact.too_large" + + # The server must have stopped reading well before the full body was sent. + # Allow generous slack (limit + 256 KiB for framing + chunks), but assert + # well below total body size (100 KiB file ≫ slack). + assert bytes_yielded < body_size // 2, ( + f"Server read {bytes_yielded} of {body_size} body bytes — " + "streaming abort is not actually aborting; bytes past the limit are still being read." + ) + + +# --------------------------------------------------------------------------- +# Malformed multipart body (422 + code=validation_error) +# --------------------------------------------------------------------------- + + +class TestMalformedMultipart: + @pytest.mark.asyncio + async def test_malformed_multipart_returns_422(self, db_session, write_token, tmp_path, monkeypatch): + """Posting a body that is not valid multipart must return 422 with + code=validation_error, never 500. + """ + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + from httpx import ASGITransport, AsyncClient + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + resp = await client.post( + "/api/v1/external-results/artifact", + content=b"this is not multipart data at all \x00\x01\x02", + headers={ + "Authorization": f"Bearer {plaintext}", + # Valid multipart content-type but body is garbage + "Content-Type": "multipart/form-data; boundary=correctboundary", + }, + ) + + assert resp.status_code == 422, resp.text + assert resp.json()["detail"]["code"] == "validation_error" + + +class TestContentTypeEnforcement: + @pytest.mark.asyncio + async def test_disallowed_content_type_returns_415(self, db_session, write_token, tmp_path, monkeypatch): + _token_model, plaintext = write_token + + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "screenshot", + "filename": "attack.exe", + }, + files={"file": ("attack.exe", io.BytesIO(b"MZ\x00"), "application/x-msdownload")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 415, resp.text + assert resp.json()["detail"]["code"] == "artifact.unsupported_type" + + @pytest.mark.asyncio + async def test_other_kind_bypasses_content_type_check(self, db_session, write_token, tmp_path, monkeypatch): + """kind=other accepts any content-type.""" + _token_model, plaintext = write_token + + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_ARTIFACT_URL_PREFIX", "http://testserver/artifacts") + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "other", + "filename": "dump.bin", + }, + files={"file": ("dump.bin", io.BytesIO(b"\x00\x01\x02"), "application/x-custom-binary")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 201, resp.text + assert resp.json()["kind"] == "other" + + +# --------------------------------------------------------------------------- +# Path traversal (filename sanitization) +# --------------------------------------------------------------------------- + + +class TestFilenameValidation: + """Verify that malicious filenames are rejected before any file is written.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "bad_filename", + [ + "../etc/passwd", + "../../tmp/pwned.png", + "/etc/shadow", + "subdir/file.png", + "file\x00.png", # null byte + "file\\path.png", # backslash (also disallowed by regex) + "a" * 256, # too long + ".", # current directory + "..", # parent directory traversal + "...", # dots-only + ], + ) + async def test_unsafe_filename_returns_422_and_writes_nothing( + self, db_session, write_token, tmp_path, monkeypatch, bad_filename + ): + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "screenshot", + "filename": bad_filename, + }, + files={"file": ("payload", io.BytesIO(b"\x89PNG"), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 422, f"Expected 422 for filename={bad_filename!r}, got {resp.status_code}" + assert resp.json()["detail"]["code"] == "validation_error" + + # Nothing should be written to the artifacts directory + written = list(tmp_path.rglob("*")) + assert written == [], f"Files written for malicious filename {bad_filename!r}: {written}" + + @pytest.mark.asyncio + async def test_local_backend_rejects_escaped_key_directly(self, tmp_path): + """LocalFsBackend second-line defense: ValueError if key escapes root.""" + from app.storage.local import LocalFsBackend + + backend = LocalFsBackend(root=tmp_path, url_prefix="http://testserver/artifacts") + with pytest.raises(ValueError, match="escapes artifact root"): + backend.save(io.BytesIO(b"data"), key="../outside/file.txt", content_type="text/plain") + + +# --------------------------------------------------------------------------- +# Validation errors (422) +# --------------------------------------------------------------------------- + + +class TestValidationErrors: + @pytest.mark.asyncio + async def test_unknown_kind_returns_422(self, db_session, write_token, tmp_path, monkeypatch): + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "totally_unknown", + "filename": "file.png", + }, + files={"file": ("file.png", io.BytesIO(b"PNG"), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "validation_error" + + @pytest.mark.asyncio + async def test_invalid_case_result_id_uuid_returns_422(self, db_session, write_token, tmp_path, monkeypatch): + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": "not-a-uuid", + "kind": "screenshot", + "filename": "file.png", + }, + files={"file": ("file.png", io.BytesIO(b"PNG"), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "validation_error" + + +# --------------------------------------------------------------------------- +# FK violation — case_result_id not found (404) +# --------------------------------------------------------------------------- + + +class TestFKViolation: + @pytest.mark.asyncio + async def test_unknown_case_result_id_returns_404(self, db_session, write_token, tmp_path, monkeypatch): + _token_model, plaintext = write_token + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + + with TestClient(app) as client: + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": str(uuid.uuid4()), + "kind": "screenshot", + "filename": "file.png", + }, + files={"file": ("file.png", io.BytesIO(b"PNG"), "image/png")}, + headers=_auth_header(plaintext), + ) + + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "case_result.not_found" + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + + +class TestAuth: + @pytest.mark.asyncio + async def test_missing_auth_returns_401(self, db_session, write_token, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local") + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + case_result_id = _create_case_result(client, plaintext, session_id) + + resp = client.post( + "/api/v1/external-results/artifact", + data={ + "case_result_id": case_result_id, + "kind": "screenshot", + "filename": "file.png", + }, + files={"file": ("file.png", io.BytesIO(b"PNG"), "image/png")}, + # No auth header + ) + + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# S3 stub +# --------------------------------------------------------------------------- + + +class TestS3Stub: + def test_s3_backend_raises_not_implemented(self): + backend = S3Backend() + with pytest.raises(NotImplementedError, match="S3 backend not yet implemented"): + backend.save(io.BytesIO(b"data"), key="test/key", content_type="image/png") + + def test_s3_url_for_raises_not_implemented(self): + backend = S3Backend() + with pytest.raises(NotImplementedError, match="S3 backend not yet implemented"): + backend.url_for("test/key") diff --git a/backend/tests/test_external_results_case.py b/backend/tests/test_external_results_case.py new file mode 100644 index 00000000..a6ccd23f --- /dev/null +++ b/backend/tests/test_external_results_case.py @@ -0,0 +1,540 @@ +"""Integration tests for External Results case-result endpoints (BGSTM#303).""" + +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy import func, select +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.audit_log import AuditLog +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}"} + + +def _make_user(role: UserRole = UserRole.admin) -> 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, + ) + + +def _session_payload() -> 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": f"https://ci.example.com/runs/{uuid.uuid4()}", + "metadata": {"os": "ubuntu-22.04"}, + } + + +@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: + 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 + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def admin_user(db_session): + admin = _make_user(UserRole.admin) + db_session.add(admin) + await db_session.commit() + return admin + + +@pytest_asyncio.fixture +async def write_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin_user.id, + ) + + +@pytest_asyncio.fixture +async def read_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="read-token", + scopes=["external_results:read"], + created_by_user_id=admin_user.id, + ) + + +def _create_session(client: TestClient, plaintext: str) -> str: + response = client.post("/api/v1/external-results/session", json=_session_payload(), headers=_auth_header(plaintext)) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def _create_test_case(db_session: AsyncSession, *, external_id: str | None = None) -> TestCase: + test_case = TestCase( + id=uuid.uuid4(), + external_id=external_id, + title=f"Case {uuid.uuid4().hex[:6]}", + description="desc", + type=TestCaseType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=TestCaseStatus.DRAFT, + ) + db_session.add(test_case) + await db_session.commit() + await db_session.refresh(test_case) + return test_case + + +async def _create_requirement(db_session: AsyncSession) -> Requirement: + requirement = Requirement( + id=uuid.uuid4(), + title=f"Req {uuid.uuid4().hex[:6]}", + description="desc", + type=RequirementType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=RequirementStatus.DRAFT, + ) + db_session.add(requirement) + await db_session.commit() + await db_session.refresh(requirement) + return requirement + + +class TestAutoUpsert: + @pytest.mark.asyncio + async def test_test_case_id_exists_links_and_auto_registered_false(self, db_session, write_token): + _token_model, plaintext = write_token + existing_test_case = await _create_test_case(db_session) + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "test_case_id": str(existing_test_case.id), + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + data = response.json() + assert data["test_case_id"] == str(existing_test_case.id) + assert data["auto_registered"] is False + + @pytest.mark.asyncio + async def test_test_case_id_missing_returns_404(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "test_case_id": str(uuid.uuid4()), + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "case.test_case_not_found" + + @pytest.mark.asyncio + async def test_external_id_existing_links_and_auto_registered_false(self, db_session, write_token): + _token_model, plaintext = write_token + existing_test_case = await _create_test_case(db_session, external_id=f"ext-{uuid.uuid4()}") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": existing_test_case.external_id, + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + data = response.json() + assert data["test_case_id"] == str(existing_test_case.id) + assert data["auto_registered"] is False + + @pytest.mark.asyncio + async def test_external_id_missing_autocreates_test_case(self, db_session, write_token): + _token_model, plaintext = write_token + external_id = f"new-ext-{uuid.uuid4()}" + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": external_id, + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + data = response.json() + assert data["auto_registered"] is True + + created = await db_session.execute(select(TestCase).where(TestCase.id == uuid.UUID(data["test_case_id"]))) + test_case = created.scalar_one_or_none() + assert test_case is not None + assert test_case.external_id == external_id + assert test_case.auto_registered is True + + +class TestTransitions: + @pytest.mark.asyncio + async def test_started_to_passed_allowed(self, db_session, write_token): + _token_model, plaintext = write_token + linked_case = await _create_test_case(db_session) + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + manual_case = ExternalCaseResult( + id=uuid.uuid4(), + session_id=uuid.UUID(session_id), + test_case_id=linked_case.id, + external_id=f"started-{uuid.uuid4()}", + title="started case", + outcome=CaseStatus.started, + duration_ms=1, + auto_registered=False, + ) + db_session.add(manual_case) + await db_session.commit() + response = client.patch( + f"/api/v1/external-results/case/{manual_case.id}", + json={"outcome": "passed"}, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 200, response.text + assert response.json()["outcome"] == "passed" + + @pytest.mark.asyncio + async def test_passed_to_flaky_allowed(self, db_session, write_token): + _token_model, plaintext = write_token + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + create_resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"p2f-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + case_id = create_resp.json()["id"] + response = client.patch( + f"/api/v1/external-results/case/{case_id}", + json={"outcome": "flaky"}, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 200, response.text + assert response.json()["outcome"] == "flaky" + + @pytest.mark.asyncio + async def test_passed_to_failed_blocked(self, db_session, write_token): + _token_model, plaintext = write_token + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + create_resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"p2f-block-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + case_id = create_resp.json()["id"] + response = client.patch( + f"/api/v1/external-results/case/{case_id}", + json={"outcome": "failed"}, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "case.transition.invalid" + + +class TestTraceabilityAndIdempotency: + @pytest.mark.asyncio + async def test_valid_requirement_links_and_echoes(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session) + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"req-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [str(requirement.id)], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [str(requirement.id)] + + @pytest.mark.asyncio + async def test_unknown_requirement_is_unresolved_and_audited(self, db_session, write_token): + _token_model, plaintext = write_token + missing_req_id = uuid.uuid4() + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"missing-req-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [str(missing_req_id)], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [] + + audit_result = await db_session.execute( + select(AuditLog) + .where(AuditLog.action == "external_results.case.create") + .order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert audit.details["unresolved_requirement_ids"] == [str(missing_req_id)] + + @pytest.mark.asyncio + async def test_idempotent_external_id_no_duplicate_rows_or_links(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session) + external_id = f"idem-{uuid.uuid4()}" + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + payload = { + "session_id": session_id, + "external_id": external_id, + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [str(requirement.id)], + } + first = client.post("/api/v1/external-results/case", json=payload, headers=_auth_header(plaintext)) + second = client.post("/api/v1/external-results/case", json=payload, headers=_auth_header(plaintext)) + + assert first.status_code == 201, first.text + assert second.status_code == 200, second.text + assert first.json()["id"] == second.json()["id"] + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(first.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 + + idempotent_audit = await db_session.execute( + select(AuditLog).where(AuditLog.action == "external_results.case.create.idempotent") + ) + assert idempotent_audit.scalar_one_or_none() is not None + + +class TestAuditAndAuth: + @pytest.mark.asyncio + async def test_create_and_update_write_required_audit_fields(self, db_session, write_token): + token_model, plaintext = write_token + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + create_resp = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"audit-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(plaintext), + ) + case_id = create_resp.json()["id"] + patch_resp = client.patch( + f"/api/v1/external-results/case/{case_id}", + json={"outcome": "flaky"}, + headers=_auth_header(plaintext), + ) + + assert create_resp.status_code == 201, create_resp.text + assert patch_resp.status_code == 200, patch_resp.text + + create_audits = await db_session.execute( + select(AuditLog).where(AuditLog.action == "external_results.case.create") + ) + create_entries = create_audits.scalars().all() + assert len(create_entries) == 1 + assert create_entries[0].actor_kind == "runner_token" + assert create_entries[0].actor_token_id == token_model.id + assert create_entries[0].details["external_id"] is not None + + update_audits = await db_session.execute( + select(AuditLog).where(AuditLog.action == "external_results.case.update") + ) + update_entries = update_audits.scalars().all() + assert len(update_entries) == 1 + assert update_entries[0].details["previous_outcome"] == "passed" + assert update_entries[0].details["new_outcome"] == "flaky" + + @pytest.mark.asyncio + async def test_auth_requirements(self, db_session, write_token, read_token): + _write_model, write_plaintext = write_token + _read_model, read_plaintext = read_token + + with TestClient(app) as client: + session_id = _create_session(client, write_plaintext) + + missing_auth_post = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"auth-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + ) + no_write_scope_post = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"auth-scope-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(read_plaintext), + ) + created = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"auth-get-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [], + }, + headers=_auth_header(write_plaintext), + ) + case_id = created.json()["id"] + + client.post( + "/api/v1/auth/register", + json={ + "email": "case-admin@example.com", + "password": "pass", + "full_name": "Case Admin", + "role": "admin", + }, + ) + login = client.post( + "/api/v1/auth/login", + json={"email": "case-admin@example.com", "password": "pass"}, + ) + admin_jwt = login.json()["access_token"] + + get_with_user = client.get( + f"/api/v1/external-results/case/{case_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + get_with_runner = client.get( + f"/api/v1/external-results/case/{case_id}", + headers=_auth_header(read_plaintext), + ) + get_unknown = client.get( + f"/api/v1/external-results/case/{uuid.uuid4()}", + headers=_auth_header(read_plaintext), + ) + + assert missing_auth_post.status_code == 401 + assert no_write_scope_post.status_code == 403 + assert get_with_user.status_code == 200 + assert get_with_runner.status_code == 200 + assert get_unknown.status_code == 404 diff --git a/backend/tests/test_external_results_requirement_links.py b/backend/tests/test_external_results_requirement_links.py new file mode 100644 index 00000000..5e304c97 --- /dev/null +++ b/backend/tests/test_external_results_requirement_links.py @@ -0,0 +1,364 @@ +"""Requirement-link integration tests for External Results case-result endpoints.""" + +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy import func, select +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.audit_log import AuditLog +from app.models.base import Base +from app.models.link import RequirementTestCaseLink +from app.models.project import Project +from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType +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}"} + + +def _make_user(role: UserRole = UserRole.admin) -> 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, + ) + + +def _session_payload() -> 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": f"https://ci.example.com/runs/{uuid.uuid4()}", + "metadata": {"os": "ubuntu-22.04"}, + } + + +@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: + 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 + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def admin_user(db_session): + admin = _make_user(UserRole.admin) + db_session.add(admin) + await db_session.commit() + return admin + + +@pytest_asyncio.fixture +async def write_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin_user.id, + ) + + +def _create_session(client: TestClient, plaintext: str) -> str: + response = client.post("/api/v1/external-results/session", json=_session_payload(), headers=_auth_header(plaintext)) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def _create_requirement( + db_session: AsyncSession, + *, + external_id: str | None = None, +) -> Requirement: + requirement = Requirement( + id=uuid.uuid4(), + title=f"Req {uuid.uuid4().hex[:6]}", + description="desc", + type=RequirementType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=RequirementStatus.DRAFT, + external_id=external_id, + ) + db_session.add(requirement) + await db_session.commit() + await db_session.refresh(requirement) + return requirement + + +class TestRequirementExternalIds: + @pytest.mark.asyncio + async def test_known_external_id_resolves_and_links(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session, external_id="REQ-KNOWN") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"known-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-KNOWN"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [str(requirement.id)] + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 + + @pytest.mark.asyncio + async def test_unknown_external_id_without_auto_register_is_audited_and_unlinked(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"missing-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-MISSING"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [] + + audit_result = await db_session.execute( + select(AuditLog) + .where(AuditLog.action == "external_results.case.create") + .order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert audit.details["requirement_external_ids_submitted"] == ["REQ-MISSING"] + assert audit.details["unresolved_requirement_external_ids"] == ["REQ-MISSING"] + assert audit.details["auto_register_requirements"] is False + + @pytest.mark.asyncio + async def test_unknown_external_id_with_auto_register_creates_requirement_and_link(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"auto-register-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-MISSING"], + "auto_register_requirements": True, + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + requirement_id = uuid.UUID(response.json()["requirement_ids"][0]) + + requirement_result = await db_session.execute(select(Requirement).where(Requirement.id == requirement_id)) + requirement = requirement_result.scalar_one() + assert requirement.external_id == "REQ-MISSING" + assert requirement.title == "REQ-MISSING" + assert requirement.description == "Auto-registered from external ID REQ-MISSING" + assert requirement.type == RequirementType.FUNCTIONAL + assert requirement.priority == PriorityLevel.MEDIUM + assert requirement.status == RequirementStatus.DRAFT + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 + + @pytest.mark.asyncio + async def test_mixed_uuid_and_external_ids_deduplicate_union(self, db_session, write_token): + _token_model, plaintext = write_token + shared_requirement = await _create_requirement(db_session, external_id="REQ-SHARED") + uuid_only_requirement = await _create_requirement(db_session) + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"mixed-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [str(shared_requirement.id), str(uuid_only_requirement.id)], + "requirement_external_ids": ["REQ-SHARED"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert set(response.json()["requirement_ids"]) == { + str(shared_requirement.id), + str(uuid_only_requirement.id), + } + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + ) + assert link_count_result.scalar_one() == 2 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("submitted_value", "include_field"), + [ + (None, True), + ([], True), + (None, False), + ], + ) + async def test_empty_or_null_external_ids_are_noop_and_do_not_pollute_audit( + self, + db_session, + write_token, + submitted_value, + include_field, + ): + _token_model, plaintext = write_token + + payload: dict[str, object] = { + "session_id": None, + "external_id": f"noop-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + } + + with TestClient(app) as client: + payload["session_id"] = _create_session(client, plaintext) + if include_field: + payload["requirement_external_ids"] = submitted_value + response = client.post( + "/api/v1/external-results/case", + json=payload, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [] + + audit_result = await db_session.execute( + select(AuditLog) + .where(AuditLog.action == "external_results.case.create") + .order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert "requirement_external_ids_submitted" not in audit.details + assert "unresolved_requirement_external_ids" not in audit.details + assert "auto_register_requirements" not in audit.details + + @pytest.mark.asyncio + async def test_whitespace_only_external_id_entry_returns_422(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"validation-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": [" "], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_idempotent_repost_with_same_external_ids_does_not_duplicate_links(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session, external_id="REQ-IDEMPOTENT") + external_id = f"idem-{uuid.uuid4()}" + payload = { + "external_id": external_id, + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-IDEMPOTENT"], + } + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + first = client.post( + "/api/v1/external-results/case", + json={"session_id": session_id, **payload}, + headers=_auth_header(plaintext), + ) + second = client.post( + "/api/v1/external-results/case", + json={"session_id": session_id, **payload}, + headers=_auth_header(plaintext), + ) + + assert first.status_code == 201, first.text + assert second.status_code == 200, second.text + assert first.json()["id"] == second.json()["id"] + assert second.json()["requirement_ids"] == [str(requirement.id)] + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(first.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 diff --git a/docs/specs/external_results_v1.md b/docs/specs/external_results_v1.md index 00936456..bfe36ac5 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 **Note:** There is no separate `content_type` or `size_bytes` form field. The `content_type` is read from the `file` part's `Content-Type` header and the `size_bytes` is counted while streaming the body. -```json -{ - "case_result_id": "7f000001-0000-0000-0000-000000000001", - "kind": "screenshot", - "filename": "failure-state.png", - "content_type": "image/png", - "size_bytes": 20480 -} +#### Content-type allowlist + +The following MIME types are accepted. Requests with any other content-type are rejected with `415` **unless** `kind=other`, which bypasses the allowlist entirely. + +``` +image/png image/jpeg image/gif image/webp +video/webm video/mp4 video/mpeg +application/zip application/x-zip-compressed application/octet-stream +text/plain application/json ``` +#### Size enforcement + +The server enforces `BGSTM_ARTIFACT_MAX_BYTES` (default 50 MiB) by parsing the multipart body with `streaming-form-data`. A `_SizeLimitExceeded` sentinel is raised inside the part-data callback the moment the cumulative byte count of the `file` part exceeds the limit — the stream loop exits immediately and the server returns `413` without reading further bytes from the connection. Server aborts streaming parse mid-request when the cumulative byte count exceeds `BGSTM_ARTIFACT_MAX_BYTES`; bytes past the limit are not read from the connection. + #### Success response — `201 Created` ```json @@ -386,13 +412,24 @@ Multipart upload (`multipart/form-data`). The metadata part must come first, fol | `401` | `runner_token.invalid` | Missing or invalid token. | | `403` | `runner_token.scope_denied` | Token lacks `external_results:write`. | | `404` | `case_result.not_found` | `case_result_id` does not exist. | -| `409` | `artifact.duplicate` | Same `case_result_id` + same SHA-256 already exists (returns existing artifact). | | `413` | `artifact.too_large` | Artifact body exceeds the configured size limit. | | `415` | `artifact.unsupported_type` | `content_type` is not in the allowed list. | -| `422` | `validation_error` | Metadata part fails schema validation. | +| `422` | `validation_error` | Payload fails schema validation (bad UUID, unknown kind, etc.). | | `500` | `internal_error` | Unexpected server error. | -Artifact storage implementation is tracked in [BGSTM#298](https://github.com/bg-playground/BGSTM/issues/298). +#### Audit log + +Every successful upload writes an `external_results.artifact.upload` audit entry. The `details` JSON always contains these five fields (the smoke workflow at PR #314 reconstructs case-result → artifact relationships from these fields): + +```json +{ + "case_result_id": "", + "kind": "", + "size_bytes": 20480, + "filename": "failure-state.png", + "content_type": "image/png" +} +``` --- @@ -404,11 +441,9 @@ When a runner POSTs a case result with an `external_id` that already exists for ### Traceability links — `requirement_ids` -Inserting a `(test_case_id, requirement_id)` link that already exists is a **no-op**. No error is raised. - -### Artifacts — deduplication by SHA-256 +Inserting a `(test_case_id, requirement_id)` link that already exists is a **no-op**. No error is raised. The same rule applies when the caller supplies `requirement_external_ids`: after each external ID is resolved (or auto-registered) to a requirement UUID, duplicate links are ignored. -If the body of a new artifact upload has the same SHA-256 as an artifact already attached to the same `case_result_id`, BGSTM returns the **existing artifact row** (`200 OK`) rather than creating a second copy. The `409` code in the error table above is the response shape, but the HTTP status is `200` (not an error condition — the caller's intent is fulfilled). +Artifact uploads are not deduplicated; reporters that retry an upload (for example on transient network errors) may create duplicate artifact rows, and this is acceptable for v0.1. --- @@ -471,19 +506,60 @@ All error responses share a single envelope: | `case_result.transition.invalid` | Requested outcome transition is not allowed. | | `requirement.not_found` | One or more `requirement_ids` do not exist. | | `artifact.not_found` | Artifact UUID does not exist. | -| `artifact.too_large` | Artifact body exceeds size limit. | +| `artifact.too_large` | Artifact body exceeds size limit. Server aborts streaming parse mid-request when the cumulative byte count exceeds `BGSTM_ARTIFACT_MAX_BYTES`; bytes past the limit are not read from the connection. | | `artifact.unsupported_type` | `content_type` is not in the allowed list. | -| `artifact.duplicate` | Identical artifact already exists (see §d). | | `validation_error` | Request body failed Pydantic schema validation. | | `internal_error` | Unhandled server-side error. | --- -## g. Observability +## g. Storage abstraction + +Artifact binaries are stored via a pluggable `StorageBackend` abstraction (`backend/app/storage/`). + +### Backend selection + +Controlled by the `BGSTM_STORAGE_BACKEND` environment variable: + +| Value | Backend | Notes | +|---|---|---| +| `local` | `LocalFsBackend` | Writes to `BGSTM_ARTIFACTS_DIR` (default `./artifacts`). Files are served by a dev-only static route mounted at `/artifacts`. **Do not use in production.** | +| `s3` | `S3Backend` | Stub only — raises `NotImplementedError` with a clear message. Set `BGSTM_STORAGE_BACKEND=local` for now. | + +### Configuration + +| Environment variable | Default | Description | +|---|---|---| +| `BGSTM_STORAGE_BACKEND` | `local` | Backend selector (`local` or `s3`). | +| `BGSTM_ARTIFACTS_DIR` | `./artifacts` | Root directory for `LocalFsBackend`. | +| `BGSTM_ARTIFACT_MAX_BYTES` | `52428800` (50 MiB) | Maximum artifact upload size. | +| `BGSTM_ARTIFACT_URL_PREFIX` | `http://localhost:8000/artifacts` | Base URL used by `LocalFsBackend` when constructing download URLs. | + +#### Recommended deploy hardening + +Configure your reverse proxy to enforce a body-size cap as first-line DoS defense, e.g. nginx `client_max_body_size 60m;` (slightly larger than `BGSTM_ARTIFACT_MAX_BYTES` to allow multipart framing overhead). The handler also enforces the cap at the application layer via streaming abort, but the proxy cap protects against attackers exhausting Python worker time. + +### `StorageBackend` ABC + +```python +class StorageBackend(ABC): + def save(self, stream, *, key: str, content_type: str) -> StorageResult: ... + def url_for(self, key: str) -> str: ... +``` + +`get_storage()` (in `backend/app/storage/__init__.py`) is a **function**, not a module-level singleton, so tests can swap settings without import-time side effects. + +### Dev-only static route + +When `BGSTM_STORAGE_BACKEND=local`, `main.py` mounts a `StaticFiles` route at `/artifacts` pointing at `BGSTM_ARTIFACTS_DIR`. This route is **not** mounted for any other backend. + +--- + +## h. Observability ### Audit log -Every state-changing call writes an **audit log entry** recording the caller's token identity, action, and affected resource. The audit-log integration is tracked in [BGSTM#297](https://github.com/bg-playground/BGSTM/issues/297). +Every state-changing call now writes an **audit log entry** recording actor identity, action, and affected resource (implemented in [BGSTM#297](https://github.com/bg-playground/BGSTM/issues/297)). ### Action taxonomy @@ -492,29 +568,29 @@ Every state-changing call writes an **audit log entry** recording the caller's t | `external_results.session.start` | `POST /session` → `201` | | `external_results.session.finish` | `PATCH /session/{id}` → `200` | | `external_results.case.create` | `POST /case` → `201` | +| `external_results.case.create.idempotent` | `POST /case` returning `200` (duplicate `external_id`) | | `external_results.case.update` | `PATCH /case/{id}` → `200` | | `external_results.artifact.upload` | `POST /artifact` → `201` | Each audit entry records: +- `actor_kind` — `user` or `runner_token`. +- `user_id` — UUID for user actors, nullable for runner-token actors. +- `actor_token_id` — UUID for runner-token actors, nullable for user actors (never the raw token string). - `action` — one of the values above. - `resource_type` — `external_session`, `case_result`, or `artifact`. - `resource_id` — UUID of the created/updated resource. -- `actor_token_id` — UUID of the runner token (never the raw token string). - `project_id` — UUID of the project. - `details` — JSON snapshot of the mutation (before/after where applicable). ---- +Action taxonomy is enforced on the write paths: no state-changing External Results endpoint may skip audit emission. -## h. Reference implementation +--- -The TypeScript reference reporter is being developed in [bgstm-playwright-frameworks](https://github.com/bg-playground/bgstm-playwright-frameworks) as part of [bgstm-playwright-frameworks#3](https://github.com/bg-playground/bgstm-playwright-frameworks/issues/3). +## i. Reference implementation -The reporter: +The TypeScript reference reporter lives in [`bgstm-playwright-frameworks`](https://github.com/bg-playground/bgstm-playwright-frameworks). BGSTM smoke validation is pinned to reference reporter merge commit `ab5d7c1b0740cfbb5004cb6a14851c541364451e` to keep contract verification deterministic. -1. Calls `POST /session` at suite start. -2. Calls `POST /case` for each test result as it completes. -3. Calls `POST /artifact` for screenshots / traces on failure. -4. Calls `PATCH /session/{id}` with the terminal status at suite end. +The BGSTM workflow [`.github/workflows/external-results-smoke.yml`](../../.github/workflows/external-results-smoke.yml) runs this pinned reporter against a live BGSTM stack on relevant pull requests, every push to `main`, and manual dispatch. It validates persisted session/case/artifact/audit outcomes end-to-end for the External Results v1 contract. -A smoke-test that exercises the full lifecycle against a real BGSTM instance will live in BGSTM CI (tracked in [BGSTM#295](https://github.com/bg-playground/BGSTM/issues/295)). +When the reporter releases a new version, bump only the pinned `ref` in `external-results-smoke.yml` in a dedicated PR, then confirm the smoke workflow is green at that new SHA before merging. Do not track `main` directly from the frameworks repository. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0333520..34849a4d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ "@types/node": "^25.3.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.24", "eslint": "^10.0.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -30,9 +30,9 @@ "globals": "^17.3.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", + "typescript": "~6.0.2", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^8.0.3" } }, "node_modules/@alloc/quick-lru": { @@ -180,16 +180,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -250,38 +240,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -294,482 +252,74 @@ "@babel/types": "^7.28.6" }, "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1002,6 +552,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -1018,31 +597,10 @@ "node": ">=18" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -1051,12 +609,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -1065,12 +626,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -1079,26 +643,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -1107,26 +660,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -1135,180 +677,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -1317,40 +814,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -1359,21 +867,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@tailwindcss/node": { "version": "4.2.1", @@ -1628,67 +1132,33 @@ "os": [ "win32" ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", - "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "postcss": "^8.5.6", - "tailwindcss": "4.2.1" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "engines": { + "node": ">= 20" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@tailwindcss/postcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.28.2" + "tslib": "^2.4.0" } }, "node_modules/@types/esrecurse": { @@ -1996,24 +1466,29 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "1.0.0-rc.7" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/acorn": { @@ -2392,48 +1867,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3550,9 +2983,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3595,9 +3028,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -3677,16 +3110,6 @@ "react": "^19.2.4" } }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", @@ -3735,50 +3158,46 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" }, "node_modules/scheduler": { "version": "0.27.0", @@ -3857,14 +3276,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3886,6 +3305,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3900,9 +3327,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3986,18 +3413,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -4013,9 +3439,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4028,13 +3455,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -4075,6 +3505,279 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index eb50fba1..bedc757d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,7 @@ "@types/node": "^25.3.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.24", "eslint": "^10.0.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -35,8 +35,8 @@ "globals": "^17.3.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", + "typescript": "~6.0.2", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^8.0.3" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 21db3b2f..4e25c340 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -19,6 +19,7 @@ const allProjects = [ export default defineConfig({ testDir: './tests/e2e', + globalSetup: './tests/e2e/global-setup.ts', timeout: process.env.CI ? 60_000 : 30_000, fullyParallel: false, forbidOnly: !!process.env.CI, diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx index 8467026f..9d96b4a5 100644 --- a/frontend/src/components/AdminRoute.tsx +++ b/frontend/src/components/AdminRoute.tsx @@ -9,7 +9,7 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { if (!isAuthenticated) return ; if (user?.role !== 'admin') { return ( -
+

403

Access Denied — Admin only.

diff --git a/frontend/tests/e2e/crud.spec.ts b/frontend/tests/e2e/crud.spec.ts index 9c40e23a..3fe416fc 100644 --- a/frontend/tests/e2e/crud.spec.ts +++ b/frontend/tests/e2e/crud.spec.ts @@ -3,6 +3,7 @@ import { login } from './helpers/auth'; const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || 'admin@test.com'; const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || 'password123'; +const ITEM_ROW_SELECTOR = 'tr, [data-testid*="row"], div.bg-white.rounded-lg.shadow-md.p-6'; // --------------------------------------------------------------------------- // Requirements CRUD @@ -35,37 +36,51 @@ test.describe('Requirements CRUD', () => { }); test('edit an existing requirement', async ({ page }) => { - // Click the edit button on the first visible requirement - const editBtn = page.getByRole('button', { name: /edit/i }).first(); - if (!(await editBtn.isVisible().catch(() => false))) { - test.skip(); - return; + const seedTitle = `E2E Edit Req Target ${Date.now()}`; + + await page.getByRole('button', { name: /add requirement|new requirement|\+ requirement/i }).click(); + await page.getByLabel(/title/i).fill(seedTitle); + await page.getByLabel(/description/i).fill('Throwaway requirement target for edit test.'); + await page.getByRole('button', { name: /save|create|submit/i }).click(); + + const createDialog = page.locator('[role="dialog"]'); + if (await createDialog.isVisible().catch(() => false)) { + await createDialog.waitFor({ state: 'hidden', timeout: 10_000 }); } - await editBtn.click(); + + const requirementRow = page.locator(ITEM_ROW_SELECTOR).filter({ hasText: seedTitle }).first(); + await expect(requirementRow).toBeVisible({ timeout: 10_000 }); + await requirementRow.getByRole('button', { name: /edit/i }).click(); const titleInput = page.getByLabel(/title/i); + const updatedTitle = `${seedTitle} (edited)`; await titleInput.clear(); - await titleInput.fill('Updated Requirement Title'); + await titleInput.fill(updatedTitle); await page.getByRole('button', { name: /save|update|submit/i }).click(); - await expect(page.getByText('Updated Requirement Title')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(updatedTitle)).toBeVisible({ timeout: 10_000 }); await expect(page.getByText(/updated successfully|saved successfully/i)).toBeVisible({ timeout: 10_000, }); }); test('delete a requirement', async ({ page }) => { - // Find a requirement row and click its delete button - const deleteBtn = page.getByRole('button', { name: /delete/i }).first(); - if (!(await deleteBtn.isVisible().catch(() => false))) { - test.skip(); - return; + const seedTitle = `E2E Delete Req Target ${Date.now()}`; + + await page.getByRole('button', { name: /add requirement|new requirement|\+ requirement/i }).click(); + await page.getByLabel(/title/i).fill(seedTitle); + await page.getByLabel(/description/i).fill('Throwaway requirement target for delete test.'); + await page.getByRole('button', { name: /save|create|submit/i }).click(); + + const createDialog = page.locator('[role="dialog"]'); + if (await createDialog.isVisible().catch(() => false)) { + await createDialog.waitFor({ state: 'hidden', timeout: 10_000 }); } - // Grab the title from the h3 heading within the requirement card - const card = deleteBtn.locator('../..').first(); - const itemTitle = await card.locator('h3').first().textContent().catch(() => ''); + const requirementRow = page.locator(ITEM_ROW_SELECTOR).filter({ hasText: seedTitle }).first(); + await expect(requirementRow).toBeVisible({ timeout: 10_000 }); + const deleteBtn = requirementRow.getByRole('button', { name: /delete/i }); // Handle the native confirm() dialog BEFORE clicking delete page.once('dialog', async (dialog) => { @@ -75,9 +90,7 @@ test.describe('Requirements CRUD', () => { await deleteBtn.click(); await expect(page.getByText(/deleted successfully/i)).toBeVisible({ timeout: 10_000 }); - if (itemTitle) { - await expect(page.getByText(itemTitle.trim(), { exact: true })).toHaveCount(0); - } + await expect(page.getByText(seedTitle, { exact: true })).toHaveCount(0); }); }); @@ -112,32 +125,52 @@ test.describe('Test Cases CRUD', () => { }); test('edit an existing test case', async ({ page }) => { - const editBtn = page.getByRole('button', { name: /edit/i }).first(); - if (!(await editBtn.isVisible().catch(() => false))) { - test.skip(); - return; + const seedTitle = `E2E Edit TC Target ${Date.now()}`; + + await page.getByRole('button', { name: /add test case|new test case|\+ test case/i }).click(); + await page.getByLabel(/title/i).fill(seedTitle); + await page.getByLabel(/description/i).fill('Throwaway test case target for edit test.'); + await page.getByRole('button', { name: /save|create|submit/i }).click(); + + const createDialog = page.locator('[role="dialog"]'); + if (await createDialog.isVisible().catch(() => false)) { + await createDialog.waitFor({ state: 'hidden', timeout: 10_000 }); } - await editBtn.click(); + + const testCaseRow = page.locator(ITEM_ROW_SELECTOR).filter({ hasText: seedTitle }).first(); + await expect(testCaseRow).toBeVisible({ timeout: 10_000 }); + await testCaseRow.getByRole('button', { name: /edit/i }).click(); const titleInput = page.getByLabel(/title/i); + const updatedTitle = `${seedTitle} (edited)`; await titleInput.clear(); - await titleInput.fill('Updated Test Case Title'); + await titleInput.fill(updatedTitle); await page.getByRole('button', { name: /save|update|submit/i }).click(); - await expect(page.getByText('Updated Test Case Title')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(updatedTitle)).toBeVisible({ timeout: 10_000 }); await expect(page.getByText(/updated successfully|saved successfully/i)).toBeVisible({ timeout: 10_000, }); }); test('delete a test case', async ({ page }) => { - const deleteBtn = page.getByRole('button', { name: /delete/i }).first(); - if (!(await deleteBtn.isVisible().catch(() => false))) { - test.skip(); - return; + const seedTitle = `E2E Delete TC Target ${Date.now()}`; + + await page.getByRole('button', { name: /add test case|new test case|\+ test case/i }).click(); + await page.getByLabel(/title/i).fill(seedTitle); + await page.getByLabel(/description/i).fill('Throwaway test case target for delete test.'); + await page.getByRole('button', { name: /save|create|submit/i }).click(); + + const createDialog = page.locator('[role="dialog"]'); + if (await createDialog.isVisible().catch(() => false)) { + await createDialog.waitFor({ state: 'hidden', timeout: 10_000 }); } + const testCaseRow = page.locator(ITEM_ROW_SELECTOR).filter({ hasText: seedTitle }).first(); + await expect(testCaseRow).toBeVisible({ timeout: 10_000 }); + const deleteBtn = testCaseRow.getByRole('button', { name: /delete/i }); + // Handle the native confirm() dialog BEFORE clicking delete page.once('dialog', async (dialog) => { await dialog.accept(); @@ -146,5 +179,6 @@ test.describe('Test Cases CRUD', () => { await deleteBtn.click(); await expect(page.getByText(/deleted successfully/i)).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(seedTitle, { exact: true })).toHaveCount(0); }); }); diff --git a/frontend/tests/e2e/fixtures/seed.sql b/frontend/tests/e2e/fixtures/seed.sql index cb3e74a9..489d23f9 100644 --- a/frontend/tests/e2e/fixtures/seed.sql +++ b/frontend/tests/e2e/fixtures/seed.sql @@ -1,6 +1,7 @@ -- E2E test seed data -- Passwords are bcrypt hashes of "password123" -- This file is run AFTER alembic upgrade head via the backend entrypoint +\set ON_ERROR_STOP on -- ============================================================ -- Users @@ -60,29 +61,76 @@ ON CONFLICT (id) DO NOTHING; -- ============================================================ -- Test Cases (5 sample) -- ============================================================ -INSERT INTO test_cases (id, title, description, type, priority, status, automation_status, created_at, updated_at) -VALUES - ('20000000-0000-0000-0000-000000000001'::uuid, - 'TC-001: Login with valid credentials', - 'Verify that a user can log in with valid email and password.', - 'functional', 'high', 'ready', 'automated', NOW(), NOW()), - ('20000000-0000-0000-0000-000000000002'::uuid, - 'TC-002: Login with invalid credentials', - 'Verify that an error message is shown for invalid credentials.', - 'functional', 'high', 'ready', 'automated', NOW(), NOW()), - ('20000000-0000-0000-0000-000000000003'::uuid, - 'TC-003: Export PDF report', - 'Verify that the traceability matrix can be exported as a PDF.', - 'functional', 'medium', 'draft', 'manual', NOW(), NOW()), - ('20000000-0000-0000-0000-000000000004'::uuid, - 'TC-004: Role enforcement for admin actions', - 'Verify that only admin users can access administrative features.', - 'functional', 'high', 'ready', 'manual', NOW(), NOW()), - ('20000000-0000-0000-0000-000000000005'::uuid, - 'TC-005: API response time under load', - 'Measure API response times with 100 concurrent requests.', - 'performance', 'low', 'draft', 'manual', NOW(), NOW()) -ON CONFLICT (id) DO NOTHING; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'test_cases' + AND column_name = 'auto_registered' + ) THEN + INSERT INTO test_cases + (id, external_id, title, description, type, priority, status, automation_status, auto_registered, created_at, updated_at) + VALUES + ('20000000-0000-0000-0000-000000000001'::uuid, + 'TC-001', + 'TC-001: Login with valid credentials', + 'Verify that a user can log in with valid email and password.', + 'functional', 'high', 'ready', 'automated', false, NOW(), NOW()), + ('20000000-0000-0000-0000-000000000002'::uuid, + 'TC-002', + 'TC-002: Login with invalid credentials', + 'Verify that an error message is shown for invalid credentials.', + 'functional', 'high', 'ready', 'automated', false, NOW(), NOW()), + ('20000000-0000-0000-0000-000000000003'::uuid, + 'TC-003', + 'TC-003: Export PDF report', + 'Verify that the traceability matrix can be exported as a PDF.', + 'functional', 'medium', 'draft', 'manual', false, NOW(), NOW()), + ('20000000-0000-0000-0000-000000000004'::uuid, + 'TC-004', + 'TC-004: Role enforcement for admin actions', + 'Verify that only admin users can access administrative features.', + 'functional', 'high', 'ready', 'manual', false, NOW(), NOW()), + ('20000000-0000-0000-0000-000000000005'::uuid, + 'TC-005', + 'TC-005: API response time under load', + 'Measure API response times with 100 concurrent requests.', + 'performance', 'low', 'draft', 'manual', false, NOW(), NOW()) + ON CONFLICT (id) DO NOTHING; + ELSE + INSERT INTO test_cases + (id, external_id, title, description, type, priority, status, automation_status, created_at, updated_at) + VALUES + ('20000000-0000-0000-0000-000000000001'::uuid, + 'TC-001', + 'TC-001: Login with valid credentials', + 'Verify that a user can log in with valid email and password.', + 'functional', 'high', 'ready', 'automated', NOW(), NOW()), + ('20000000-0000-0000-0000-000000000002'::uuid, + 'TC-002', + 'TC-002: Login with invalid credentials', + 'Verify that an error message is shown for invalid credentials.', + 'functional', 'high', 'ready', 'automated', NOW(), NOW()), + ('20000000-0000-0000-0000-000000000003'::uuid, + 'TC-003', + 'TC-003: Export PDF report', + 'Verify that the traceability matrix can be exported as a PDF.', + 'functional', 'medium', 'draft', 'manual', NOW(), NOW()), + ('20000000-0000-0000-0000-000000000004'::uuid, + 'TC-004', + 'TC-004: Role enforcement for admin actions', + 'Verify that only admin users can access administrative features.', + 'functional', 'high', 'ready', 'manual', NOW(), NOW()), + ('20000000-0000-0000-0000-000000000005'::uuid, + 'TC-005', + 'TC-005: API response time under load', + 'Measure API response times with 100 concurrent requests.', + 'performance', 'low', 'draft', 'manual', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING; + END IF; +END $$; -- ============================================================ -- Existing links (3) diff --git a/frontend/tests/e2e/global-setup.ts b/frontend/tests/e2e/global-setup.ts new file mode 100644 index 00000000..af716d59 --- /dev/null +++ b/frontend/tests/e2e/global-setup.ts @@ -0,0 +1,76 @@ +import { apiLogin, API_URL } from './helpers/api'; + +type PaginatedResponse = { + items: T[]; + total: number; +}; + +type SeededTestCase = { + external_id: string | null; + title: string; +}; + +const API_PREFIX = '/api/v1'; +const REQUIRED_TEST_CASE_IDS = ['TC-001', 'TC-002', 'TC-003', 'TC-004', 'TC-005'] as const; + +async function getPaginated(path: string, token: string): Promise> { + const response = await fetch(`${API_URL}${API_PREFIX}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`[E2E seed smoke check] GET ${path} failed with ${response.status}: ${body}`); + } + + return (await response.json()) as PaginatedResponse; +} + +export default async function globalSetup(): Promise { + const email = process.env.E2E_ADMIN_EMAIL || 'admin@test.com'; + const password = process.env.E2E_ADMIN_PASSWORD || 'password123'; + const token = await apiLogin(email, password); + + const [requirements, testCases, links] = await Promise.all([ + getPaginated('/requirements?page=1&page_size=200', token), + getPaginated('/test-cases?page=1&page_size=200', token), + getPaginated('/links?page=1&page_size=200', token), + ]); + + if (requirements.total < 5) { + throw new Error( + `[E2E seed smoke check] requirements seed incomplete: expected >= 5, found ${requirements.total}.\n` + + `This usually means seed.sql failed mid-file. Check the bgstm-test-db container logs for the failing INSERT.`, + ); + } + + if (testCases.total < 5) { + throw new Error( + `[E2E seed smoke check] test_cases seed incomplete: expected >= 5, found ${testCases.total}.\n` + + `This usually means seed.sql failed mid-file. Check the bgstm-test-db container logs for the failing INSERT.`, + ); + } + + if (links.total < 3) { + throw new Error( + `[E2E seed smoke check] requirement_test_case_links seed incomplete: expected >= 3, found ${links.total}.\n` + + `This usually means seed.sql failed mid-file. Check the bgstm-test-db container logs for the failing INSERT.`, + ); + } + + const availableIds = new Set( + testCases.items + .map((testCase) => testCase.external_id) + .filter((externalId): externalId is string => typeof externalId === 'string'), + ); + + const missingSeededCases = REQUIRED_TEST_CASE_IDS.filter((requiredId) => !availableIds.has(requiredId)); + + if (missingSeededCases.length > 0) { + throw new Error( + `[E2E seed smoke check] seeded test_cases missing external_id(s): ${missingSeededCases.join(', ')}`, + ); + } +} diff --git a/frontend/tests/e2e/rbac.spec.ts b/frontend/tests/e2e/rbac.spec.ts index a8ed5d8c..04a6f7b0 100644 --- a/frontend/tests/e2e/rbac.spec.ts +++ b/frontend/tests/e2e/rbac.spec.ts @@ -25,10 +25,7 @@ test.describe('RBAC – Viewer role', () => { // 3. Redirect to login const url = page.url(); const wasRedirected = !url.includes('/admin') || url.includes('/login'); - const showsForbidden = await page - .getByText(/403|unauthorized|forbidden|access denied|not authorized/i) - .isVisible() - .catch(() => false); + const showsForbidden = await page.getByTestId('admin-route-forbidden').isVisible().catch(() => false); const showsEmptyOrError = await page .getByText(/no users|error|something went wrong/i) .isVisible() @@ -94,7 +91,7 @@ test.describe('RBAC – Admin role', () => { expect(page.url()).not.toMatch(/\/login/); // Should NOT see a 403/forbidden page - const isForbidden = await page.getByText(/403|forbidden|access denied/i).isVisible().catch(() => false); + const isForbidden = await page.getByTestId('admin-route-forbidden').isVisible().catch(() => false); expect(isForbidden).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/frontend/tests/e2e/traceability.spec.ts b/frontend/tests/e2e/traceability.spec.ts index 09130ae6..fb8efdac 100644 --- a/frontend/tests/e2e/traceability.spec.ts +++ b/frontend/tests/e2e/traceability.spec.ts @@ -35,8 +35,9 @@ test.describe('Traceability Matrix', () => { }); test('seeded test case "TC-004" is visible in the matrix', async ({ page }) => { - // TC-004 is linked to "Role-Based Access Control" and is not - // touched by CRUD tests, so it remains stable across runs. + // TC-004 is a seeded test case linked to "Role-Based Access Control". + // CRUD tests in crud.spec.ts now create their own throwaway targets + // (see #308) so seeded TC-00X rows remain intact across the suite. const dataRows = page.locator('table tbody tr'); await expect(dataRows.first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(/TC-004/i)).toBeVisible({ timeout: 10_000 }); @@ -64,4 +65,4 @@ test.describe('Traceability Matrix', () => { await expect(exportBtn).toBeVisible(); }); -}); \ No newline at end of file +}); diff --git a/scripts/smoke/assert.py b/scripts/smoke/assert.py new file mode 100644 index 00000000..4e3d4d1a --- /dev/null +++ b/scripts/smoke/assert.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import os +from collections import Counter +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass +class Check: + name: str + passed: bool + detail: str + + +def validate_snapshot(snapshot: dict[str, Any], project_id: str, actor_token_id: str) -> list[Check]: + checks: list[Check] = [] + + sessions = [s for s in snapshot["sessions"] if s.get("project_id") == project_id] + checks.append(Check("single session for project", len(sessions) == 1, f"found={len(sessions)}")) + + session = sessions[0] if sessions else {} + checks.append(Check("session status failed", session.get("status") == "failed", f"status={session.get('status')}")) + checks.append( + Check( + "session runner prefix", + str(session.get("runner", "")).startswith("@bgstm/playwright-core@"), + f"runner={session.get('runner')}", + ) + ) + + session_id = session.get("id") + case_rows = [c for c in snapshot["case_results"] if c.get("session_id") == session_id] + checks.append(Check("three case results", len(case_rows) == 3, f"found={len(case_rows)}")) + + outcome_counts = Counter(c.get("outcome") for c in case_rows) + checks.append( + Check( + "case outcomes 1/1/1", + outcome_counts == Counter({"passed": 1, "failed": 1, "skipped": 1}), + f"counts={dict(outcome_counts)}", + ) + ) + + def _find_case(suffix: str) -> dict[str, Any] | None: + for case in case_rows: + if str(case.get("external_id", "")).endswith(suffix): + return case + return None + + passed_case = _find_case("passes — homepage loads") + failed_case = _find_case("fails — intentional assertion failure to exercise artifact upload") + skipped_case = _find_case("skipped — exercises skip path") + + checks.append(Check("passed case exists", passed_case is not None, "expected suffix=passes — homepage loads")) + + audit_details_by_case_id = { + entry["resource_id"]: (entry.get("details") or {}) + for entry in snapshot["audit_entries"] + if entry.get("action") == "external_results.case.create" + } + + passed_audit = audit_details_by_case_id.get((passed_case or {}).get("id"), {}) + passed_submitted = passed_audit.get("requirement_external_ids_submitted") + passed_unresolved = passed_audit.get("unresolved_requirement_external_ids") + checks.append( + Check( + "passed case submitted REQ-CRM-HOMEPAGE", + passed_submitted == ["REQ-CRM-HOMEPAGE"], + f"requirement_external_ids_submitted={passed_submitted!r}", + ) + ) + checks.append( + Check( + "passed case resolved REQ-CRM-HOMEPAGE (no unresolved)", + passed_unresolved == [], + f"unresolved_requirement_external_ids={passed_unresolved!r}", + ) + ) + + checks.append( + Check( + "failed case exists", + failed_case is not None, + "expected suffix=fails — intentional assertion failure to exercise artifact upload", + ) + ) + + failed_audit = audit_details_by_case_id.get((failed_case or {}).get("id"), {}) + failed_submitted = failed_audit.get("requirement_external_ids_submitted") + failed_unresolved = failed_audit.get("unresolved_requirement_external_ids") + checks.append( + Check( + "failed case submitted REQ-CRM-FAIL-PROBE", + failed_submitted == ["REQ-CRM-FAIL-PROBE"], + f"requirement_external_ids_submitted={failed_submitted!r}", + ) + ) + checks.append( + Check( + "failed case left REQ-CRM-FAIL-PROBE unresolved", + failed_unresolved == ["REQ-CRM-FAIL-PROBE"], + f"unresolved_requirement_external_ids={failed_unresolved!r}", + ) + ) + checks.append(Check("skipped case exists", skipped_case is not None, "expected suffix=skipped — exercises skip path")) + + failed_case_id = (failed_case or {}).get("id") + failed_case_artifacts = [a for a in snapshot["artifacts"] if a.get("case_result_id") == failed_case_id] + checks.append( + Check("failed case has artifact", len(failed_case_artifacts) >= 1, f"artifact_count={len(failed_case_artifacts)}") + ) + checks.append( + Check( + "failed case has screenshot artifact", + any(a.get("kind") == "screenshot" for a in failed_case_artifacts), + f"kinds={[a.get('kind') for a in failed_case_artifacts]}", + ) + ) + screenshot_artifact = next((a for a in failed_case_artifacts if a.get("kind") == "screenshot"), {}) + screenshot_filename = screenshot_artifact.get("filename") + screenshot_content_type = screenshot_artifact.get("content_type") + screenshot_size_bytes = screenshot_artifact.get("size_bytes") + checks.append( + Check( + "artifact has filename", + isinstance(screenshot_filename, str) and bool(screenshot_filename.strip()), + f"filename={screenshot_filename!r}", + ) + ) + checks.append( + Check( + "artifact has content_type", + isinstance(screenshot_content_type, str) + and bool(screenshot_content_type.strip()) + and screenshot_content_type.startswith("image/"), + f"content_type={screenshot_content_type!r}", + ) + ) + checks.append( + Check( + "artifact has size_bytes > 0", + isinstance(screenshot_size_bytes, int) and screenshot_size_bytes > 0, + f"size_bytes={screenshot_size_bytes!r}", + ) + ) + + audit_entries = [ + entry + for entry in snapshot["audit_entries"] + if entry.get("actor_kind") == "runner_token" and entry.get("actor_token_id") == actor_token_id + ] + action_counts = Counter(entry.get("action") for entry in audit_entries) + checks.append(Check("audit total >= 5", len(audit_entries) >= 5, f"total={len(audit_entries)}")) + checks.append( + Check( + "audit action counts", + action_counts.get("external_results.session.start", 0) == 1 + and action_counts.get("external_results.case.create", 0) == 3 + and action_counts.get("external_results.session.finish", 0) == 1, + f"counts={dict(action_counts)}", + ) + ) + + return checks + + +def _fetch_snapshot(api_url: str, admin_jwt: str, project_id: str, actor_token_id: str) -> dict[str, Any]: + headers = {"Authorization": f"Bearer {admin_jwt}"} + + with httpx.Client(base_url=api_url, headers=headers, timeout=30.0) as client: + def _get_json(path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]: + response = client.get(path, params=params) + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError(f"Request failed for {path}: {exc.response.status_code} {exc.response.text}") from exc + payload = response.json() + if not isinstance(payload, dict): + raise RuntimeError(f"Unexpected response shape from {path}: {type(payload).__name__}") + return payload + + audit_entries = _get_json( + "/api/v1/audit-log", + params={"actor_kind": "runner_token", "actor_token_id": actor_token_id, "limit": 500}, + ).get("entries", []) + + session_ids = { + entry.get("resource_id") + for entry in audit_entries + if entry.get("action") == "external_results.session.start" + and (entry.get("details") or {}).get("project_id") == project_id + } + + sessions: list[dict[str, Any]] = [] + for session_id in sorted(str(sid) for sid in session_ids if sid): + sessions.append(_get_json(f"/api/v1/external-results/session/{session_id}")) + + case_results: list[dict[str, Any]] = [] + # `/api/v1/external-results/case/{id}` is not available on main yet + # (tracked for v0.2 follow-up #315), so reconstruct case rows from + # audit entries emitted on case creation. + for entry in audit_entries: + if entry.get("action") != "external_results.case.create": + continue + details = entry.get("details") or {} + payload = details.get("payload") + source = payload if isinstance(payload, dict) else details + case_results.append( + { + "id": entry.get("resource_id"), + "session_id": source.get("session_id"), + "external_id": source.get("external_id") or source.get("title"), + "outcome": source.get("outcome"), + "requirement_ids": source.get("requirement_ids") or [], + } + ) + + artifacts: list[dict[str, Any]] = [] + for entry in audit_entries: + if entry.get("action") != "external_results.artifact.upload": + continue + details = entry.get("details") or {} + payload = details.get("payload") + source = payload if isinstance(payload, dict) else details + artifacts.append( + { + "id": entry.get("resource_id"), + "case_result_id": source.get("case_result_id"), + "kind": source.get("kind"), + "filename": source.get("filename"), + "content_type": source.get("content_type"), + "size_bytes": source.get("size_bytes"), + } + ) + + return { + "sessions": sessions, + "case_results": case_results, + "artifacts": artifacts, + "audit_entries": audit_entries, + } + + +def _print_results(checks: list[Check]) -> None: + print("| Check | Result | Detail |") + print("|---|---|---|") + for check in checks: + icon = "✅" if check.passed else "❌" + print(f"| {check.name} | {icon} | {check.detail} |") + + +def main() -> None: + api_url = os.environ["BGSTM_API_URL"] + admin_jwt = os.environ["BGSTM_ADMIN_JWT"] + project_id = os.environ["BGSTM_PROJECT_ID"] + actor_token_id = os.environ["BGSTM_RUNNER_TOKEN_ID"] + + snapshot = _fetch_snapshot(api_url, admin_jwt, project_id, actor_token_id) + checks = validate_snapshot(snapshot, project_id, actor_token_id) + _print_results(checks) + + failed = [check for check in checks if not check.passed] + if failed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke/bootstrap.py b/scripts/smoke/bootstrap.py new file mode 100644 index 00000000..6f7f9a46 --- /dev/null +++ b/scripts/smoke/bootstrap.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import os +from typing import Any + +import httpx + + +def _api(client: httpx.Client, method: str, path: str, **kwargs) -> dict[str, Any]: + response = client.request(method, path, **kwargs) + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"{method} {path} failed with status={exc.response.status_code}, body={exc.response.text}" + ) from exc + data = response.json() + if not isinstance(data, dict): + raise ValueError(f"Expected object response from {path}, got {type(data).__name__}") + return data + + +def _create_requirement( + client: httpx.Client, + headers: dict[str, str], + *, + external_id: str, + title: str, +) -> str: + response = client.post( + "/api/v1/requirements", + headers=headers, + json={ + "external_id": external_id, + "title": title, + "description": f"Pre-created for smoke test ({external_id}).", + "type": "functional", + "priority": "medium", + "status": "draft", + }, + ) + if response.status_code >= 300: + raise RuntimeError( + f"Requirement creation failed: status={response.status_code}, body={response.text}" + ) + payload = response.json() + requirement_id = payload.get("id") + if not isinstance(requirement_id, str) or not requirement_id: + raise RuntimeError("Requirement creation succeeded but no id was returned.") + return requirement_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() + project_id = payload.get("id") + if isinstance(project_id, str) and project_id: + return project_id + raise RuntimeError("Project creation succeeded but no project id was returned.") + raise RuntimeError(f"Project creation failed: status={response.status_code}, body={response.text}") + + +def main() -> None: + github_env = os.environ.get("GITHUB_ENV") + if not github_env: + raise RuntimeError("GITHUB_ENV is required") + + api_url = os.environ.get("BGSTM_API_URL", "http://localhost:8001") + + with httpx.Client(base_url=api_url, timeout=30.0) as client: + login = _api( + client, + "POST", + "/api/v1/auth/login", + json={"email": "admin@test.com", "password": "password123"}, + ) + admin_jwt = login["access_token"] + headers = {"Authorization": f"Bearer {admin_jwt}"} + + project_id = _create_project_id(client, headers) + + homepage_requirement_id = _create_requirement( + client, + headers, + external_id="REQ-CRM-HOMEPAGE", + title="CRM homepage loads", + ) + + token_payload = { + "label": "smoke", + "scopes": ["external_results:write", "external_results:read"], + } + runner_token = _api(client, "POST", "/api/v1/auth/runner-tokens", headers=headers, json=token_payload) + + with open(github_env, "a", encoding="utf-8") as fh: + fh.write(f"BGSTM_API_URL={api_url}\n") + fh.write(f"BGSTM_API_TOKEN={runner_token['token']}\n") + fh.write(f"BGSTM_PROJECT_ID={project_id}\n") + fh.write(f"BGSTM_ADMIN_JWT={admin_jwt}\n") + fh.write(f"BGSTM_RUNNER_TOKEN_ID={runner_token['id']}\n") + + print( + json.dumps( + { + "api_url": api_url, + "project_id": project_id, + "homepage_requirement_id": homepage_requirement_id, + "runner_token_id": runner_token["id"], + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke/test_assert.py b/scripts/smoke/test_assert.py new file mode 100644 index 00000000..9c534719 --- /dev/null +++ b/scripts/smoke/test_assert.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import sys +from copy import deepcopy +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Any + +_MODULE_PATH = Path(__file__).with_name("assert.py") +_SPEC = spec_from_file_location("smoke_assert", _MODULE_PATH) +if _SPEC is None or _SPEC.loader is None: + raise RuntimeError("Unable to load scripts/smoke/assert.py") +_MODULE = module_from_spec(_SPEC) +sys.modules[_SPEC.name] = _MODULE +_SPEC.loader.exec_module(_MODULE) +validate_snapshot = _MODULE.validate_snapshot + + +PROJECT_ID = "00000000-0000-0000-0000-000000000111" +TOKEN_ID = "00000000-0000-0000-0000-000000000222" +SESSION_ID = "00000000-0000-0000-0000-000000000333" +FAILED_CASE_ID = "00000000-0000-0000-0000-000000000444" + + +def _snapshot() -> dict[str, Any]: + return { + "sessions": [ + { + "id": SESSION_ID, + "project_id": PROJECT_ID, + "status": "failed", + "runner": "@bgstm/playwright-core@0.1.0", + } + ], + "case_results": [ + { + "id": "case-pass", + "session_id": SESSION_ID, + "external_id": "suite > passes — homepage loads", + "outcome": "passed", + "requirement_ids": ["req-1"], + }, + { + "id": FAILED_CASE_ID, + "session_id": SESSION_ID, + "external_id": "suite > fails — intentional assertion failure to exercise artifact upload", + "outcome": "failed", + "requirement_ids": [], + }, + { + "id": "case-skip", + "session_id": SESSION_ID, + "external_id": "suite > skipped — exercises skip path", + "outcome": "skipped", + "requirement_ids": [], + }, + ], + "artifacts": [ + { + "id": "art-1", + "case_result_id": FAILED_CASE_ID, + "kind": "screenshot", + "filename": "failure-state.png", + "content_type": "image/png", + "size_bytes": 20480, + } + ], + "audit_entries": [ + {"actor_kind": "runner_token", "actor_token_id": TOKEN_ID, "action": "external_results.session.start"}, + {"actor_kind": "runner_token", "actor_token_id": TOKEN_ID, "action": "external_results.case.create"}, + {"actor_kind": "runner_token", "actor_token_id": TOKEN_ID, "action": "external_results.case.create"}, + {"actor_kind": "runner_token", "actor_token_id": TOKEN_ID, "action": "external_results.case.create"}, + {"actor_kind": "runner_token", "actor_token_id": TOKEN_ID, "action": "external_results.session.finish"}, + ], + } + + +def test_validate_snapshot_passes_for_expected_payload() -> None: + checks = validate_snapshot(_snapshot(), PROJECT_ID, TOKEN_ID) + assert all(check.passed for check in checks) + + +def test_validate_snapshot_fails_for_wrong_session_count() -> None: + payload = _snapshot() + payload["sessions"].append(deepcopy(payload["sessions"][0])) + checks = validate_snapshot(payload, PROJECT_ID, TOKEN_ID) + assert any((check.name == "single session for project" and not check.passed) for check in checks) + + +def test_validate_snapshot_fails_for_wrong_case_outcome() -> None: + payload = _snapshot() + payload["case_results"][2]["outcome"] = "passed" + checks = validate_snapshot(payload, PROJECT_ID, TOKEN_ID) + assert any((check.name == "case outcomes 1/1/1" and not check.passed) for check in checks) + + +def test_validate_snapshot_fails_for_missing_failed_artifact() -> None: + payload = _snapshot() + payload["artifacts"] = [] + checks = validate_snapshot(payload, PROJECT_ID, TOKEN_ID) + assert any((check.name == "failed case has artifact" and not check.passed) for check in checks) + assert any((check.name == "artifact has filename" and not check.passed) for check in checks) + + +def test_validate_snapshot_fails_for_missing_audit_action() -> None: + payload = _snapshot() + payload["audit_entries"] = payload["audit_entries"][:-1] + checks = validate_snapshot(payload, PROJECT_ID, TOKEN_ID) + assert any((check.name == "audit action counts" and not check.passed) for check in checks)