Initial commit for new v2

This commit is contained in:
Thomas Sileo 2022-06-22 20:11:22 +02:00
commit d528369954
63 changed files with 7961 additions and 0 deletions

0
tests/__init__.py Normal file
View file

49
tests/conftest.py Normal file
View file

@ -0,0 +1,49 @@
from typing import Generator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import orm
from app.database import Base
from app.database import engine
from app.database import get_db
from app.main import app
_Session = orm.sessionmaker(bind=engine, autocommit=False, autoflush=False)
def _get_db_for_testing() -> Generator[orm.Session, None, None]:
session = _Session()
try:
yield session
finally:
session.close()
@pytest.fixture
def db() -> Generator:
Base.metadata.create_all(bind=engine)
yield orm.scoped_session(orm.sessionmaker(bind=engine))
try:
Base.metadata.drop_all(bind=engine)
except Exception:
# XXX: for some reason, the teardown occasionally fails because of this
pass
@pytest.fixture
def exclude_fastapi_middleware():
"""Workaround for https://github.com/encode/starlette/issues/472"""
user_middleware = app.user_middleware.copy()
app.user_middleware = []
app.middleware_stack = app.build_middleware_stack()
yield
app.user_middleware = user_middleware
app.middleware_stack = app.build_middleware_stack()
@pytest.fixture
def client(db, exclude_fastapi_middleware) -> Generator:
app.dependency_overrides[get_db] = _get_db_for_testing
with TestClient(app) as c:
yield c

140
tests/factories.py Normal file
View file

@ -0,0 +1,140 @@
from uuid import uuid4
import factory # type: ignore
from Crypto.PublicKey import RSA
from sqlalchemy import orm
from app import activitypub as ap
from app import actor
from app import models
from app.actor import RemoteActor
from app.ap_object import RemoteObject
from app.database import engine
_Session = orm.scoped_session(orm.sessionmaker(bind=engine))
def generate_key() -> tuple[str, str]:
k = RSA.generate(1024)
return k.exportKey("PEM").decode(), k.publickey().exportKey("PEM").decode()
def build_follow_activity(
from_remote_actor: actor.RemoteActor,
for_remote_actor: actor.RemoteActor,
outbox_public_id: str | None = None,
) -> ap.RawObject:
return {
"@context": ap.AS_CTX,
"type": "Follow",
"id": from_remote_actor.ap_id + "/follow/" + (outbox_public_id or uuid4().hex),
"actor": from_remote_actor.ap_id,
"object": for_remote_actor.ap_id,
}
def build_accept_activity(
from_remote_actor: actor.RemoteActor,
for_remote_object: RemoteObject,
outbox_public_id: str | None = None,
) -> ap.RawObject:
return {
"@context": ap.AS_CTX,
"type": "Accept",
"id": from_remote_actor.ap_id + "/accept/" + (outbox_public_id or uuid4().hex),
"actor": from_remote_actor.ap_id,
"object": for_remote_object.ap_id,
}
class BaseModelMeta:
sqlalchemy_session = _Session
sqlalchemy_session_persistence = "commit"
class RemoteActorFactory(factory.Factory):
class Meta:
model = RemoteActor
exclude = (
"base_url",
"username",
"public_key",
)
class Params:
icon_url = None
summary = "I like unit tests"
ap_actor = factory.LazyAttribute(
lambda o: {
"@context": ap.AS_CTX,
"type": "Person",
"id": o.base_url,
"following": o.base_url + "/following",
"followers": o.base_url + "/followers",
# "featured": ID + "/featured",
"inbox": o.base_url + "/inbox",
"outbox": o.base_url + "/outbox",
"preferredUsername": o.username,
"name": o.username,
"summary": o.summary,
"endpoints": {},
"url": o.base_url,
"manuallyApprovesFollowers": False,
"attachment": [],
"icon": {},
"publicKey": {
"id": f"{o.base_url}#main-key",
"owner": o.base_url,
"publicKeyPem": o.public_key,
},
}
)
class ActorFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta(BaseModelMeta):
model = models.Actor
# ap_actor
# ap_id
ap_type = "Person"
@classmethod
def from_remote_actor(cls, ra):
return cls(
ap_type=ra.ap_type,
ap_actor=ra.ap_actor,
ap_id=ra.ap_id,
)
class OutboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta(BaseModelMeta):
model = models.OutboxObject
# public_id
# relates_to_inbox_object_id
# relates_to_outbox_object_id
@classmethod
def from_remote_object(cls, public_id, ro):
return cls(
public_id=public_id,
ap_type=ro.ap_type,
ap_id=ro.ap_id,
ap_context=ro.context,
ap_object=ro.ap_object,
visibility=ro.visibility,
og_meta=ro.og_meta,
activity_object_ap_id=ro.activity_object_ap_id,
is_hidden_from_homepage=True if ro.in_reply_to else False,
)
class OutgoingActivityFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta(BaseModelMeta):
model = models.OutgoingActivity
# recipient
# outbox_object_id

