diff --git a/tests/test_inbox.py b/tests/test_inbox.py index efcc3b7..41b6463 100644 --- a/tests/test_inbox.py +++ b/tests/test_inbox.py @@ -12,24 +12,15 @@ from app import activitypub as ap from app import models from app.actor import LOCAL_ACTOR from app.ap_object import RemoteObject -from app.database import AsyncSession -from app.incoming_activities import fetch_next_incoming_activity -from app.incoming_activities import process_next_incoming_activity from tests import factories from tests.utils import mock_httpsig_checker -from tests.utils import run_async +from tests.utils import run_process_next_incoming_activity from tests.utils import setup_inbox_delete from tests.utils import setup_remote_actor from tests.utils import setup_remote_actor_as_follower from tests.utils import setup_remote_actor_as_following -async def _process_next_incoming_activity(db_session: AsyncSession) -> None: - next_activity = await fetch_next_incoming_activity(db_session) - assert next_activity - await process_next_incoming_activity(db_session, next_activity) - - def test_inbox_requires_httpsig( client: TestClient, ): @@ -70,10 +61,10 @@ def test_inbox_incoming_follow_request( json=follow_activity.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # And the actor was saved in DB saved_actor = db.execute(select(models.Actor)).scalar_one() @@ -127,11 +118,11 @@ def test_inbox_incoming_follow_request__manually_approves_followers( json=follow_activity.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 with mock.patch("app.boxes.MANUALLY_APPROVES_FOLLOWERS", True): - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # And the actor was saved in DB saved_actor = db.execute(select(models.Actor)).scalar_one() @@ -183,10 +174,10 @@ def test_inbox_accept_follow_request( json=accept_activity.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # And the Accept activity was saved in the inbox inbox_activity = db.execute(select(models.InboxObject)).scalar_one() @@ -229,11 +220,11 @@ def test_inbox__create_from_follower( json=ro.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 # And when processing the incoming activity - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # Then the Create activity was saved create_activity_from_inbox: models.InboxObject | None = db.execute( @@ -283,11 +274,11 @@ def test_inbox__create_already_deleted_object( json=ro.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 # And when processing the incoming activity - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # Then the Create activity was saved create_activity_from_inbox: models.InboxObject | None = db.execute( @@ -339,11 +330,11 @@ def test_inbox__actor_is_blocked( json=ro.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 # And when processing the incoming activity from a blocked actor - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # Then the Create activity was discarded assert ( @@ -389,10 +380,10 @@ def test_inbox__move_activity( json=move_activity.ap_object, ) - # Then the server returns a 204 + # Then the server returns a 202 assert response.status_code == 202 - run_async(_process_next_incoming_activity) + run_process_next_incoming_activity() # And the Move activity was saved in the inbox inbox_activity = db.execute(select(models.InboxObject)).scalar_one() diff --git a/tests/test_remote_actor_deletion.py b/tests/test_remote_actor_deletion.py new file mode 100644 index 0000000..34ca73f --- /dev/null +++ b/tests/test_remote_actor_deletion.py @@ -0,0 +1,109 @@ +import httpx +import respx +from fastapi.testclient import TestClient +from sqlalchemy import func +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app import activitypub as ap +from app import models +from app.ap_object import RemoteObject +from tests import factories +from tests.utils import mock_httpsig_checker +from tests.utils import run_process_next_incoming_activity +from tests.utils import setup_remote_actor +from tests.utils import setup_remote_actor_as_following_and_follower + + +def test_inbox__incoming_delete_for_unknown_actor( + db: Session, + client: TestClient, + respx_mock: respx.MockRouter, +) -> None: + # Given a remote actor who is already deleted + ra = factories.RemoteActorFactory( + base_url="https://deleted.com", + username="toto", + public_key="pk", + ) + respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(404, json=ra.ap_actor)) + + # When receiving a Delete activity for an unknown actor + delete_activity = RemoteObject( + factories.build_delete_activity( + from_remote_actor=ra, + deleted_object_ap_id=ra.ap_id, + ), + ra, + ) + with mock_httpsig_checker(ra, has_valid_signature=False, is_ap_actor_gone=True): + response = client.post( + "/inbox", + headers={"Content-Type": ap.AS_CTX}, + json=delete_activity.ap_object, + ) + + # Then the server returns a 202 + assert response.status_code == 202 + + # And no incoming activity was created + assert db.scalar(select(func.count(models.IncomingActivity.id))) == 0 + + +def test_inbox__incoming_delete_for_known_actor( + db: Session, + client: TestClient, + respx_mock: respx.MockRouter, +) -> None: + # Given a remote actor + ra = setup_remote_actor(respx_mock) + + # Which is both followed and a follower + following, _ = setup_remote_actor_as_following_and_follower(ra) + actor = following.actor + assert actor + assert following.outbox_object + + # TODO: setup few more activities (like announce and create) + + # When receiving a Delete activity for an unknown actor + delete_activity = RemoteObject( + factories.build_delete_activity( + from_remote_actor=ra, + deleted_object_ap_id=ra.ap_id, + ), + ra, + ) + + with mock_httpsig_checker(ra): + response = client.post( + "/inbox", + headers={"Content-Type": ap.AS_CTX}, + json=delete_activity.ap_object, + ) + + # Then the server returns a 202 + assert response.status_code == 202 + + run_process_next_incoming_activity() + + # Then every inbox object from the actor was deleted + assert ( + db.scalar( + select(func.count(models.InboxObject.id)).where( + models.InboxObject.actor_id == actor.id, + models.InboxObject.is_deleted.is_(False), + ) + ) + == 0 + ) + + # And the following actor was deleted + assert db.scalar(select(func.count(models.Following.id))) == 0 + + # And the follower actor was deleted too + assert db.scalar(select(func.count(models.Follower.id))) == 0 + + # And the actor was marked in deleted + db.refresh(actor) + assert actor.is_deleted is True diff --git a/tests/utils.py b/tests/utils.py index bc243b6..69787e0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,19 +14,27 @@ from app import models from app.actor import LOCAL_ACTOR from app.ap_object import RemoteObject from app.config import session_serializer +from app.database import AsyncSession from app.database import async_session +from app.incoming_activities import fetch_next_incoming_activity +from app.incoming_activities import process_next_incoming_activity from app.main import app from tests import factories @contextmanager -def mock_httpsig_checker(ra: actor.RemoteActor): +def mock_httpsig_checker( + ra: actor.RemoteActor, + has_valid_signature: bool = True, + is_ap_actor_gone: bool = False, +): async def httpsig_checker( request: fastapi.Request, ) -> httpsig.HTTPSigInfo: return httpsig.HTTPSigInfo( - has_valid_signature=True, + has_valid_signature=has_valid_signature, signed_by_ap_actor_id=ra.ap_id, + is_ap_actor_gone=is_ap_actor_gone, ) app.dependency_overrides[httpsig.httpsig_checker] = httpsig_checker @@ -115,6 +123,52 @@ def setup_remote_actor_as_following(ra: actor.RemoteActor) -> models.Following: return following +def setup_remote_actor_as_following_and_follower( + ra: actor.RemoteActor, +) -> tuple[models.Following, models.Follower]: + actor = factories.ActorFactory.from_remote_actor(ra) + + follow_id = uuid4().hex + follow_from_outbox = RemoteObject( + factories.build_follow_activity( + from_remote_actor=LOCAL_ACTOR, + for_remote_actor=ra, + outbox_public_id=follow_id, + ), + LOCAL_ACTOR, + ) + outbox_object = factories.OutboxObjectFactory.from_remote_object( + follow_id, follow_from_outbox + ) + + following = factories.FollowingFactory( + outbox_object_id=outbox_object.id, + actor_id=actor.id, + ap_actor_id=actor.ap_id, + ) + + follow_id = uuid4().hex + follow_from_inbox = RemoteObject( + factories.build_follow_activity( + from_remote_actor=ra, + for_remote_actor=LOCAL_ACTOR, + outbox_public_id=follow_id, + ), + ra, + ) + inbox_object = factories.InboxObjectFactory.from_remote_object( + follow_from_inbox, actor + ) + + follower = factories.FollowerFactory( + inbox_object_id=inbox_object.id, + actor_id=actor.id, + ap_actor_id=actor.ap_id, + ) + + return following, follower + + def setup_inbox_delete( actor: models.Actor, deleted_object_ap_id: str ) -> models.InboxObject: @@ -137,3 +191,13 @@ def run_async(func, *args, **kwargs): return await func(db, *args, **kwargs) asyncio.run(_func()) + + +async def _process_next_incoming_activity(db_session: AsyncSession) -> None: + next_activity = await fetch_next_incoming_activity(db_session) + assert next_activity + await process_next_incoming_activity(db_session, next_activity) + + +def run_process_next_incoming_activity() -> None: + run_async(_process_next_incoming_activity)