from unittest import mock from uuid import uuid4 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.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 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, ): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json={}, ) assert response.status_code == 401 assert response.json()["detail"] == "Invalid HTTP sig" def test_inbox_incoming_follow_request( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = factories.RemoteActorFactory( base_url="https://example.com", username="toto", public_key="pk", ) respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor)) # When receiving a Follow activity follow_activity = RemoteObject( factories.build_follow_activity( from_remote_actor=ra, for_remote_actor=LOCAL_ACTOR, ), ra, ) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=follow_activity.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 run_async(_process_next_incoming_activity) # And the actor was saved in DB saved_actor = db.execute(select(models.Actor)).scalar_one() assert saved_actor.ap_id == ra.ap_id # And the Follow activity was saved in the inbox inbox_object = db.execute(select(models.InboxObject)).scalar_one() assert inbox_object.ap_object == follow_activity.ap_object # And a follower was internally created follower = db.query(models.Follower).one() assert follower.ap_actor_id == ra.ap_id assert follower.actor_id == saved_actor.id assert follower.inbox_object_id == inbox_object.id # And an Accept activity was created in the outbox outbox_object = db.execute(select(models.OutboxObject)).scalar_one() assert outbox_object.ap_type == "Accept" assert outbox_object.activity_object_ap_id == follow_activity.ap_id # And an outgoing activity was created to track the Accept activity delivery outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one() assert outgoing_activity.outbox_object_id == outbox_object.id def test_inbox_incoming_follow_request__manually_approves_followers( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = factories.RemoteActorFactory( base_url="https://example.com", username="toto", public_key="pk", ) respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor)) # When receiving a Follow activity follow_activity = RemoteObject( factories.build_follow_activity( from_remote_actor=ra, for_remote_actor=LOCAL_ACTOR, ), ra, ) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=follow_activity.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 with mock.patch("app.boxes.MANUALLY_APPROVES_FOLLOWERS", True): run_async(_process_next_incoming_activity) # And the actor was saved in DB saved_actor = db.execute(select(models.Actor)).scalar_one() assert saved_actor.ap_id == ra.ap_id # And the Follow activity was saved in the inbox inbox_object = db.execute(select(models.InboxObject)).scalar_one() assert inbox_object.ap_object == follow_activity.ap_object # And no follower was internally created assert db.scalar(select(func.count(models.Follower.id))) == 0 def test_inbox_accept_follow_request( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = setup_remote_actor(respx_mock) actor_in_db = factories.ActorFactory.from_remote_actor(ra) # And a Follow activity in the outbox 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 ) # When receiving a Accept activity accept_activity = RemoteObject( factories.build_accept_activity( from_remote_actor=ra, for_remote_object=follow_from_outbox, ), ra, ) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=accept_activity.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 run_async(_process_next_incoming_activity) # And the Accept activity was saved in the inbox inbox_activity = db.execute(select(models.InboxObject)).scalar_one() assert inbox_activity.ap_type == "Accept" assert inbox_activity.relates_to_outbox_object_id == outbox_object.id assert inbox_activity.actor_id == actor_in_db.id # And a following entry was created internally following = db.execute(select(models.Following)).scalar_one() assert following.ap_actor_id == actor_in_db.ap_id def test_inbox__create_from_follower( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = setup_remote_actor(respx_mock) # Who is also a follower setup_remote_actor_as_follower(ra) create_activity = factories.build_create_activity( factories.build_note_object( from_remote_actor=ra, outbox_public_id=str(uuid4()), content="Hello", to=[LOCAL_ACTOR.ap_id], ) ) # When receiving a Create activity ro = RemoteObject(create_activity, ra) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=ro.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 # And when processing the incoming activity run_async(_process_next_incoming_activity) # Then the Create activity was saved create_activity_from_inbox: models.InboxObject | None = db.execute( select(models.InboxObject).where(models.InboxObject.ap_type == "Create") ).scalar_one_or_none() assert create_activity_from_inbox assert create_activity_from_inbox.ap_id == ro.ap_id # And the Note object was created note_activity_from_inbox: models.InboxObject | None = db.execute( select(models.InboxObject).where(models.InboxObject.ap_type == "Note") ).scalar_one_or_none() assert note_activity_from_inbox assert note_activity_from_inbox.ap_id == ro.activity_object_ap_id def test_inbox__create_already_deleted_object( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = setup_remote_actor(respx_mock) # Who is also a follower follower = setup_remote_actor_as_follower(ra) # And a Create activity for a Note object create_activity = factories.build_create_activity( factories.build_note_object( from_remote_actor=ra, outbox_public_id=str(uuid4()), content="Hello", to=[LOCAL_ACTOR.ap_id], ) ) ro = RemoteObject(create_activity, ra) # And a Delete activity received for the create object setup_inbox_delete(follower.actor, ro.activity_object_ap_id) # type: ignore # When receiving a Create activity with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=ro.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 # And when processing the incoming activity run_async(_process_next_incoming_activity) # Then the Create activity was saved create_activity_from_inbox: models.InboxObject | None = db.execute( select(models.InboxObject).where(models.InboxObject.ap_type == "Create") ).scalar_one_or_none() assert create_activity_from_inbox assert create_activity_from_inbox.ap_id == ro.ap_id # But it has the deleted flag assert create_activity_from_inbox.is_deleted is True # And the Note wasn't created assert ( db.execute( select(models.InboxObject).where(models.InboxObject.ap_type == "Note") ).scalar_one_or_none() is None ) def test_inbox__actor_is_blocked( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = setup_remote_actor(respx_mock) # Who is also a follower follower = setup_remote_actor_as_follower(ra) follower.actor.is_blocked = True db.commit() create_activity = factories.build_create_activity( factories.build_note_object( from_remote_actor=ra, outbox_public_id=str(uuid4()), content="Hello", to=[LOCAL_ACTOR.ap_id], ) ) # When receiving a Create activity ro = RemoteObject(create_activity, ra) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=ro.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 # And when processing the incoming activity from a blocked actor run_async(_process_next_incoming_activity) # Then the Create activity was discarded assert ( db.scalar( select(func.count(models.InboxObject.id)).where( models.InboxObject.ap_type != "Follow" ) ) == 0 ) def test_inbox__move_activity( db: Session, client: TestClient, respx_mock: respx.MockRouter, ) -> None: # Given a remote actor ra = setup_remote_actor(respx_mock) # Which is followed by the local actor following = setup_remote_actor_as_following(ra) old_actor = following.actor assert old_actor assert following.outbox_object follow_id = following.outbox_object.ap_id # When receiving a Move activity new_ra = setup_remote_actor( respx_mock, base_url="https://new-account.com", also_known_as=[ra.ap_id], ) move_activity = RemoteObject( factories.build_move_activity(ra, new_ra), ra, ) with mock_httpsig_checker(ra): response = client.post( "/inbox", headers={"Content-Type": ap.AS_CTX}, json=move_activity.ap_object, ) # Then the server returns a 204 assert response.status_code == 202 run_async(_process_next_incoming_activity) # And the Move activity was saved in the inbox inbox_activity = db.execute(select(models.InboxObject)).scalar_one() assert inbox_activity.ap_type == "Move" assert inbox_activity.actor_id == old_actor.id # And the following actor was deleted assert db.scalar(select(func.count(models.Following.id))) == 0 # And the follow was undone assert ( db.scalar( select(func.count(models.OutboxObject.id)).where( models.OutboxObject.ap_type == "Undo", models.OutboxObject.activity_object_ap_id == follow_id, ) ) == 1 ) # And the new account was followed assert ( db.scalar( select(func.count(models.OutboxObject.id)).where( models.OutboxObject.ap_type == "Follow", models.OutboxObject.activity_object_ap_id == new_ra.ap_id, ) ) == 1 )