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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ Content-Type: application/json
```

**Response:**
- `200 OK`: SBOM accepted by DependencyTrack. Body provides the URL the
publisher should poll for processing status:
```json
{
"polling_url": "https://sbom.eclipse.org/api/v1/bom/token/<token>"
}
```
- `401 Unauthorized`:
- Invalid Authorization header format
- Invalid token
Expand All @@ -206,7 +213,7 @@ Content-Type: application/json
- No matching DependencyTrack project found
- `422 Unprocessable Entity`: Missing Authorization header or invalid JSON
- `502`: DependencyTrack upload request failed
- `*`: Relay DependencyTrack status code
- `*`: Relay DependencyTrack status code and body verbatim (non-2xx)


### 4.2 Settings
Expand Down
33 changes: 28 additions & 5 deletions pia/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .models import (
DependencyTrackUploadPayload,
PiaUploadPayload,
PiaUploadResponse,
Workload,
find_dt_project,
find_workload_by_claims,
Expand Down Expand Up @@ -185,14 +186,36 @@ async def upload_sbom(
settings.dependency_track_api_key,
dt_payload,
)
return Response(
content=dt_response.content,
status_code=dt_response.status_code,
media_type="application/json",
)
except dependencytrack.DependencyTrackError as e:
logger.error(f"DependencyTrack upload failed: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to upload to DependencyTrack",
) from e

# Relay DT failures verbatim; on success, return the polling URL the
# publisher should query for processing status.
if not dt_response.ok:
return Response(
content=dt_response.content,
status_code=dt_response.status_code,
media_type="application/json",
)

try:
token = dt_response.json()["token"]
except (ValueError, KeyError):
# DT returned a 2xx with an unexpected body shape — the upload
# likely landed, but we can't hand the publisher a polling URL.
# Log full context and re-raise so FastAPI returns 500: a retry
# is NOT safe (it would duplicate the SBOM in DT).
logger.error(
f"DependencyTrack returned unparseable success response "
f"(status={dt_response.status_code}, body={dt_response.text!r})"
)
raise

dt_url = str(settings.dependency_track_url).rstrip("/")
return PiaUploadResponse(
polling_url=f"{dt_url}/token/{token}", # type: ignore[arg-type]
)
11 changes: 10 additions & 1 deletion pia/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from typing import Any

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
from sqlalchemy import ForeignKey, Select, String, UniqueConstraint, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

Expand Down Expand Up @@ -201,6 +201,15 @@ class PiaUploadPayload(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)


class PiaUploadResponse(BaseModel):
"""Response for a successful PIA SBOM upload."""

polling_url: HttpUrl
"""DependencyTrack URL to poll for processing status of this upload."""

model_config = ConfigDict(use_attribute_docstrings=True)


class DependencyTrackUploadPayload(BaseModel):
"""Payload for DependencyTrack SBOM upload."""

Expand Down
58 changes: 54 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,67 @@ def test_upload_success(
valid_request_data,
authenticate_as_workload,
):
"""Successful SBOM upload."""
"""Successful SBOM upload returns DT polling URL."""
mock_dt_response = Mock()
mock_dt_response.ok = True
mock_dt_response.status_code = 200
mock_dt_response.content = b"content"
mock_dt_response.json.return_value = {"token": "dt-token-abc"}
mock_upload.return_value = mock_dt_response

response = client.post("/v1/upload/sbom", json=valid_request_data)

assert response.status_code == 200
assert response.content == b"content"
assert response.headers["content-type"] == "application/json"
assert response.json() == {
"polling_url": "https://sbom.eclipse.org/api/v1/bom/token/dt-token-abc"
}

@patch("pia.main.dependencytrack.upload_sbom")
def test_upload_dt_malformed_success_body(
self,
mock_upload,
client,
valid_request_data,
authenticate_as_workload,
caplog,
):
"""A 2xx DT response without a 'token' field propagates an error.

TestClient re-raises server exceptions; in production FastAPI's ASGI
server converts them to 500. Either way the publisher does not get
a misleading 200.
"""
mock_dt_response = Mock()
mock_dt_response.ok = True
mock_dt_response.status_code = 200
mock_dt_response.json.return_value = {"unexpected": "shape"}
mock_dt_response.text = '{"unexpected": "shape"}'
mock_upload.return_value = mock_dt_response

with pytest.raises(KeyError):
client.post("/v1/upload/sbom", json=valid_request_data)

assert "unparseable success response" in caplog.text
assert "unexpected" in caplog.text

@patch("pia.main.dependencytrack.upload_sbom")
def test_upload_dt_non_ok_relayed(
self,
mock_upload,
client,
valid_request_data,
authenticate_as_workload,
):
"""Non-2xx DT responses are relayed verbatim, not wrapped."""
mock_dt_response = Mock()
mock_dt_response.ok = False
mock_dt_response.status_code = 400
mock_dt_response.content = b'{"detail":"invalid bom"}'
mock_upload.return_value = mock_dt_response

response = client.post("/v1/upload/sbom", json=valid_request_data)

assert response.status_code == 400
assert response.content == b'{"detail":"invalid bom"}'

def test_upload_invalid_json(self, client, authenticate_as_workload):
"""Error with invalid JSON."""
Expand Down
Loading