27
tests/test.key Normal file
View file

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAvYhynEC0l2WVpXoPutfhhZHEeQyyoHiMszOfl1EHM50V0xOC
XCoXd/i5Hsa6dWswyjftOtSmdknY5Whr6LatwNu+i/tlsjmHSGgdhUxLhbj4Xc5T
LQWxDbS1cg49IwSZFYSIrBw2yfPI3dpMNzYvBt8CKAk0zodypHzdfSKPbSRIyBAy
SuG+mJsxsg9tx9CgWNrizauj/zVSWa/cRvNTvIwlxs1J516QJ0px3NygKqPMP2I4
zNkhKFzaNDLzuv4zMsW8UNoM+Mlpf6+NbHQycUC9gIqywrP21E7YFmdljyr5cAfr
qn+KgDsQTpDSINFE1oUanY0iadKvFXjD9uQLfwIDAQABAoIBAAtqK1TjxLyVfqS/
rDDZjZiIxedwb1WgzQCB7GulkqR2Inla5G/+jPlJvoRu/Y3SzdZv9dakNf5LxkdS
uaUDU4WY9mnh0ycftdkThCuiA65jDHpB0dqVTCuCJadf2ijAvyN/nueWr2oMR52s
5wgwODbWuX+Fxmtl1u63InPF4BN3kEQcGP4pgXMiQ2QEwjxMubG7fZTuHFChsZMZ
0QyHy0atmauK8+1FeseoZv7LefgjE+UhAKnIz5z/Ij4erGRaWJUKe5YS7i8nTT6M
W+SJ/gs/l6vOUmrqHZaXsp29pvseY23akgGnZciHJfuj/vxMJjGfZVM2ls+MUkh4
tdEZ0NECgYEAxRGcRxhQyOdiohcsH4efG03mB7u+JBuvt33oFXWOCpW7lenAr9qg
3hm30lZq95ST3XilqGldgIW2zpHCkSLXk/lsJteNC9EEk8HuTDJ7Gd4SBiXisELd
IY147SJu5KXN/kaGoDMgMCGcR7Qkr6hzsRT3308A6nMNZG0viyUMzicCgYEA9jXx
WaLe0PC8pT/yAyPJnYerSOofv+vz+3KNlopBTSRsREsCpdbyOnGCXa4bechj29Lv
0QCbQMkga2pXUPNszdUz7L0LnAi8DZhKumPxyz82kcZSxSCGsvwp9kZju/LPCIHo
j1wKW92/w47QXdzCVjgkKbDAGsSwzphEJOuMhukCgYBUKl9KZfIqu9f+TlND7BJi
APUbnG1q0oBLp/R1Jc3Sa3zAXCM1d/R4pxdBODNbJhO45QwrT0Tl3TXkJ5Cnl+/m
fQJZ3Hma8Fw6FvuFg5HbzGJ6Sbf1e7kh2WAqNyiRctb1oH1i8jLvG4u5fBCnDRTM
Lp5mu0Ey4Ix5tcA2d05uxQKBgQDDBiePIPvt9UL4gpZo9kgViAmdUBamJ3izjCGr
RQhE2r0Hu4L1ajWlJZRmMCuDY7/1uDhODXTs9GPBshJIBQoCYQcoVvaDOkf7XM6U
peY5YHERN08I5qLL1AJJGaiWj9Z+nqhgJj/uVNA5Tz6tmtg1A3Nhsqf4jCShAOu5
cvt1QQKBgH2Lg/o9KpFLeZLVXQzW3GFB7RzDetSDbpdhBBE3o/HAtrX0foEqYfKx
JuPrlGR2L6Q8jSw7AvFErkx5g5kCgdN8mOYjCe/EsL3ctIatqaoGDrjfvgWAeanW
XxMcVRlcMFzp5XB0VQhG0nP9uvHm/eIw/izN2JN7gz3ZZp84lq3S
-----END RSA PRIVATE KEY-----

