diff --git a/pyproject.toml b/pyproject.toml index c77b780b..fb0a5803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ pytest-pretty = "^1.0.0" httpx = ">=0.23.0" aiosqlite = ">=0.16.0,<1.0.0" +[tool.bandit.assert_used] +skips = ["*/test_*.py", "*_test.py"] + [tool.coverage.run] source = ["src/app", "client/pyroclient"] diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 3d402664..73ce848e 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -46,7 +46,12 @@ async def register_camera( return CameraOut(**camera.model_dump()) -@router.get("/{camera_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific camera") +@router.get( + "/{camera_id}", + status_code=status.HTTP_200_OK, + summary="Fetch the information of a specific camera", + description="Returns camera details including a presigned S3 URL for the last captured image. `last_image_url` is null if no image has been uploaded yet or if the image is temporarily unavailable in storage.", +) async def get_camera( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), @@ -65,11 +70,19 @@ async def get_camera( pose_reads = [PoseReadWithoutImgInfo(**p.model_dump()) for p in cam_poses] bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) - last_image_url = bucket.get_public_url(camera.last_image) if camera.last_image else None + try: + last_image_url = bucket.get_public_url(camera.last_image) if camera.last_image else None + except HTTPException: + last_image_url = None return CameraRead(**camera.model_dump(), last_image_url=last_image_url, poses=pose_reads) -@router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the cameras") +@router.get( + "/", + status_code=status.HTTP_200_OK, + summary="Fetch all the cameras", + description="Returns all cameras accessible to the current user. `last_image_url` is null for a given camera if no image has been uploaded yet or if the image is temporarily unavailable in storage.", +) async def fetch_cameras( cameras: CameraCRUD = Depends(get_camera_crud), poses: PoseCRUD = Depends(get_pose_crud), @@ -82,7 +95,10 @@ async def fetch_cameras( async def get_url_for_cam(cam: Camera) -> str | None: # noqa: RUF029 if cam.last_image: bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(cam.organization_id)) - return bucket.get_public_url(cam.last_image) + try: + return bucket.get_public_url(cam.last_image) + except HTTPException: + return None return None urls = await asyncio.gather(*[get_url_for_cam(cam) for cam in cams]) @@ -97,7 +113,10 @@ async def get_url_for_cam(cam: Camera) -> str | None: # noqa: RUF029 async def get_url_for_cam_single_bucket(cam: Camera) -> str | None: # noqa: RUF029 if cam.last_image: - return bucket.get_public_url(cam.last_image) + try: + return bucket.get_public_url(cam.last_image) + except HTTPException: + return None return None urls = await asyncio.gather(*[get_url_for_cam_single_bucket(cam) for cam in cams]) diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index d0f4e82e..f8c5ee25 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -76,6 +76,7 @@ class CameraOut(CameraCreate): last_active_at: datetime | None last_image: str | None created_at: datetime + poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list) class CameraRead(CameraOut): @@ -83,5 +84,7 @@ class CameraRead(CameraOut): Returned by read endpoints """ - last_image_url: str | None = Field(None, description="URL of the last image of the camera") - poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list) + last_image_url: str | None = Field( + None, + description="Presigned URL of the last image of the camera. Returns null if no image has been uploaded yet or if the image is temporarily unavailable in storage.", + ) diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index e135effc..76a7a507 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -4,6 +4,8 @@ from httpx import AsyncClient from sqlmodel.ext.asyncio.session import AsyncSession +from app.services.storage import s3_service + @pytest.mark.parametrize( ("user_idx", "payload", "status_code", "status_detail"), @@ -93,7 +95,7 @@ async def test_create_camera( assert { k: v for k, v in response.json().items() - if k not in {"id", "created_at", "last_active_at", "is_trustable", "last_image"} + if k not in {"id", "created_at", "last_active_at", "is_trustable", "last_image", "poses"} } == payload @@ -315,8 +317,8 @@ async def test_heartbeat( assert isinstance(response.json()["last_active_at"], str) if pytest.camera_table[cam_idx]["last_active_at"] is not None: assert response.json()["last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] - assert {k: v for k, v in response.json().items() if k != "last_active_at"} == { - k: v for k, v in pytest.camera_table[cam_idx].items() if k != "last_active_at" + assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "poses"}} == { + k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "poses"} } @@ -358,8 +360,8 @@ async def test_update_image( assert isinstance(response.json()["last_image"], str) if pytest.camera_table[cam_idx]["last_image"] is not None: assert response.json()["last_image"] != pytest.camera_table[cam_idx]["last_image"] - assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "last_image"}} == { - k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "last_image"} + assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "last_image", "poses"}} == { + k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "last_image", "poses"} } @@ -671,3 +673,63 @@ async def test_fetch_cameras_with_last_image( assert camera_1["last_image_url"] is not None assert isinstance(camera_1["last_image_url"], str) assert "http" in camera_1["last_image_url"] + + +@pytest.mark.asyncio +async def test_get_camera_s3_unavailable_returns_null_url( + async_client: AsyncClient, + camera_session: AsyncSession, + pose_session: AsyncSession, + mock_img: bytes, +): + cam_auth = pytest.get_token(pytest.camera_table[0]["id"], ["camera"], pytest.camera_table[0]["organization_id"]) + upload_response = await async_client.patch( + "/cameras/image", files={"file": ("img.png", mock_img, "image/png")}, headers=cam_auth + ) + assert upload_response.status_code == 200 + bucket_key = upload_response.json()["last_image"] + + user_auth = pytest.get_token( + pytest.user_table[1]["id"], pytest.user_table[1]["role"].split(), pytest.user_table[1]["organization_id"] + ) + + # Verify the URL is accessible before deletion + response = await async_client.get("/cameras/1", headers=user_auth) + assert response.status_code == 200 + assert response.json()["last_image_url"] is not None + + # Delete the file from S3 then verify the endpoint handles it gracefully + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"])) + bucket.delete_file(bucket_key) + + response = await async_client.get("/cameras/1", headers=user_auth) + assert response.status_code == 200 + assert response.json()["last_image_url"] is None + + +@pytest.mark.asyncio +async def test_fetch_cameras_s3_unavailable_returns_null_url( + async_client: AsyncClient, + camera_session: AsyncSession, + pose_session: AsyncSession, + mock_img: bytes, +): + cam_auth = pytest.get_token(pytest.camera_table[0]["id"], ["camera"], pytest.camera_table[0]["organization_id"]) + upload_response = await async_client.patch( + "/cameras/image", files={"file": ("img.png", mock_img, "image/png")}, headers=cam_auth + ) + assert upload_response.status_code == 200 + bucket_key = upload_response.json()["last_image"] + + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"])) + bucket.delete_file(bucket_key) + + # Use a non-admin user (agent, org 1) to hit the get_url_for_cam_single_bucket path + user_auth = pytest.get_token( + pytest.user_table[1]["id"], pytest.user_table[1]["role"].split(), pytest.user_table[1]["organization_id"] + ) + response = await async_client.get("/cameras", headers=user_auth) + assert response.status_code == 200 + cameras = response.json() + camera_1 = next(c for c in cameras if c["id"] == 1) + assert camera_1["last_image_url"] is None