diff --git a/apps/backend/alembic/versions/0032_scan_kind_sbom.py b/apps/backend/alembic/versions/0032_scan_kind_sbom.py new file mode 100644 index 00000000..43bab829 --- /dev/null +++ b/apps/backend/alembic/versions/0032_scan_kind_sbom.py @@ -0,0 +1,55 @@ +"""scan_kind += sbom + +Revision ID: 0032 +Revises: 0031 +Created: 2026-06-13 + +Phase: sbom-ingest (prereq schema) +Kind: schema +Forward-only: yes + +What: + - Add ``sbom`` to the ``scan_kind`` Postgres enum. + +Why: + - External CycloneDX SBOM ingest (a separate follow-up PR) creates Scan rows + whose source is an uploaded SBOM rather than a source-tree or container + scan. The ``scan_kind`` enum (created in mig 0003) is native Postgres, so a + Scan INSERT with kind ``sbom`` is rejected at the DB layer until the type + accepts it. This revision adds the value up front so the ingest PR ships + purely as application code. The model tuple ``SCAN_KIND_VALUES`` and the + wire ``ScanKind`` Literal are updated in the same PR; a parity test + (tests/unit/test_catalog_contracts.py) keeps the three in lockstep. + +Backfill: + - None. Additive enum value; existing rows unaffected. + +Downgrade: + - Forward-only per CLAUDE.md §6. Postgres cannot drop an enum value without a + full type rebuild (and that would orphan any row already tagged ``sbom``); + we never remove emitted kinds. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0032" +down_revision: str | None = "0031" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # PG 12+ permits ALTER TYPE ... ADD VALUE inside a transaction (the new + # value just cannot be USED in the same transaction — we only add it here). + # IF NOT EXISTS keeps the migration idempotent across partial re-runs. + op.execute("ALTER TYPE scan_kind ADD VALUE IF NOT EXISTS 'sbom'") + + +def downgrade() -> None: + # Forward-only (CLAUDE.md §6). Removing an enum value requires a type + # rebuild and risks orphaning rows; we never drop emitted kinds. + pass diff --git a/apps/backend/models/scan.py b/apps/backend/models/scan.py index cb05efd5..a7356526 100644 --- a/apps/backend/models/scan.py +++ b/apps/backend/models/scan.py @@ -79,7 +79,7 @@ # name= with create_type=False so SQLAlchemy never tries to (re)create them. PROJECT_VISIBILITY_VALUES = ("team", "organization") -SCAN_KIND_VALUES = ("source", "container") +SCAN_KIND_VALUES = ("source", "container", "sbom") SCAN_STATUS_VALUES = ("queued", "running", "succeeded", "failed", "cancelled") VULN_SEVERITY_VALUES = ("critical", "high", "medium", "low", "info", "unknown") # Vulnerability finding status — Phase 2 ships the full 7-state set up front diff --git a/apps/backend/schemas/scan.py b/apps/backend/schemas/scan.py index 5fe0c49e..6851fb4d 100644 --- a/apps/backend/schemas/scan.py +++ b/apps/backend/schemas/scan.py @@ -174,7 +174,7 @@ def _measure_metadata_depth(value: Any, *, _level: int = 0) -> int: # Literal here local — drift would surface immediately as a mypy error in the # service layer when it casts to the model column. ProjectVisibility = Literal["team", "organization"] -ScanKind = Literal["source", "container"] +ScanKind = Literal["source", "container", "sbom"] ScanStatus = Literal["queued", "running", "succeeded", "failed", "cancelled"] diff --git a/apps/backend/tests/unit/test_catalog_contracts.py b/apps/backend/tests/unit/test_catalog_contracts.py index b70ab031..cf7a0a61 100644 --- a/apps/backend/tests/unit/test_catalog_contracts.py +++ b/apps/backend/tests/unit/test_catalog_contracts.py @@ -80,6 +80,26 @@ def test_dispatcher_builders_cover_every_dispatcher_kind() -> None: assert set(_BUILDERS.keys()) == dispatcher_kinds +# --------------------------------------------------------------------------- +# Scan kind vocabulary — drift guard (testing-standards rule 2) +# --------------------------------------------------------------------------- + + +def test_scan_kind_enum_matches_schema_literal() -> None: + """DB enum tuple (models) and the wire Literal (schemas) must be identical. + + ``scan_kind`` lives in three places: the native Postgres enum (migration + 0003, extended by 0032 for ``sbom``), ``SCAN_KIND_VALUES`` (the SQLAlchemy + binding), and the ``ScanKind`` Literal (the request/response contract). A + value added to one without the others either rejects valid input at the API + boundary or rejects a valid INSERT at the DB — this test fails first. + """ + from models.scan import SCAN_KIND_VALUES + from schemas.scan import ScanKind + + assert set(SCAN_KIND_VALUES) == set(typing.get_args(ScanKind)) + + # --------------------------------------------------------------------------- # Obligation kind vocabulary — H-9 guard # ---------------------------------------------------------------------------