46
tests/test_actor.py Normal file
View file

@ -0,0 +1,46 @@
import httpx
import respx
from app import models
from app.actor import fetch_actor
from app.database import Session
from tests import factories
def test_fetch_actor(db: Session, respx_mock) -> 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 fetching this actor for the first time
saved_actor = fetch_actor(db, ra.ap_id)
# Then it has been fetched and saved in DB
assert respx.calls.call_count == 1
assert db.query(models.Actor).one().ap_id == saved_actor.ap_id
# When fetching it a second time
actor_from_db = fetch_actor(db, ra.ap_id)
# Then it's read from the DB
assert actor_from_db.ap_id == ra.ap_id
assert db.query(models.Actor).count() == 1
assert respx.calls.call_count == 1
def test_sqlalchemy_factory(db: Session) -> None:
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key="pk",
)
actor_in_db = factories.ActorFactory(
ap_type=ra.ap_type,
ap_actor=ra.ap_actor,
ap_id=ra.ap_id,
)
assert actor_in_db.id == db.query(models.Actor).one().id

21
tests/test_admin.py Normal file
View file

@ -0,0 +1,21 @@
from fastapi.testclient import TestClient
from app.main import app
def test_admin_endpoints_are_authenticated(client: TestClient):
routes_tested = []
for route in app.routes:
if not route.path.startswith("/admin") or route.path == "/admin/login":
continue
for method in route.methods:
resp = client.request(method, route.path)
# Admin routes should redirect to the login page
assert resp.status_code == 302, f"{method} {route.path} is unauthenticated"
assert resp.headers.get("Location") == "http://testserver/admin/login"
routes_tested.append((method, route.path))
assert len(routes_tested) > 0

177
tests/test_httpsig.py Normal file
View file

