@@ -209,6 +209,11 @@ def _share_board(client: TestClient, token: str, board_id: str) -> None:
209209 assert r .status_code == status .HTTP_201_CREATED
210210
211211
212+ def _set_board_visibility (client : TestClient , token : str , board_id : str , visibility : str ) -> None :
213+ r = client .patch (f"/api/v1/boards/{ board_id } " , json = {"board_visibility" : visibility }, headers = _auth (token ))
214+ assert r .status_code == status .HTTP_201_CREATED
215+
216+
212217def _create_workflow (client : TestClient , token : str ) -> str :
213218 r = client .post ("/api/v1/workflows/" , json = {"workflow" : WORKFLOW_BODY }, headers = _auth (token ))
214219 assert r .status_code == 200
@@ -279,6 +284,24 @@ def test_admin_can_add_image_to_any_board(
279284 )
280285 assert r .status_code != status .HTTP_403_FORBIDDEN
281286
287+ def test_non_owner_can_add_own_image_to_public_board (
288+ self , client : TestClient , mock_invoker : Invoker , user1_token : str , user2_token : str
289+ ):
290+ """Public boards are documented as writable by other authenticated users."""
291+ public_board_id = _create_board (client , user1_token , "User1 Public Board" )
292+ _set_board_visibility (client , user1_token , public_board_id , "public" )
293+
294+ user2 = mock_invoker .services .users .get_by_email ("user2@test.com" )
295+ assert user2 is not None
296+ _save_image (mock_invoker , "user2-public-board-img" , user2 .user_id )
297+
298+ r = client .post (
299+ "/api/v1/board_images/" ,
300+ json = {"board_id" : public_board_id , "image_name" : "user2-public-board-img" },
301+ headers = _auth (user2_token ),
302+ )
303+ assert r .status_code == status .HTTP_201_CREATED
304+
282305 def test_owner_can_add_image_to_own_board (self , client : TestClient , mock_invoker : Invoker , user1_token : str ):
283306 user1 = mock_invoker .services .users .get_by_email ("user1@test.com" )
284307 assert user1 is not None
@@ -633,6 +656,24 @@ def test_non_owner_cannot_batch_delete_image(
633656 )
634657 assert r .status_code == status .HTTP_403_FORBIDDEN
635658
659+ def test_non_owner_can_delete_image_from_public_board (
660+ self , client : TestClient , mock_invoker : Invoker , user1_token : str , user2_token : str
661+ ):
662+ """Public-board semantics promise delete access to images contained in the board."""
663+ public_board_id = _create_board (client , user1_token , "User1 Public Delete Board" )
664+ _set_board_visibility (client , user1_token , public_board_id , "public" )
665+
666+ user1 = mock_invoker .services .users .get_by_email ("user1@test.com" )
667+ assert user1 is not None
668+ _save_image (mock_invoker , "user1-public-delete" , user1 .user_id )
669+ mock_invoker .services .board_image_records .add_image_to_board (public_board_id , "user1-public-delete" )
670+
671+ r = client .delete (
672+ "/api/v1/images/i/user1-public-delete" ,
673+ headers = _auth (user2_token ),
674+ )
675+ assert r .status_code == status .HTTP_200_OK
676+
636677 def test_clear_intermediates_non_admin_forbidden (self , client : TestClient , user1_token : str ):
637678 r = client .delete ("/api/v1/images/intermediates" , headers = _auth (user1_token ))
638679 assert r .status_code == status .HTTP_403_FORBIDDEN
@@ -645,13 +686,30 @@ def test_download_images_requires_auth(self, enable_multiuser: Any, client: Test
645686 r = client .post ("/api/v1/images/download" , json = {"image_names" : ["x" ]})
646687 assert r .status_code == status .HTTP_401_UNAUTHORIZED
647688
648- def test_get_bulk_download_unauthenticated_returns_404 (self , enable_multiuser : Any , client : TestClient ):
649- """GET /download/{name} is intentionally unauthenticated (browser <a download>
650- links cannot carry Authorization headers). Access control relies on the
651- UUID filename being unguessable and known only to the authenticated creator.
652- An unknown filename returns 404."""
653- r = client .get ("/api/v1/images/download/some-item.zip" )
654- assert r .status_code == status .HTTP_404_NOT_FOUND
689+ def test_non_owner_cannot_fetch_existing_bulk_download_item (
690+ self ,
691+ client : TestClient ,
692+ mock_invoker : Invoker ,
693+ monkeypatch : Any ,
694+ tmp_path : Any ,
695+ user1_token : str ,
696+ user2_token : str ,
697+ ):
698+ """A bulk download zip should be fetchable only by its owner."""
699+ from fastapi import BackgroundTasks
700+
701+ user1 = mock_invoker .services .users .get_by_email ("user1@test.com" )
702+ assert user1 is not None
703+
704+ mock_file = tmp_path / "owned-download.zip"
705+ mock_file .write_text ("contents" )
706+
707+ monkeypatch .setattr (mock_invoker .services .bulk_download , "get_path" , lambda _ : str (mock_file ))
708+ monkeypatch .setattr (mock_invoker .services .bulk_download , "get_owner" , lambda _ : user1 .user_id )
709+ monkeypatch .setattr (BackgroundTasks , "add_task" , lambda * args , ** kwargs : None )
710+
711+ r = client .get ("/api/v1/images/download/owned-download.zip" , headers = _auth (user2_token ))
712+ assert r .status_code == status .HTTP_403_FORBIDDEN
655713
656714 def test_images_by_names_requires_auth (self , enable_multiuser : Any , client : TestClient ):
657715 r = client .post ("/api/v1/images/images_by_names" , json = {"image_names" : ["x" ]})
@@ -674,6 +732,27 @@ def test_images_by_names_filters_unauthorized(
674732 # user2 should get an empty list — the image belongs to user1
675733 assert r .json () == []
676734
735+ def test_none_board_image_names_only_return_callers_uncategorized_images (
736+ self , client : TestClient , mock_invoker : Invoker , user1_token : str , user2_token : str
737+ ):
738+ """The uncategorized-images sentinel must not expose other users' image names."""
739+ mock_invoker .services .board_images .get_all_board_image_names_for_board .side_effect = (
740+ mock_invoker .services .board_image_records .get_all_board_image_names_for_board
741+ )
742+
743+ user1 = mock_invoker .services .users .get_by_email ("user1@test.com" )
744+ user2 = mock_invoker .services .users .get_by_email ("user2@test.com" )
745+ assert user1 is not None
746+ assert user2 is not None
747+
748+ _save_image (mock_invoker , "user1-uncategorized-private" , user1 .user_id )
749+ _save_image (mock_invoker , "user2-uncategorized-private" , user2 .user_id )
750+
751+ r = client .get ("/api/v1/boards/none/image_names" , headers = _auth (user2_token ))
752+ assert r .status_code == status .HTTP_200_OK
753+ assert "user2-uncategorized-private" in r .json ()
754+ assert "user1-uncategorized-private" not in r .json ()
755+
677756
678757# ===========================================================================
679758# 3. Workflow mutation authorization (additional)
@@ -1396,11 +1475,8 @@ def test_bulk_download_events_carry_user_id(self):
13961475 error = BulkDownloadErrorEvent .build ("default" , "item-3" , "item-3.zip" , "oops" , user_id = "owner-abc" )
13971476 assert error .user_id == "owner-abc"
13981477
1399- def test_bulk_download_event_emitted_to_download_room (self , mock_invoker : Invoker , monkeypatch : Any ):
1400- """Verify that _handle_bulk_image_download_event emits to the
1401- bulk_download_id room. Access control for bulk downloads is enforced
1402- at POST /download time (image/board read checks), and the zip filename
1403- is a UUID capability token deleted after a single fetch."""
1478+ def test_bulk_download_event_not_emitted_to_shared_default_room (self , mock_invoker : Invoker , monkeypatch : Any ):
1479+ """Bulk download capability tokens must not be broadcast to the shared default room."""
14041480 import asyncio
14051481 from unittest .mock import AsyncMock
14061482
@@ -1423,7 +1499,8 @@ def test_bulk_download_event_emitted_to_download_room(self, mock_invoker: Invoke
14231499 asyncio .run (socketio ._handle_bulk_image_download_event (("bulk_download_complete" , event )))
14241500
14251501 rooms_emitted_to = [call .kwargs .get ("room" ) for call in mock_emit .call_args_list ]
1426- assert "default" in rooms_emitted_to
1502+ assert "default" not in rooms_emitted_to
1503+ assert "user:owner-xyz" in rooms_emitted_to
14271504
14281505
14291506# ===========================================================================
0 commit comments