Initial commit for new v2
This commit is contained in:
commit
d528369954
63 changed files with 7961 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
49
tests/conftest.py
Normal file
49
tests/conftest.py
Normal 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
140
tests/factories.py
Normal 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
27
tests/test.key
Normal 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
46
tests/test_actor.py
Normal 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
21
tests/test_admin.py
Normal 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
177
tests/test_httpsig.py
Normal 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
134
tests/test_inbox.py
Normal 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
46
tests/test_outbox.py
Normal 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
|
180
tests/test_process_outgoing_activities.py
Normal file
180
tests/test_process_outgoing_activities.py
Normal 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
30
tests/test_public.py
Normal 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
29
tests/utils.py
Normal 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})}
|
Loading…
Add table
Add a link
Reference in a new issue