@ -0,0 +1,177 @@
from typing import Any
import fastapi
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app import activitypub as ap
from app import httpsig
from app.httpsig import HTTPSigInfo
from app.key import Key
from tests import factories
_test_app = fastapi.FastAPI()
def _httpsig_info_to_dict(httpsig_info: HTTPSigInfo) -> dict[str, Any]:
return {
"has_valid_signature": httpsig_info.has_valid_signature,
"signed_by_ap_actor_id": httpsig_info.signed_by_ap_actor_id,
}
@_test_app.get("/httpsig_checker")
def get_httpsig_checker(
httpsig_info: httpsig.HTTPSigInfo = fastapi.Depends(httpsig.httpsig_checker),
):
return _httpsig_info_to_dict(httpsig_info)
@_test_app.post("/enforce_httpsig")
async def post_enforce_httpsig(
request: fastapi.Request,
httpsig_info: httpsig.HTTPSigInfo = fastapi.Depends(httpsig.enforce_httpsig),
):
await request.json()
return _httpsig_info_to_dict(httpsig_info)
def test_enforce_httpsig__no_signature(
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
with TestClient(_test_app) as client:
response = client.post(
"/enforce_httpsig",
headers={"Content-Type": ap.AS_CTX},
json={"enforce_httpsig": True},
)
assert response.status_code == 401
assert response.json()["detail"] == "Invalid HTTP sig"
@pytest.mark.asyncio
async def test_enforce_httpsig__with_valid_signature(
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# Given a remote actor
privkey, pubkey = factories.generate_key()
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key=pubkey,
)
k = Key(ra.ap_id, f"{ra.ap_id}#main-key")
k.load(privkey)
auth = httpsig.HTTPXSigAuth(k)
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor))
httpsig._get_public_key.cache_clear()
async with httpx.AsyncClient(app=_test_app, base_url="http://test") as client:
response = await client.post(
"/enforce_httpsig",
headers={"Content-Type": ap.AS_CTX},
json={"enforce_httpsig": True},
auth=auth, # type: ignore
)
assert response.status_code == 200
json_response = response.json()
assert json_response["has_valid_signature"] is True
assert json_response["signed_by_ap_actor_id"] == ra.ap_id
def test_httpsig_checker__no_signature(
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
with TestClient(_test_app) as client:
response = client.get(
"/httpsig_checker",
headers={"Accept": ap.AS_CTX},
)
assert response.status_code == 200
json_response = response.json()
assert json_response["has_valid_signature"] is False
assert json_response["signed_by_ap_actor_id"] is None
@pytest.mark.asyncio
async def test_httpsig_checker__with_valid_signature(
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# Given a remote actor
privkey, pubkey = factories.generate_key()
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key=pubkey,
)
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor))
k = Key(ra.ap_id, f"{ra.ap_id}#main-key")
k.load(privkey)
auth = httpsig.HTTPXSigAuth(k)
httpsig._get_public_key.cache_clear()
async with httpx.AsyncClient(app=_test_app, base_url="http://test") as client:
response = await client.get(
"/httpsig_checker",
headers={"Accept": ap.AS_CTX},
auth=auth, # type: ignore
)
assert response.status_code == 200
json_response = response.json()
assert json_response["has_valid_signature"] is True
assert json_response["signed_by_ap_actor_id"] == ra.ap_id
@pytest.mark.asyncio
async def test_httpsig_checker__with_invvalid_signature(
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# Given a remote actor
privkey, pubkey = factories.generate_key()
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key=pubkey,
)
k = Key(ra.ap_id, f"{ra.ap_id}#main-key")
k.load(privkey)
auth = httpsig.HTTPXSigAuth(k)
ra2_privkey, ra2_pubkey = factories.generate_key()
ra2 = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key=ra2_pubkey,
)
assert ra.ap_id == ra2.ap_id
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra2.ap_actor))
httpsig._get_public_key.cache_clear()
async with httpx.AsyncClient(app=_test_app, base_url="http://test") as client:
response = await client.get(
"/httpsig_checker",
headers={"Accept": ap.AS_CTX},
auth=auth, # type: ignore
)
assert response.status_code == 200
json_response = response.json()
assert json_response["has_valid_signature"] is False
assert json_response["signed_by_ap_actor_id"] == ra.ap_id

134
tests/test_inbox.py Normal file
View file

@ -0,0 +1,134 @@
from uuid import uuid4
import httpx
import respx
from fastapi.testclient import TestClient
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 Session
from tests import factories
from tests.utils import mock_httpsig_checker
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_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 sending a Follow activity
follow_activity = RemoteObject(
factories.build_follow_activity(
from_remote_actor=ra,
for_remote_actor=LOCAL_ACTOR,
)
)
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 == 204
# And the actor was saved in DB
saved_actor = db.query(models.Actor).one()
assert saved_actor.ap_id == ra.ap_id
# And the Follow activity was saved in the inbox
inbox_object = db.query(models.InboxObject).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.query(models.OutboxObject).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.query(models.OutgoingActivity).one()
assert outgoing_activity.outbox_object_id == outbox_object.id
def test_inbox_accept_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))
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,
)
)
outbox_object = factories.OutboxObjectFactory.from_remote_object(
follow_id, follow_from_outbox
)
# When sending a Accept activity
accept_activity = RemoteObject(
factories.build_accept_activity(
from_remote_actor=ra,
for_remote_object=follow_from_outbox,
)
)
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 == 204
# And the Accept activity was saved in the inbox
inbox_activity = db.query(models.InboxObject).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.query(models.Following).one()
assert following.ap_actor_id == actor_in_db.ap_id

46
tests/test_outbox.py Normal file
View file

@ -0,0 +1,46 @@
import httpx
import respx
from fastapi.testclient import TestClient
from app import models
from app.config import generate_csrf_token
from app.database import Session
from tests import factories
from tests.utils import generate_admin_session_cookies
def test_send_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))
response = client.post(
"/admin/actions/follow",
data={
"redirect_url": "http://testserver/",
"ap_actor_id": ra.ap_id,
"csrf_token": generate_csrf_token(),
},
cookies=generate_admin_session_cookies(),
)
# Then the server returns a 302
assert response.status_code == 302
assert response.headers.get("Location") == "http://testserver/"
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
assert outbox_object.ap_type == "Follow"
assert outbox_object.activity_object_ap_id == ra.ap_id
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
assert outgoing_activity.outbox_object_id == outbox_object.id

View file

@ -0,0 +1,180 @@
from uuid import uuid4
import httpx
import respx
from fastapi.testclient import TestClient
from app import models
from app.actor import LOCAL_ACTOR
from app.ap_object import RemoteObject
from app.database import Session
from app.process_outgoing_activities import _MAX_RETRIES
from app.process_outgoing_activities import new_outgoing_activity
from app.process_outgoing_activities import process_next_outgoing_activity
from tests import factories
def _setup_outbox_object() -> models.OutboxObject:
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key="pk",
)
# 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,
)
)
outbox_object = factories.OutboxObjectFactory.from_remote_object(
follow_id, follow_from_outbox
)
return outbox_object
def test_new_outgoing_activity(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
outbox_object = _setup_outbox_object()
inbox_url = "https://example.com/inbox"
# When queuing the activity
outgoing_activity = new_outgoing_activity(db, inbox_url, outbox_object.id)
assert db.query(models.OutgoingActivity).one() == outgoing_activity
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == inbox_url
def test_process_next_outgoing_activity__no_next_activity(
db: Session,
respx_mock: respx.MockRouter,
) -> None:
assert process_next_outgoing_activity(db) is False
def test_process_next_outgoing_activity__server_200(
db: Session,
respx_mock: respx.MockRouter,
) -> None:
# And an outgoing activity
outbox_object = _setup_outbox_object()
recipient_inbox_url = "https://example.com/users/toto/inbox"
respx_mock.post(recipient_inbox_url).mock(return_value=httpx.Response(204))
outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id,
)
# When processing the next outgoing activity
# Then it is processed
assert process_next_outgoing_activity(db) is True
assert respx_mock.calls.call_count == 1
outgoing_activity = db.query(models.OutgoingActivity).one()
assert outgoing_activity.is_sent is True
assert outgoing_activity.last_status_code == 204
assert outgoing_activity.error is None
assert outgoing_activity.is_errored is False
def test_process_next_outgoing_activity__error_500(
db: Session,
respx_mock: respx.MockRouter,
) -> None:
outbox_object = _setup_outbox_object()
recipient_inbox_url = "https://example.com/inbox"
respx_mock.post(recipient_inbox_url).mock(
return_value=httpx.Response(500, text="oops")
)
# And an outgoing activity
outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id,
)
# When processing the next outgoing activity
# Then it is processed
assert process_next_outgoing_activity(db) is True
assert respx_mock.calls.call_count == 1
outgoing_activity = db.query(models.OutgoingActivity).one()
assert outgoing_activity.is_sent is False
assert outgoing_activity.last_status_code == 500
assert outgoing_activity.last_response == "oops"
assert outgoing_activity.is_errored is False
assert outgoing_activity.tries == 1
def test_process_next_outgoing_activity__connect_error(
db: Session,
respx_mock: respx.MockRouter,
) -> None:
outbox_object = _setup_outbox_object()
recipient_inbox_url = "https://example.com/inbox"
respx_mock.post(recipient_inbox_url).mock(side_effect=httpx.ConnectError)
# And an outgoing activity
outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id,
)
# When processing the next outgoing activity
# Then it is processed
assert process_next_outgoing_activity(db) is True
assert respx_mock.calls.call_count == 1
outgoing_activity = db.query(models.OutgoingActivity).one()
assert outgoing_activity.is_sent is False
assert outgoing_activity.error is not None
assert outgoing_activity.tries == 1
def test_process_next_outgoing_activity__errored(
db: Session,
respx_mock: respx.MockRouter,
) -> None:
outbox_object = _setup_outbox_object()
recipient_inbox_url = "https://example.com/inbox"
respx_mock.post(recipient_inbox_url).mock(
return_value=httpx.Response(500, text="oops")
)
# And an outgoing activity
outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id,
tries=_MAX_RETRIES - 1,
)
# When processing the next outgoing activity
# Then it is processed
assert process_next_outgoing_activity(db) is True
assert respx_mock.calls.call_count == 1
outgoing_activity = db.query(models.OutgoingActivity).one()
assert outgoing_activity.is_sent is False
assert outgoing_activity.last_status_code == 500
assert outgoing_activity.last_response == "oops"
assert outgoing_activity.is_errored is True
# And it is skipped from processing
assert process_next_outgoing_activity(db) is False
# TODO(ts):
# - parse retry after

30
tests/test_public.py Normal file
View file

@ -0,0 +1,30 @@
import pytest
from fastapi.testclient import TestClient
from app.database import Session
_ACCEPTED_AP_HEADERS = [
"application/activity+json",
"application/activity+json; charset=utf-8",
"application/ld+json",
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
]
@pytest.mark.anyio
def test_index(db: Session, client: TestClient):
response = client.get("/")
assert response.status_code == 200
@pytest.mark.parametrize("accept", _ACCEPTED_AP_HEADERS)
def test__ap_version(client, db, accept: str) -> None:
response = client.get("/followers", headers={"Accept": accept})
assert response.status_code == 200
assert response.headers["content-type"] == "application/activity+json"
assert response.json()["id"].endswith("/followers")
def test__html(client, db) -> None:
response = client.get("/followers", headers={"Accept": "application/activity+json"})
assert response.status_code == 200

29
tests/utils.py Normal file
View file

@ -0,0 +1,29 @@
from contextlib import contextmanager
import fastapi
from app import actor
from app import httpsig
from app.config import session_serializer
from app.main import app
@contextmanager
def mock_httpsig_checker(ra: actor.RemoteActor):
async def httpsig_checker(
request: fastapi.Request,
) -> httpsig.HTTPSigInfo:
return httpsig.HTTPSigInfo(
has_valid_signature=True,
signed_by_ap_actor_id=ra.ap_id,
)
app.dependency_overrides[httpsig.httpsig_checker] = httpsig_checker
try:
yield
finally:
del app.dependency_overrides[httpsig.httpsig_checker]
def generate_admin_session_cookies() -> dict[str, str]:
return {"session": session_serializer.dumps({"is_logged_in": True})}