From dddfea6a63b3e546f386937788e7b0aaeb90c5b1 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 10:36:02 +0100 Subject: [PATCH 001/238] API - init Webfinger endpoint for federation with ActivityPub (wip) --- .gitlab-ci.yml | 1 + fittrackee/__init__.py | 7 + fittrackee/config.py | 4 +- fittrackee/federation/__init__.py | 0 fittrackee/federation/federation.py | 24 +++ fittrackee/federation/models.py | 88 +++++++++++ fittrackee/federation/utils.py | 44 ++++++ fittrackee/federation/webfinger.py | 46 ++++++ .../23_8842c351a2d8_init_federation.py | 50 +++++++ fittrackee/tests/conftest.py | 1 + fittrackee/tests/federation/__init__.py | 0 .../federation/test_federation_federation.py | 39 +++++ .../federation/test_federation_models.py | 71 +++++++++ .../federation/test_federation_webfinger.py | 118 +++++++++++++++ .../tests/fixtures/fixtures_federation.py | 13 ++ poetry.lock | 140 ++++++++++++------ pyproject.toml | 1 + 17 files changed, 599 insertions(+), 48 deletions(-) create mode 100644 fittrackee/federation/__init__.py create mode 100644 fittrackee/federation/federation.py create mode 100644 fittrackee/federation/models.py create mode 100644 fittrackee/federation/utils.py create mode 100644 fittrackee/federation/webfinger.py create mode 100644 fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py create mode 100644 fittrackee/tests/federation/__init__.py create mode 100644 fittrackee/tests/federation/test_federation_federation.py create mode 100644 fittrackee/tests/federation/test_federation_models.py create mode 100644 fittrackee/tests/federation/test_federation_webfinger.py create mode 100644 fittrackee/tests/fixtures/fixtures_federation.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 884434da6..1ceca4421 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,7 @@ variables: EMAIL_URL: smtp://none:none@0.0.0.0:1025 FLASK_APP: fittrackee/__main__.py SENDER_EMAIL: fittrackee@example.com + UI_URL: https://0.0.0.0:5000 services: - name: postgres:latest diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index b5f6492f3..c83f3a448 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -86,6 +86,13 @@ def create_app() -> Flask: app.register_blueprint(users_blueprint, url_prefix='/api') app.register_blueprint(workouts_blueprint, url_prefix='/api') + # ActivityPub federation + from .federation.federation import ap_federation_blueprint # noqa + from .federation.webfinger import ap_webfinger_blueprint # noqa + + app.register_blueprint(ap_federation_blueprint, url_prefix='/federation') + app.register_blueprint(ap_webfinger_blueprint) + if app.debug: logging.getLogger('sqlalchemy').setLevel(logging.WARNING) logging.getLogger('sqlalchemy').handlers = logging.getLogger( diff --git a/fittrackee/config.py b/fittrackee/config.py index 668411b72..c1c7fd66f 100644 --- a/fittrackee/config.py +++ b/fittrackee/config.py @@ -27,7 +27,7 @@ class BaseConfig: PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'} WORKOUT_ALLOWED_EXTENSIONS = {'gpx', 'zip'} TEMPLATES_FOLDER = os.path.join(current_app.root_path, 'emails/templates') - UI_URL = os.environ.get('UI_URL') + UI_URL = os.environ['UI_URL'] EMAIL_URL = os.environ.get('EMAIL_URL') SENDER_EMAIL = os.environ.get('SENDER_EMAIL') DRAMATIQ_BROKER = broker @@ -46,6 +46,8 @@ class BaseConfig: os.environ.get('DEFAULT_STATICMAP', 'False') == 'True' ), } + # ActivityPub + AP_DOMAIN = UI_URL.replace('https://', '') class DevelopmentConfig(BaseConfig): diff --git a/fittrackee/federation/__init__.py b/fittrackee/federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py new file mode 100644 index 000000000..a6241ded2 --- /dev/null +++ b/fittrackee/federation/federation.py @@ -0,0 +1,24 @@ +from flask import Blueprint, current_app + +from fittrackee.responses import HttpResponse, UserNotFoundErrorResponse + +from .models import Actor + +ap_federation_blueprint = Blueprint('ap_federation', __name__) + + +@ap_federation_blueprint.route( + '/user/', methods=['GET'] +) +def get_actor(preferred_username: str) -> HttpResponse: + actor = Actor.query.filter_by( + preferred_username=preferred_username, + domain=current_app.config['AP_DOMAIN'], + ).first() + if not actor: + return UserNotFoundErrorResponse() + + return HttpResponse( + response=actor.serialize(), + content_type='application/jrd+json; charset=utf-8', + ) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py new file mode 100644 index 000000000..170e3984c --- /dev/null +++ b/fittrackee/federation/models.py @@ -0,0 +1,88 @@ +from datetime import datetime +from typing import Dict, Optional + +from flask import current_app +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.types import Enum + +from fittrackee import db +from fittrackee.users.models import User + +from .utils import ACTOR_TYPES, AP_CTX, generate_keys, get_ap_url + +BaseModel: DeclarativeMeta = db.Model + + +class Actor(BaseModel): + """ActivityPub Actor""" + + __tablename__ = 'actors' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + ap_id = db.Column(db.String(255), unique=True, nullable=False) + user_id = db.Column( + db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False + ) + type = db.Column( + Enum(*ACTOR_TYPES, name='actor_types'), server_default='Person' + ) + domain = db.Column(db.String(1000), nullable=False) + name = db.Column(db.String(255), nullable=False) + preferred_username = db.Column(db.String(255), nullable=False) + public_key = db.Column(db.String(5000), nullable=True) + private_key = db.Column(db.String(5000), nullable=True) + inbox_url = db.Column(db.String(255), nullable=False) + outbox_url = db.Column(db.String(255), nullable=False) + followers_url = db.Column(db.String(255), nullable=False) + following_url = db.Column(db.String(255), nullable=False) + shared_inbox_url = db.Column(db.String(255), nullable=False) + is_remote = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + manually_approves_followers = db.Column( + db.Boolean, default=True, nullable=False + ) + last_fetch_date = db.Column(db.DateTime, nullable=True) + + def __str__(self) -> str: + return f'' + + def __init__( + self, + user: User, + created_at: Optional[datetime] = datetime.utcnow(), + is_remote: Optional[bool] = False, + ) -> None: + self.ap_id = get_ap_url(user.username, 'user_url') + self.created_at = created_at + self.domain = f"{current_app.config['AP_DOMAIN']}" + self.followers_url = get_ap_url(user.username, 'followers') + self.following_url = get_ap_url(user.username, 'following') + self.inbox_url = get_ap_url(user.username, 'inbox') + self.is_remote = is_remote + self.name = user.username + self.outbox_url = get_ap_url(user.username, 'outbox') + self.preferred_username = user.username + self.shared_inbox_url = get_ap_url(user.username, 'shared_inbox') + self.user_id = user.id + + def generate_keys(self) -> None: + self.public_key, self.private_key = generate_keys() + + def serialize(self) -> Dict: + return { + '@context': AP_CTX, + 'id': self.ap_id, + 'type': self.type, + 'preferredUsername': self.preferred_username, + 'name': self.name, + 'inbox': self.inbox_url, + 'outbox': self.outbox_url, + 'followers': self.followers_url, + 'following': self.following_url, + 'manuallyApprovesFollowers': self.manually_approves_followers, + 'publicKey': { + 'id': f'{self.ap_id}#main-key', + 'owner': self.ap_id, + 'publicKeyPem': self.public_key, + }, + 'endpoints': {'sharedInbox': self.shared_inbox_url}, + } diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py new file mode 100644 index 000000000..673302d7d --- /dev/null +++ b/fittrackee/federation/utils.py @@ -0,0 +1,44 @@ +from typing import Tuple + +from Crypto.PublicKey import RSA +from flask import current_app + +ACTOR_TYPES = ['Application', 'Group', 'Person'] + +AP_CTX = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', +] + + +def generate_keys() -> Tuple[str, str]: + """ + Generate a new RSA key pair and return public and private keys as string + """ + key_pair = RSA.generate(2048) + private_key = key_pair.exportKey('PEM').decode('utf-8') + public_key = key_pair.publickey().exportKey('PEM').decode('utf-8') + return public_key, private_key + + +def get_ap_url(username: str, url_type: str) -> str: + """ + Return ActivityPub URLs. + + Supported URL types: + - 'user_url' + - 'inbox' + - 'outbox' + - 'following' + - 'followers' + - 'shared_inbox' + """ + ap_url = f"{current_app.config['AP_DOMAIN']}/federation/" + ap_url_user = f'{ap_url}user/{username}' + if url_type == 'user_url': + return ap_url_user + if url_type in ['inbox', 'outbox', 'following', 'followers']: + return f'{ap_url_user}/{url_type}' + if url_type == 'shared_inbox': + return f'{ap_url}inbox' + raise Exception('Invalid \'url_type\'.') diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py new file mode 100644 index 000000000..789ba547e --- /dev/null +++ b/fittrackee/federation/webfinger.py @@ -0,0 +1,46 @@ +from flask import Blueprint, current_app, request + +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + UserNotFoundErrorResponse, +) + +from .models import Actor + +ap_webfinger_blueprint = Blueprint('ap_webfinger', __name__) + + +@ap_webfinger_blueprint.route('/.well-known/webfinger', methods=['GET']) +def webfinger() -> HttpResponse: + resource = request.args.get('resource') + if not resource or not resource.startswith('acct:'): + return InvalidPayloadErrorResponse('Missing resource in request args.') + + try: + preferred_username, domain = resource.replace('acct:', '').split('@') + except ValueError: + return InvalidPayloadErrorResponse('Invalid resource.') + + if domain != current_app.config['AP_DOMAIN']: + return UserNotFoundErrorResponse() + + actor = Actor.query.filter_by( + preferred_username=preferred_username, domain=domain + ).first() + if not actor: + return UserNotFoundErrorResponse() + + response = { + 'subject': f'acct:{actor.preferred_username}@{actor.domain}', + 'links': [ + { + 'href': actor.ap_id, + 'rel': 'self', + 'type': 'application/activity+json', + } + ], + } + return HttpResponse( + response=response, content_type='application/jrd+json; charset=utf-8' + ) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py new file mode 100644 index 000000000..f1a350822 --- /dev/null +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -0,0 +1,50 @@ +"""init federation with ActivityPub Actor + +Revision ID: 8842c351a2d8 +Revises: 4e8597c50064 +Create Date: 2021-01-10 16:02:43.811023 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8842c351a2d8' +down_revision = 'e30007d681cb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('actors', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ap_id', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), + sa.Column('domain', sa.String(length=1000), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('preferred_username', sa.String(length=255), nullable=False), + sa.Column('public_key', sa.String(length=5000), nullable=True), + sa.Column('private_key', sa.String(length=5000), nullable=True), + sa.Column('inbox_url', sa.String(length=255), nullable=False), + sa.Column('outbox_url', sa.String(length=255), nullable=False), + sa.Column('followers_url', sa.String(length=255), nullable=False), + sa.Column('following_url', sa.String(length=255), nullable=False), + sa.Column('shared_inbox_url', sa.String(length=255), nullable=False), + sa.Column('is_remote', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), + sa.Column('last_fetch_date', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('ap_id'), + sa.UniqueConstraint('user_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_table('actors') + op.execute('DROP TYPE actor_types') diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index 46b610fd0..98230d445 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -7,6 +7,7 @@ pytest_plugins = [ 'fittrackee.tests.fixtures.fixtures_app', + 'fittrackee.tests.fixtures.fixtures_federation', 'fittrackee.tests.fixtures.fixtures_workouts', 'fittrackee.tests.fixtures.fixtures_users', ] diff --git a/fittrackee/tests/federation/__init__.py b/fittrackee/tests/federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/test_federation_federation.py b/fittrackee/tests/federation/test_federation_federation.py new file mode 100644 index 000000000..72a1554d2 --- /dev/null +++ b/fittrackee/tests/federation/test_federation_federation.py @@ -0,0 +1,39 @@ +import json +from uuid import uuid4 + +from flask import Flask + +from fittrackee.federation.models import Actor + + +class TestFederationUser: + def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: + client = app.test_client() + response = client.get( + f'/federation/user/{uuid4().hex}', + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + def test_it_returns_json_resource_descriptor_as_content_type( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + f'/federation/user/{actor_1.preferred_username}', + ) + + assert response.status_code == 200 + assert response.content_type == 'application/jrd+json; charset=utf-8' + + def test_it_returns_actor(self, app: Flask, actor_1: Actor) -> None: + client = app.test_client() + response = client.get( + f'/federation/user/{actor_1.preferred_username}', + ) + + data = json.loads(response.data.decode()) + assert data == actor_1.serialize() diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py new file mode 100644 index 000000000..c1ee08e51 --- /dev/null +++ b/fittrackee/tests/federation/test_federation_models.py @@ -0,0 +1,71 @@ +from uuid import uuid4 + +import pytest +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.federation.utils import AP_CTX, get_ap_url + + +class TestGetApUrl: + def test_it_raises_error_if_url_type_is_invalid(self, app: Flask) -> None: + with pytest.raises(Exception, match="Invalid 'url_type'."): + get_ap_url(username=uuid4().hex, url_type='url') + + +class TestActivityPubActorModel: + def test_actor_model(self, app: Flask, actor_1: Actor) -> None: + assert '' == str(actor_1) + + serialized_apactor = actor_1.serialize() + ap_url = app.config['AP_DOMAIN'] + assert serialized_apactor['@context'] == AP_CTX + assert serialized_apactor['id'] == actor_1.ap_id + assert serialized_apactor['type'] == 'Person' + assert ( + serialized_apactor['preferredUsername'] + == actor_1.preferred_username + ) + assert serialized_apactor['name'] == actor_1.name + assert ( + serialized_apactor['inbox'] + == f'{ap_url}/federation/user/{actor_1.name}/inbox' + ) + assert ( + serialized_apactor['inbox'] + == f'{ap_url}/federation/user/{actor_1.name}/inbox' + ) + assert ( + serialized_apactor['outbox'] + == f'{ap_url}/federation/user/{actor_1.name}/outbox' + ) + assert ( + serialized_apactor['followers'] + == f'{ap_url}/federation/user/{actor_1.name}/followers' + ) + assert ( + serialized_apactor['following'] + == f'{ap_url}/federation/user/{actor_1.name}/following' + ) + assert serialized_apactor['manuallyApprovesFollowers'] is True + assert ( + serialized_apactor['publicKey']['id'] + == f'{actor_1.ap_id}#main-key' + ) + assert serialized_apactor['publicKey']['owner'] == actor_1.ap_id + assert 'publicKeyPem' in serialized_apactor['publicKey'] + assert ( + serialized_apactor['endpoints']['sharedInbox'] + == f'{ap_url}/federation/inbox' + ) + + def test_generated_key_is_valid(self, app: Flask, actor_1: Actor) -> None: + actor_1.generate_keys() + + signer = pkcs1_15.new(RSA.import_key(actor_1.private_key)) + hashed_message = SHA256.new('test message'.encode()) + # it raises ValueError if signature is invalid + signer.verify(hashed_message, signer.sign(hashed_message)) diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/test_federation_webfinger.py new file mode 100644 index 000000000..e9247092a --- /dev/null +++ b/fittrackee/tests/federation/test_federation_webfinger.py @@ -0,0 +1,118 @@ +import json +from uuid import uuid4 + +from flask import Flask + +from fittrackee.federation.models import Actor + + +class TestWebfinger: + def test_it_returns_400_if_resource_is_missing(self, app: Flask) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger', + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Missing resource in request args.' in data['message'] + + def test_it_returns_400_if_account_is_missing(self, app: Flask) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=test@example.com', + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Missing resource in request args.' in data['message'] + + def test_it_returns_400_if_argument_is_invalid(self, app: Flask) -> None: + client = app.test_client() + response = client.get( + f'/.well-known/webfinger?resource=acct:{uuid4().hex}', + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Invalid resource.' in data['message'] + + def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: + domain = app.config['AP_DOMAIN'] + client = app.test_client() + response = client.get( + f'/.well-known/webfinger?resource=acct:{uuid4().hex}@{domain}', + content_type='application/json', + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + def test_it_returns_404_if_domain_is_not_instance_domain( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=acct:' + f'{actor_1.preferred_username}@{uuid4().hex}', + content_type='application/json', + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + def test_it_returns_json_resource_descriptor_as_content_type( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=acct:' + f'{actor_1.preferred_username}@{actor_1.domain}', + content_type='application/json', + ) + + assert response.status_code == 200 + assert response.content_type == 'application/jrd+json; charset=utf-8' + + def test_it_returns_subject_with_user_data( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=acct:' + f'{actor_1.preferred_username}@{actor_1.domain}', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert ( + f'acct:{actor_1.preferred_username}@{actor_1.domain}' + in data['subject'] + ) + + def test_it_returns_user_links(self, app: Flask, actor_1: Actor) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=acct:' + f'{actor_1.preferred_username}@{actor_1.domain}', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['links'][0] == { + 'href': actor_1.ap_id, + 'rel': 'self', + 'type': 'application/activity+json', + } diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py new file mode 100644 index 000000000..e81b63493 --- /dev/null +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -0,0 +1,13 @@ +import pytest + +from fittrackee import db +from fittrackee.federation.models import Actor +from fittrackee.users.models import User + + +@pytest.fixture() +def actor_1(user_1: User) -> Actor: + actor = Actor(user=user_1) + db.session.add(actor) + db.session.commit() + return actor diff --git a/poetry.lock b/poetry.lock index 7385103b4..457112ce8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -135,7 +135,7 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "8.0.3" +version = "8.0.4" description = "Composable command line interface toolkit" category = "main" optional = false @@ -461,11 +461,11 @@ plugins = ["setuptools"] [[package]] name = "itsdangerous" -version = "2.0.1" +version = "2.1.0" description = "Safely pass data to untrusted environments and back." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "jinja2" @@ -498,11 +498,11 @@ lingua = ["lingua"] [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.0" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mccabe" @@ -578,7 +578,7 @@ python-versions = ">=3.7" [[package]] name = "platformdirs" -version = "2.5.0" +version = "2.5.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -646,6 +646,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pycryptodome" +version = "3.14.1" +description = "Cryptographic library for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "pyflakes" version = "2.3.1" @@ -1350,7 +1358,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "42616189ff19a6e3c7c397a44a7622d6b97ca263924860f15cf0bc781e36e40d" +content-hash = "3deeac6cf2fdf242cdae9b9d53f14bdf867057bc339ba35f1074172f8ee38f11" [metadata.files] alabaster = [ @@ -1472,8 +1480,8 @@ charset-normalizer = [ {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] click = [ - {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, - {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, + {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, + {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1678,8 +1686,8 @@ isort = [ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] itsdangerous = [ - {file = "itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c"}, - {file = "itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"}, + {file = "itsdangerous-2.1.0-py3-none-any.whl", hash = "sha256:29285842166554469a56d427addc0843914172343784cb909695fdbe90a3e129"}, + {file = "itsdangerous-2.1.0.tar.gz", hash = "sha256:d848fcb8bc7d507c4546b448574e8a44fc4ea2ba84ebf8d783290d53e81992f5"}, ] jinja2 = [ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, @@ -1690,40 +1698,46 @@ mako = [ {file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e104c0c2b4cd765b4e83909cde7ec61a1e313f8a75775897db321450e928cce"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24c3be29abb6b34052fd26fc7a8e0a49b1ee9d282e3665e8ad09a0a68faee5b3"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204730fd5fe2fe3b1e9ccadb2bd18ba8712b111dcabce185af0b3b5285a7c989"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-win32.whl", hash = "sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454"}, + {file = "MarkupSafe-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ddea4c352a488b5e1069069f2f501006b1a4362cb906bee9a193ef1245a7a61"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09c86c9643cceb1d87ca08cdc30160d1b7ab49a8a21564868921959bd16441b8"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0a0abef2ca47b33fb615b491ce31b055ef2430de52c5b3fb19a4042dbc5cadb"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:84ad5e29bf8bab3ad70fd707d3c05524862bddc54dc040982b0dbcff36481de7"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-win32.whl", hash = "sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a"}, + {file = "MarkupSafe-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc3150f85e2dbcf99e65238c842d1cfe69d3e7649b19864c1cc043213d9cd730"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-win32.whl", hash = "sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8"}, + {file = "MarkupSafe-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d66624f04de4af8bbf1c7f21cc06649c1c69a7f84109179add573ce35e46d448"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c532d5ab79be0199fa2658e24a02fce8542df196e60665dd322409a03db6a52c"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ec74fada3841b8c5f4c4f197bea916025cb9aa3fe5abf7d52b655d042f956"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c653fde75a6e5eb814d2a0a89378f83d1d3f502ab710904ee585c38888816c"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:961eb86e5be7d0973789f30ebcf6caab60b844203f4396ece27310295a6082c7"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-win32.whl", hash = "sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05"}, + {file = "MarkupSafe-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7"}, + {file = "MarkupSafe-2.1.0.tar.gz", hash = "sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -1805,8 +1819,8 @@ pillow = [ {file = "Pillow-9.0.1.tar.gz", hash = "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa"}, ] platformdirs = [ - {file = "platformdirs-2.5.0-py3-none-any.whl", hash = "sha256:30671902352e97b1eafd74ade8e4a694782bd3471685e78c32d0fdfd3aa7e7bb"}, - {file = "platformdirs-2.5.0.tar.gz", hash = "sha256:8ec11dfba28ecc0715eb5fb0147a87b1bf325f349f3da9aab2cd6b50b96b692b"}, + {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, + {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1886,6 +1900,38 @@ pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +pycryptodome = [ + {file = "pycryptodome-3.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win32.whl", hash = "sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2"}, + {file = "pycryptodome-3.14.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win32.whl", hash = "sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win_amd64.whl", hash = "sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-win32.whl", hash = "sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0"}, + {file = "pycryptodome-3.14.1.tar.gz", hash = "sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b"}, +] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, diff --git a/pyproject.toml b/pyproject.toml index f46df6f64..9f7a0c72a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ shortuuid = "^1.0.8" staticmap = "^0.5.4" SQLAlchemy = "1.4.31" pyOpenSSL = "^22.0" +pycryptodome = "^3.14.1" [tool.poetry.dev-dependencies] black = "^22.1" From f7cfa54d2d06cfc24aaf0d5fcc23c37c404f9d3b Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 10:38:52 +0100 Subject: [PATCH 002/238] API - add application config option to enable federation w/ ActivityPub --- fittrackee/__init__.py | 15 ++++- fittrackee/application/app_config.py | 2 + fittrackee/application/models.py | 2 + fittrackee/application/utils.py | 1 + fittrackee/federation/federation.py | 2 + fittrackee/federation/utils.py | 15 ++++- fittrackee/federation/webfinger.py | 2 + .../23_8842c351a2d8_init_federation.py | 57 ++++++++++------- .../tests/application/test_app_config_api.py | 2 + .../application/test_app_config_model.py | 1 + .../federation/test_federation_federation.py | 32 ++++++++-- .../federation/test_federation_webfinger.py | 62 ++++++++++++++----- fittrackee/tests/fixtures/fixtures_app.py | 10 +++ 13 files changed, 152 insertions(+), 51 deletions(-) diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index c83f3a448..af47885fb 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -1,5 +1,6 @@ import logging import os +import re import shutil from importlib import import_module, reload from typing import Any @@ -15,6 +16,7 @@ from flask_dramatiq import Dramatiq from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.exc import ProgrammingError from fittrackee.emails.email import EmailService @@ -66,9 +68,16 @@ def create_app() -> Flask: with app.app_context(): # Note: check if "app_config" table exist to avoid errors when # dropping tables on dev environments - if db.engine.dialect.has_table(db.engine.connect(), 'app_config'): - db_app_config = get_or_init_config() - update_app_config_from_database(app, db_app_config) + try: + if db.engine.dialect.has_table(db.engine.connect(), 'app_config'): + db_app_config = get_or_init_config() + update_app_config_from_database(app, db_app_config) + except ProgrammingError as e: + # avoid error on AppConfig migration + if re.match( + r'psycopg2.errors.UndefinedColumn(.*)app_config.', str(e) + ): + pass from .application.app_config import config_blueprint # noqa from .users.auth import auth_blueprint # noqa diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index b3c91a846..b11529e16 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -123,6 +123,8 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: try: config = AppConfig.query.one() + if 'federation_enabled' in config_data: + config.federation_enabled = config_data.get('federation_enabled') if 'gpx_limit_import' in config_data: config.gpx_limit_import = config_data.get('gpx_limit_import') if 'max_single_file_size' in config_data: diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index c4d2cea87..0a0a0a51d 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -23,6 +23,7 @@ class AppConfig(BaseModel): db.Integer, default=1048576, nullable=False ) max_zip_file_size = db.Column(db.Integer, default=10485760, nullable=False) + federation_enabled = db.Column(db.Boolean, default=False, nullable=False) @property def is_registration_enabled(self) -> bool: @@ -43,6 +44,7 @@ def map_attribution(self) -> str: def serialize(self) -> Dict: return { + 'federation_enabled': self.federation_enabled, 'gpx_limit_import': self.gpx_limit_import, 'is_registration_enabled': self.is_registration_enabled, 'max_single_file_size': self.max_single_file_size, diff --git a/fittrackee/application/utils.py b/fittrackee/application/utils.py index eb8183fc2..a03b66712 100644 --- a/fittrackee/application/utils.py +++ b/fittrackee/application/utils.py @@ -28,6 +28,7 @@ def get_or_init_config() -> AppConfig: def update_app_config_from_database( current_app: Flask, db_config: AppConfig ) -> None: + current_app.config['federation_enabled'] = db_config.federation_enabled current_app.config['gpx_limit_import'] = db_config.gpx_limit_import current_app.config['max_single_file_size'] = db_config.max_single_file_size current_app.config['MAX_CONTENT_LENGTH'] = db_config.max_zip_file_size diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index a6241ded2..87b4756ed 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -3,6 +3,7 @@ from fittrackee.responses import HttpResponse, UserNotFoundErrorResponse from .models import Actor +from .utils import federation_required ap_federation_blueprint = Blueprint('ap_federation', __name__) @@ -10,6 +11,7 @@ @ap_federation_blueprint.route( '/user/', methods=['GET'] ) +@federation_required def get_actor(preferred_username: str) -> HttpResponse: actor = Actor.query.filter_by( preferred_username=preferred_username, diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 673302d7d..71717c5c1 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,8 +1,11 @@ -from typing import Tuple +from functools import wraps +from typing import Any, Callable, Tuple from Crypto.PublicKey import RSA from flask import current_app +from fittrackee.responses import InternalServerErrorResponse + ACTOR_TYPES = ['Application', 'Group', 'Person'] AP_CTX = [ @@ -42,3 +45,13 @@ def get_ap_url(username: str, url_type: str) -> str: if url_type == 'shared_inbox': return f'{ap_url}inbox' raise Exception('Invalid \'url_type\'.') + + +def federation_required(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Callable: + if not current_app.config['federation_enabled']: + return InternalServerErrorResponse() + return f(*args, **kwargs) + + return decorated_function diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index 789ba547e..d66d4b49f 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -7,11 +7,13 @@ ) from .models import Actor +from .utils import federation_required ap_webfinger_blueprint = Blueprint('ap_webfinger', __name__) @ap_webfinger_blueprint.route('/.well-known/webfinger', methods=['GET']) +@federation_required def webfinger() -> HttpResponse: resource = request.args.get('resource') if not resource or not resource.startswith('acct:'): diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index f1a350822..9dd217e2e 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -17,34 +17,43 @@ def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + 'app_config', + sa.Column( + 'federation_enabled', sa.Boolean(), nullable=True, default=False + ), + ) + op.execute('UPDATE app_config SET federation_enabled = true') + op.alter_column('app_config', 'federation_enabled', nullable=False) + op.create_table('actors', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('ap_id', sa.String(length=255), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), - sa.Column('domain', sa.String(length=1000), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('preferred_username', sa.String(length=255), nullable=False), - sa.Column('public_key', sa.String(length=5000), nullable=True), - sa.Column('private_key', sa.String(length=5000), nullable=True), - sa.Column('inbox_url', sa.String(length=255), nullable=False), - sa.Column('outbox_url', sa.String(length=255), nullable=False), - sa.Column('followers_url', sa.String(length=255), nullable=False), - sa.Column('following_url', sa.String(length=255), nullable=False), - sa.Column('shared_inbox_url', sa.String(length=255), nullable=False), - sa.Column('is_remote', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), - sa.Column('last_fetch_date', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('ap_id'), - sa.UniqueConstraint('user_id') + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ap_id', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), + sa.Column('domain', sa.String(length=1000), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('preferred_username', sa.String(length=255), nullable=False), + sa.Column('public_key', sa.String(length=5000), nullable=True), + sa.Column('private_key', sa.String(length=5000), nullable=True), + sa.Column('inbox_url', sa.String(length=255), nullable=False), + sa.Column('outbox_url', sa.String(length=255), nullable=False), + sa.Column('followers_url', sa.String(length=255), nullable=False), + sa.Column('following_url', sa.String(length=255), nullable=False), + sa.Column('shared_inbox_url', sa.String(length=255), nullable=False), + sa.Column('is_remote', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), + sa.Column('last_fetch_date', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('ap_id'), + sa.UniqueConstraint('user_id') ) - # ### end Alembic commands ### def downgrade(): op.drop_table('actors') op.execute('DROP TYPE actor_types') + + op.drop_column('app_config', 'federation_enabled') diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index c17dac1c3..75dccb2cf 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -106,6 +106,7 @@ def test_it_updates_all_config( content_type='application/json', data=json.dumps( dict( + federation_enabled=True, gpx_limit_import=20, max_single_file_size=10000, max_zip_file_size=25000, @@ -118,6 +119,7 @@ def test_it_updates_all_config( data = json.loads(response.data.decode()) assert response.status_code == 200 assert 'success' in data['status'] + assert data['data']['federation_enabled'] is True assert data['data']['gpx_limit_import'] == 20 assert data['data']['is_registration_enabled'] is True assert data['data']['max_single_file_size'] == 10000 diff --git a/fittrackee/tests/application/test_app_config_model.py b/fittrackee/tests/application/test_app_config_model.py index efc59efad..b713c42e1 100644 --- a/fittrackee/tests/application/test_app_config_model.py +++ b/fittrackee/tests/application/test_app_config_model.py @@ -9,6 +9,7 @@ def test_application_config(self, app: Flask) -> None: assert 1 == app_config.id serialized_app_config = app_config.serialize() + assert serialized_app_config['federation_enabled'] is False assert serialized_app_config['gpx_limit_import'] == 10 assert serialized_app_config['is_registration_enabled'] is True assert serialized_app_config['max_single_file_size'] == 1048576 diff --git a/fittrackee/tests/federation/test_federation_federation.py b/fittrackee/tests/federation/test_federation_federation.py index 72a1554d2..ddd4a1015 100644 --- a/fittrackee/tests/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/test_federation_federation.py @@ -7,8 +7,10 @@ class TestFederationUser: - def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: - client = app.test_client() + def test_it_returns_404_if_user_does_not_exist( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() response = client.get( f'/federation/user/{uuid4().hex}', ) @@ -19,9 +21,9 @@ def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: assert 'user does not exist' in data['message'] def test_it_returns_json_resource_descriptor_as_content_type( - self, app: Flask, actor_1: Actor + self, app_with_federation: Flask, actor_1: Actor ) -> None: - client = app.test_client() + client = app_with_federation.test_client() response = client.get( f'/federation/user/{actor_1.preferred_username}', ) @@ -29,11 +31,29 @@ def test_it_returns_json_resource_descriptor_as_content_type( assert response.status_code == 200 assert response.content_type == 'application/jrd+json; charset=utf-8' - def test_it_returns_actor(self, app: Flask, actor_1: Actor) -> None: - client = app.test_client() + def test_it_returns_actor( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() response = client.get( f'/federation/user/{actor_1.preferred_username}', ) data = json.loads(response.data.decode()) assert data == actor_1.serialize() + + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + f'/federation/user/{actor_1.preferred_username}', + ) + + assert response.status_code == 500 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, please try again or contact the administrator' + in data['message'] + ) diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/test_federation_webfinger.py index e9247092a..7d39d3ad3 100644 --- a/fittrackee/tests/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/test_federation_webfinger.py @@ -7,8 +7,10 @@ class TestWebfinger: - def test_it_returns_400_if_resource_is_missing(self, app: Flask) -> None: - client = app.test_client() + def test_it_returns_400_if_resource_is_missing( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger', content_type='application/json', @@ -19,8 +21,10 @@ def test_it_returns_400_if_resource_is_missing(self, app: Flask) -> None: assert 'error' in data['status'] assert 'Missing resource in request args.' in data['message'] - def test_it_returns_400_if_account_is_missing(self, app: Flask) -> None: - client = app.test_client() + def test_it_returns_400_if_account_is_missing( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=test@example.com', content_type='application/json', @@ -31,8 +35,10 @@ def test_it_returns_400_if_account_is_missing(self, app: Flask) -> None: assert 'error' in data['status'] assert 'Missing resource in request args.' in data['message'] - def test_it_returns_400_if_argument_is_invalid(self, app: Flask) -> None: - client = app.test_client() + def test_it_returns_400_if_argument_is_invalid( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() response = client.get( f'/.well-known/webfinger?resource=acct:{uuid4().hex}', content_type='application/json', @@ -43,9 +49,11 @@ def test_it_returns_400_if_argument_is_invalid(self, app: Flask) -> None: assert 'error' in data['status'] assert 'Invalid resource.' in data['message'] - def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: - domain = app.config['AP_DOMAIN'] - client = app.test_client() + def test_it_returns_404_if_user_does_not_exist( + self, app_with_federation: Flask + ) -> None: + domain = app_with_federation.config['AP_DOMAIN'] + client = app_with_federation.test_client() response = client.get( f'/.well-known/webfinger?resource=acct:{uuid4().hex}@{domain}', content_type='application/json', @@ -57,9 +65,9 @@ def test_it_returns_404_if_user_does_not_exist(self, app: Flask) -> None: assert 'user does not exist' in data['message'] def test_it_returns_404_if_domain_is_not_instance_domain( - self, app: Flask, actor_1: Actor + self, app_with_federation: Flask, actor_1: Actor ) -> None: - client = app.test_client() + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.preferred_username}@{uuid4().hex}', @@ -72,9 +80,9 @@ def test_it_returns_404_if_domain_is_not_instance_domain( assert 'user does not exist' in data['message'] def test_it_returns_json_resource_descriptor_as_content_type( - self, app: Flask, actor_1: Actor + self, app_with_federation: Flask, actor_1: Actor ) -> None: - client = app.test_client() + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.preferred_username}@{actor_1.domain}', @@ -85,9 +93,9 @@ def test_it_returns_json_resource_descriptor_as_content_type( assert response.content_type == 'application/jrd+json; charset=utf-8' def test_it_returns_subject_with_user_data( - self, app: Flask, actor_1: Actor + self, app_with_federation: Flask, actor_1: Actor ) -> None: - client = app.test_client() + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.preferred_username}@{actor_1.domain}', @@ -101,8 +109,10 @@ def test_it_returns_subject_with_user_data( in data['subject'] ) - def test_it_returns_user_links(self, app: Flask, actor_1: Actor) -> None: - client = app.test_client() + def test_it_returns_user_links( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.preferred_username}@{actor_1.domain}', @@ -116,3 +126,21 @@ def test_it_returns_user_links(self, app: Flask, actor_1: Actor) -> None: 'rel': 'self', 'type': 'application/activity+json', } + + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/.well-known/webfinger?resource=acct:' + f'{actor_1.preferred_username}@{actor_1.domain}', + content_type='application/json', + ) + + assert response.status_code == 500 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, please try again or contact the administrator' + in data['message'] + ) diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index e4195d5d4..c4a21f9e7 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -14,9 +14,11 @@ def get_app_config( max_single_file_size: Optional[Union[int, float]] = None, max_zip_file_size: Optional[Union[int, float]] = None, max_users: Optional[int] = None, + with_federation: Optional[bool] = False, ) -> Optional[AppConfig]: if with_config: config = AppConfig() + config.federation_enabled = with_federation config.gpx_limit_import = 10 if max_workouts is None else max_workouts config.max_single_file_size = ( (1 if max_single_file_size is None else max_single_file_size) @@ -41,6 +43,7 @@ def get_app( max_single_file_size: Optional[Union[int, float]] = None, max_zip_file_size: Optional[Union[int, float]] = None, max_users: Optional[int] = None, + with_federation: Optional[bool] = False, ) -> Generator: app = create_app() with app.app_context(): @@ -52,6 +55,7 @@ def get_app( max_single_file_size, max_zip_file_size, max_users, + with_federation, ) if app_db_config: update_app_config_from_database(app, app_db_config) @@ -151,9 +155,15 @@ def app_wo_domain() -> Generator: yield from get_app(with_config=True) +@pytest.fixture +def app_with_federation() -> Generator: + yield from get_app(with_config=True, with_federation=True) + + @pytest.fixture() def app_config() -> AppConfig: config = AppConfig() + config.federation_enabled = False config.gpx_limit_import = 10 config.max_single_file_size = 1048576 config.max_zip_file_size = 10485760 From 37382b1a363a3c9e3a9d72020e96ed82a2bf37cd Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 10:49:19 +0100 Subject: [PATCH 003/238] API - update error message on disabled federation --- fittrackee/federation/utils.py | 4 ++-- fittrackee/responses.py | 8 +++++++- fittrackee/tests/federation/test_federation_federation.py | 4 ++-- fittrackee/tests/federation/test_federation_webfinger.py | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 71717c5c1..1de407edb 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -4,7 +4,7 @@ from Crypto.PublicKey import RSA from flask import current_app -from fittrackee.responses import InternalServerErrorResponse +from fittrackee.responses import DisabledFederationErrorResponse ACTOR_TYPES = ['Application', 'Group', 'Person'] @@ -51,7 +51,7 @@ def federation_required(f: Callable) -> Callable: @wraps(f) def decorated_function(*args: Any, **kwargs: Any) -> Callable: if not current_app.config['federation_enabled']: - return InternalServerErrorResponse() + return DisabledFederationErrorResponse() return f(*args, **kwargs) return decorated_function diff --git a/fittrackee/responses.py b/fittrackee/responses.py index 165b8ca0a..bf0e8f1c6 100644 --- a/fittrackee/responses.py +++ b/fittrackee/responses.py @@ -124,7 +124,7 @@ def __init__( class InternalServerErrorResponse(GenericErrorResponse): def __init__( self, message: Optional[str] = None, status: Optional[str] = None - ): + ) -> None: message = ( 'error, please try again or contact the administrator' if message is None @@ -133,6 +133,12 @@ def __init__( super().__init__(status_code=500, message=message, status=status) +class DisabledFederationErrorResponse(ForbiddenErrorResponse): + def __init__(self) -> None: + message = 'error, federation is disabled for this instance' + super().__init__(message=message) + + def handle_error_and_return_response( error: Exception, message: Optional[str] = None, diff --git a/fittrackee/tests/federation/test_federation_federation.py b/fittrackee/tests/federation/test_federation_federation.py index ddd4a1015..f86986f93 100644 --- a/fittrackee/tests/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/test_federation_federation.py @@ -50,10 +50,10 @@ def test_it_returns_error_if_federation_is_disabled( f'/federation/user/{actor_1.preferred_username}', ) - assert response.status_code == 500 + assert response.status_code == 403 data = json.loads(response.data.decode()) assert 'error' in data['status'] assert ( - 'error, please try again or contact the administrator' + 'error, federation is disabled for this instance' in data['message'] ) diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/test_federation_webfinger.py index 7d39d3ad3..f1d1126dc 100644 --- a/fittrackee/tests/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/test_federation_webfinger.py @@ -137,10 +137,10 @@ def test_it_returns_error_if_federation_is_disabled( content_type='application/json', ) - assert response.status_code == 500 + assert response.status_code == 403 data = json.loads(response.data.decode()) assert 'error' in data['status'] assert ( - 'error, please try again or contact the administrator' + 'error, federation is disabled for this instance' in data['message'] ) From 6201c853423485bced901582157da21ace17d6d5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:07:14 +0100 Subject: [PATCH 004/238] API - init nodeinfo endpoints for federation with ActivityPub (wip) --- fittrackee/__init__.py | 4 +- fittrackee/config.py | 3 + fittrackee/federation/nodeinfo.py | 50 +++++++++++ fittrackee/federation/webfinger.py | 2 +- fittrackee/tests/application/test_config.py | 17 ++++ .../federation/test_federation_nodeinfo.py | 88 +++++++++++++++++++ 6 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 fittrackee/federation/nodeinfo.py create mode 100644 fittrackee/tests/federation/test_federation_nodeinfo.py diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index af47885fb..1ad9cea60 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -97,10 +97,12 @@ def create_app() -> Flask: # ActivityPub federation from .federation.federation import ap_federation_blueprint # noqa + from .federation.nodeinfo import ap_nodeinfo_blueprint # noqa from .federation.webfinger import ap_webfinger_blueprint # noqa app.register_blueprint(ap_federation_blueprint, url_prefix='/federation') - app.register_blueprint(ap_webfinger_blueprint) + app.register_blueprint(ap_nodeinfo_blueprint) + app.register_blueprint(ap_webfinger_blueprint, url_prefix='/.well-known') if app.debug: logging.getLogger('sqlalchemy').setLevel(logging.WARNING) diff --git a/fittrackee/config.py b/fittrackee/config.py index c1c7fd66f..ccc9d9137 100644 --- a/fittrackee/config.py +++ b/fittrackee/config.py @@ -5,6 +5,8 @@ from flask import current_app from sqlalchemy.pool import NullPool +from fittrackee import VERSION + if os.getenv('APP_SETTINGS') == 'fittrackee.config.TestingConfig': broker = StubBroker else: @@ -46,6 +48,7 @@ class BaseConfig: os.environ.get('DEFAULT_STATICMAP', 'False') == 'True' ), } + VERSION = VERSION # ActivityPub AP_DOMAIN = UI_URL.replace('https://', '') diff --git a/fittrackee/federation/nodeinfo.py b/fittrackee/federation/nodeinfo.py new file mode 100644 index 000000000..6ed69392e --- /dev/null +++ b/fittrackee/federation/nodeinfo.py @@ -0,0 +1,50 @@ +from flask import Blueprint, current_app + +from fittrackee.responses import HttpResponse +from fittrackee.users.models import User +from fittrackee.workouts.models import Workout + +from .utils import federation_required + +ap_nodeinfo_blueprint = Blueprint('ap_nodeinfo', __name__) + + +@ap_nodeinfo_blueprint.route('/.well-known/nodeinfo', methods=['GET']) +@federation_required +def get_nodeinfo_url() -> HttpResponse: + nodeinfo_url = f'https://{current_app.config["AP_DOMAIN"]}/nodeinfo/2.0' + response = { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': nodeinfo_url, + } + ] + } + return HttpResponse( + response=response, content_type='application/json; charset=utf-8' + ) + + +@ap_nodeinfo_blueprint.route('/nodeinfo/2.0', methods=['GET']) +@federation_required +def get_nodeinfo() -> HttpResponse: + # TODO : add 'activeHalfyear' and 'activeMonth' for users + workouts_count = Workout.query.filter().count() + users_count = User.query.filter().count() + response = { + 'version': '2.0', + 'software': { + 'name': 'fittrackee', + 'version': current_app.config['VERSION'], + }, + 'protocols': ['activitypub'], + 'usage': { + 'users': {'total': users_count}, + 'localWorkouts': workouts_count, + }, + 'openRegistrations': current_app.config['is_registration_enabled'], + } + return HttpResponse( + response=response, content_type='application/json; charset=utf-8' + ) diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index d66d4b49f..0998c836d 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -12,7 +12,7 @@ ap_webfinger_blueprint = Blueprint('ap_webfinger', __name__) -@ap_webfinger_blueprint.route('/.well-known/webfinger', methods=['GET']) +@ap_webfinger_blueprint.route('/webfinger', methods=['GET']) @federation_required def webfinger() -> HttpResponse: resource = request.args.get('resource') diff --git a/fittrackee/tests/application/test_config.py b/fittrackee/tests/application/test_config.py index 7e43967d4..341bff75a 100644 --- a/fittrackee/tests/application/test_config.py +++ b/fittrackee/tests/application/test_config.py @@ -2,6 +2,8 @@ from flask import Flask +from fittrackee import VERSION + class TestDevelopmentConfig: def test_debug_is_enabled(self, app: Flask) -> None: @@ -23,6 +25,11 @@ def test_sqlalchemy_is_configured_to_use_dev_database( 'DATABASE_URL' ) + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.DevelopmentConfig') + + assert app.config['VERSION'] == VERSION + class TestTestingConfig: def test_debug_is_enabled(self, app: Flask) -> None: @@ -51,6 +58,11 @@ def test_it_does_not_preserve_context_on_exception( assert not app.config['PRESERVE_CONTEXT_ON_EXCEPTION'] + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.TestingConfig') + + assert app.config['VERSION'] == VERSION + class TestProductionConfig: def test_debug_is_disabled(self, app: Flask) -> None: @@ -78,3 +90,8 @@ def test_it_does_not_preserve_context_on_exception( app.config.from_object('fittrackee.config.ProductionConfig') assert not app.config['PRESERVE_CONTEXT_ON_EXCEPTION'] + + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.ProductionConfig') + + assert app.config['VERSION'] == VERSION diff --git a/fittrackee/tests/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/test_federation_nodeinfo.py new file mode 100644 index 000000000..94021af2a --- /dev/null +++ b/fittrackee/tests/federation/test_federation_nodeinfo.py @@ -0,0 +1,88 @@ +import json + +from flask import Flask + +from fittrackee import VERSION +from fittrackee.federation.models import Actor + + +class TestWellKnowNodeInfo: + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/.well-known/nodeinfo', + content_type='application/json', + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, federation is disabled for this instance' + in data['message'] + ) + + def test_it_returns_instance_nodeinfo_url_if_federation_is_enabled( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + response = client.get( + '/.well-known/nodeinfo', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + nodeinfo_url = ( + f'https://{app_with_federation.config["AP_DOMAIN"]}/nodeinfo/2.0' + ) + assert data == { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': nodeinfo_url, + } + ] + } + + +class TestNodeInfo: + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, actor_1: Actor + ) -> None: + client = app.test_client() + response = client.get( + '/nodeinfo/2.0', + content_type='application/json', + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, federation is disabled for this instance' + in data['message'] + ) + + def test_it_returns_instance_nodeinfo_if_federation_is_enabled( + self, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + client = app_with_federation.test_client() + response = client.get( + '/nodeinfo/2.0', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + 'version': '2.0', + 'software': {'name': 'fittrackee', 'version': VERSION}, + 'protocols': ['activitypub'], + 'usage': {'users': {'total': 1}, 'localWorkouts': 0}, + 'openRegistrations': True, + } From 093141a6eabf5015af3db3f993e58d3888a54289 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:24:18 +0100 Subject: [PATCH 005/238] API - move instances domains in dedicated table --- fittrackee/federation/decorators.py | 28 ++++++ fittrackee/federation/federation.py | 10 +-- fittrackee/federation/models.py | 50 +++++++++-- fittrackee/federation/nodeinfo.py | 9 +- fittrackee/federation/utils.py | 15 +--- fittrackee/federation/webfinger.py | 10 +-- .../23_8842c351a2d8_init_federation.py | 17 +++- .../federation/test_federation_federation.py | 4 +- .../federation/test_federation_models.py | 89 ++++++++++++++----- .../federation/test_federation_nodeinfo.py | 20 ++++- .../federation/test_federation_webfinger.py | 12 +-- fittrackee/tests/fixtures/fixtures_app.py | 10 ++- .../tests/fixtures/fixtures_federation.py | 19 +++- 13 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 fittrackee/federation/decorators.py diff --git a/fittrackee/federation/decorators.py b/fittrackee/federation/decorators.py new file mode 100644 index 000000000..ca2a3ca91 --- /dev/null +++ b/fittrackee/federation/decorators.py @@ -0,0 +1,28 @@ +from functools import wraps +from typing import Any, Callable + +from flask import current_app + +from fittrackee import appLog +from fittrackee.responses import ( + DisabledFederationErrorResponse, + InternalServerErrorResponse, +) + +from .models import Domain + + +def federation_required(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Callable: + if not current_app.config['federation_enabled']: + return DisabledFederationErrorResponse() + app_domain = Domain.query.filter_by( + name=current_app.config['AP_DOMAIN'] + ).first() + if not app_domain: + appLog.error('Local domain does not exist.') + return InternalServerErrorResponse() + return f(app_domain, *args, **kwargs) + + return decorated_function diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 87b4756ed..4de1de76b 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -1,9 +1,9 @@ -from flask import Blueprint, current_app +from flask import Blueprint from fittrackee.responses import HttpResponse, UserNotFoundErrorResponse -from .models import Actor -from .utils import federation_required +from .decorators import federation_required +from .models import Actor, Domain ap_federation_blueprint = Blueprint('ap_federation', __name__) @@ -12,10 +12,10 @@ '/user/', methods=['GET'] ) @federation_required -def get_actor(preferred_username: str) -> HttpResponse: +def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: actor = Actor.query.filter_by( preferred_username=preferred_username, - domain=current_app.config['AP_DOMAIN'], + domain_id=app_domain.id, ).first() if not actor: return UserNotFoundErrorResponse() diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 170e3984c..b6ec9638a 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -13,6 +13,40 @@ BaseModel: DeclarativeMeta = db.Model +class Domain(BaseModel): + """ActivityPub Domain""" + + __tablename__ = 'domains' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(1000), unique=True, nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + is_allowed = db.Column(db.Boolean, default=True, nullable=False) + + actors = db.relationship('Actor', back_populates='domain') + + def __str__(self) -> str: + return f'' + + def __init__( + self, name: str, created_at: Optional[datetime] = datetime.utcnow() + ) -> None: + self.name = name + self.created_at = created_at + + @property + def is_remote(self) -> bool: + return self.name != current_app.config['AP_DOMAIN'] + + def serialize(self) -> Dict: + return { + 'id': self.id, + 'name': self.name, + 'created_at': self.created_at, + 'is_remote': self.is_remote, + 'is_allowed': self.is_allowed, + } + + class Actor(BaseModel): """ActivityPub Actor""" @@ -22,10 +56,12 @@ class Actor(BaseModel): user_id = db.Column( db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False ) + domain_id = db.Column( + db.Integer, db.ForeignKey('domains.id'), nullable=False + ) type = db.Column( Enum(*ACTOR_TYPES, name='actor_types'), server_default='Person' ) - domain = db.Column(db.String(1000), nullable=False) name = db.Column(db.String(255), nullable=False) preferred_username = db.Column(db.String(255), nullable=False) public_key = db.Column(db.String(5000), nullable=True) @@ -35,29 +71,29 @@ class Actor(BaseModel): followers_url = db.Column(db.String(255), nullable=False) following_url = db.Column(db.String(255), nullable=False) shared_inbox_url = db.Column(db.String(255), nullable=False) - is_remote = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, nullable=False) manually_approves_followers = db.Column( db.Boolean, default=True, nullable=False ) last_fetch_date = db.Column(db.DateTime, nullable=True) + domain = db.relationship('Domain', back_populates='actors') + def __str__(self) -> str: return f'' def __init__( self, user: User, + domain_id: int, created_at: Optional[datetime] = datetime.utcnow(), - is_remote: Optional[bool] = False, ) -> None: self.ap_id = get_ap_url(user.username, 'user_url') self.created_at = created_at - self.domain = f"{current_app.config['AP_DOMAIN']}" + self.domain_id = domain_id self.followers_url = get_ap_url(user.username, 'followers') self.following_url = get_ap_url(user.username, 'following') self.inbox_url = get_ap_url(user.username, 'inbox') - self.is_remote = is_remote self.name = user.username self.outbox_url = get_ap_url(user.username, 'outbox') self.preferred_username = user.username @@ -67,6 +103,10 @@ def __init__( def generate_keys(self) -> None: self.public_key, self.private_key = generate_keys() + @property + def is_remote(self) -> bool: + return self.domain.is_remote + def serialize(self) -> Dict: return { '@context': AP_CTX, diff --git a/fittrackee/federation/nodeinfo.py b/fittrackee/federation/nodeinfo.py index 6ed69392e..90c075017 100644 --- a/fittrackee/federation/nodeinfo.py +++ b/fittrackee/federation/nodeinfo.py @@ -4,15 +4,16 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Workout -from .utils import federation_required +from .decorators import federation_required +from .models import Domain ap_nodeinfo_blueprint = Blueprint('ap_nodeinfo', __name__) @ap_nodeinfo_blueprint.route('/.well-known/nodeinfo', methods=['GET']) @federation_required -def get_nodeinfo_url() -> HttpResponse: - nodeinfo_url = f'https://{current_app.config["AP_DOMAIN"]}/nodeinfo/2.0' +def get_nodeinfo_url(app_domain: Domain) -> HttpResponse: + nodeinfo_url = f'https://{app_domain.name}/nodeinfo/2.0' response = { 'links': [ { @@ -28,7 +29,7 @@ def get_nodeinfo_url() -> HttpResponse: @ap_nodeinfo_blueprint.route('/nodeinfo/2.0', methods=['GET']) @federation_required -def get_nodeinfo() -> HttpResponse: +def get_nodeinfo(app_domain: Domain) -> HttpResponse: # TODO : add 'activeHalfyear' and 'activeMonth' for users workouts_count = Workout.query.filter().count() users_count = User.query.filter().count() diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 1de407edb..673302d7d 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,11 +1,8 @@ -from functools import wraps -from typing import Any, Callable, Tuple +from typing import Tuple from Crypto.PublicKey import RSA from flask import current_app -from fittrackee.responses import DisabledFederationErrorResponse - ACTOR_TYPES = ['Application', 'Group', 'Person'] AP_CTX = [ @@ -45,13 +42,3 @@ def get_ap_url(username: str, url_type: str) -> str: if url_type == 'shared_inbox': return f'{ap_url}inbox' raise Exception('Invalid \'url_type\'.') - - -def federation_required(f: Callable) -> Callable: - @wraps(f) - def decorated_function(*args: Any, **kwargs: Any) -> Callable: - if not current_app.config['federation_enabled']: - return DisabledFederationErrorResponse() - return f(*args, **kwargs) - - return decorated_function diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index 0998c836d..0a48d0a27 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -6,15 +6,15 @@ UserNotFoundErrorResponse, ) -from .models import Actor -from .utils import federation_required +from .decorators import federation_required +from .models import Actor, Domain ap_webfinger_blueprint = Blueprint('ap_webfinger', __name__) @ap_webfinger_blueprint.route('/webfinger', methods=['GET']) @federation_required -def webfinger() -> HttpResponse: +def webfinger(app_domain: Domain) -> HttpResponse: resource = request.args.get('resource') if not resource or not resource.startswith('acct:'): return InvalidPayloadErrorResponse('Missing resource in request args.') @@ -28,13 +28,13 @@ def webfinger() -> HttpResponse: return UserNotFoundErrorResponse() actor = Actor.query.filter_by( - preferred_username=preferred_username, domain=domain + preferred_username=preferred_username, domain_id=app_domain.id ).first() if not actor: return UserNotFoundErrorResponse() response = { - 'subject': f'acct:{actor.preferred_username}@{actor.domain}', + 'subject': f'acct:{actor.preferred_username}@{actor.domain.name}', 'links': [ { 'href': actor.ap_id, diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 9dd217e2e..6740e54ed 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -23,15 +23,24 @@ def upgrade(): 'federation_enabled', sa.Boolean(), nullable=True, default=False ), ) - op.execute('UPDATE app_config SET federation_enabled = true') + op.execute('UPDATE app_config SET federation_enabled = false') op.alter_column('app_config', 'federation_enabled', nullable=False) + op.create_table('domains', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=1000), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('is_allowed', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.create_table('actors', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('ap_id', sa.String(length=255), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('domain_id', sa.Integer(), nullable=False), sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), - sa.Column('domain', sa.String(length=1000), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('preferred_username', sa.String(length=255), nullable=False), sa.Column('public_key', sa.String(length=5000), nullable=True), @@ -41,11 +50,11 @@ def upgrade(): sa.Column('followers_url', sa.String(length=255), nullable=False), sa.Column('following_url', sa.String(length=255), nullable=False), sa.Column('shared_inbox_url', sa.String(length=255), nullable=False), - sa.Column('is_remote', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), sa.Column('last_fetch_date', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['domain_id'], ['domains.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('ap_id'), sa.UniqueConstraint('user_id') @@ -56,4 +65,6 @@ def downgrade(): op.drop_table('actors') op.execute('DROP TYPE actor_types') + op.drop_table('domains') + op.drop_column('app_config', 'federation_enabled') diff --git a/fittrackee/tests/federation/test_federation_federation.py b/fittrackee/tests/federation/test_federation_federation.py index f86986f93..6b840af3d 100644 --- a/fittrackee/tests/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/test_federation_federation.py @@ -43,11 +43,11 @@ def test_it_returns_actor( assert data == actor_1.serialize() def test_it_returns_error_if_federation_is_disabled( - self, app: Flask, actor_1: Actor + self, app: Flask, app_actor: Actor ) -> None: client = app.test_client() response = client.get( - f'/federation/user/{actor_1.preferred_username}', + f'/federation/user/{app_actor.preferred_username}', ) assert response.status_code == 403 diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index c1ee08e51..f3d39b91a 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -6,7 +6,7 @@ from Crypto.Signature import pkcs1_15 from flask import Flask -from fittrackee.federation.models import Actor +from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import AP_CTX, get_ap_url @@ -16,53 +16,98 @@ def test_it_raises_error_if_url_type_is_invalid(self, app: Flask) -> None: get_ap_url(username=uuid4().hex, url_type='url') +class TestActivityPubDomainModel: + def test_it_returns_string_representation( + self, app_with_federation: Flask + ) -> None: + local_domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + assert f"" == str( + local_domain + ) + + def test_app_domain_is_local(self, app_with_federation: Flask) -> None: + local_domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + assert not local_domain.is_remote + + def test_it_returns_serialized_object( + self, app_with_federation: Flask + ) -> None: + local_domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + serialized_domain = local_domain.serialize() + + assert serialized_domain['id'] + assert 'created_at' in serialized_domain + assert ( + serialized_domain['name'] + == app_with_federation.config['AP_DOMAIN'] + ) + assert serialized_domain['is_allowed'] + assert not serialized_domain['is_remote'] + + class TestActivityPubActorModel: - def test_actor_model(self, app: Flask, actor_1: Actor) -> None: + def test_it_returns_string_representation( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: assert '' == str(actor_1) - serialized_apactor = actor_1.serialize() - ap_url = app.config['AP_DOMAIN'] - assert serialized_apactor['@context'] == AP_CTX - assert serialized_apactor['id'] == actor_1.ap_id - assert serialized_apactor['type'] == 'Person' + def test_actor_is_local_is_local( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + assert not actor_1.is_remote + + def test_it_returns_serialized_object( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + serialized_actor = actor_1.serialize() + ap_url = app_with_federation.config['AP_DOMAIN'] + assert serialized_actor['@context'] == AP_CTX + assert serialized_actor['id'] == actor_1.ap_id + assert serialized_actor['type'] == 'Person' assert ( - serialized_apactor['preferredUsername'] - == actor_1.preferred_username + serialized_actor['preferredUsername'] == actor_1.preferred_username ) - assert serialized_apactor['name'] == actor_1.name + assert serialized_actor['name'] == actor_1.name assert ( - serialized_apactor['inbox'] + serialized_actor['inbox'] == f'{ap_url}/federation/user/{actor_1.name}/inbox' ) assert ( - serialized_apactor['inbox'] + serialized_actor['inbox'] == f'{ap_url}/federation/user/{actor_1.name}/inbox' ) assert ( - serialized_apactor['outbox'] + serialized_actor['outbox'] == f'{ap_url}/federation/user/{actor_1.name}/outbox' ) assert ( - serialized_apactor['followers'] + serialized_actor['followers'] == f'{ap_url}/federation/user/{actor_1.name}/followers' ) assert ( - serialized_apactor['following'] + serialized_actor['following'] == f'{ap_url}/federation/user/{actor_1.name}/following' ) - assert serialized_apactor['manuallyApprovesFollowers'] is True + assert serialized_actor['manuallyApprovesFollowers'] is True assert ( - serialized_apactor['publicKey']['id'] - == f'{actor_1.ap_id}#main-key' + serialized_actor['publicKey']['id'] == f'{actor_1.ap_id}#main-key' ) - assert serialized_apactor['publicKey']['owner'] == actor_1.ap_id - assert 'publicKeyPem' in serialized_apactor['publicKey'] + assert serialized_actor['publicKey']['owner'] == actor_1.ap_id + assert 'publicKeyPem' in serialized_actor['publicKey'] assert ( - serialized_apactor['endpoints']['sharedInbox'] + serialized_actor['endpoints']['sharedInbox'] == f'{ap_url}/federation/inbox' ) - def test_generated_key_is_valid(self, app: Flask, actor_1: Actor) -> None: + def test_generated_key_is_valid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: actor_1.generate_keys() signer = pkcs1_15.new(RSA.import_key(actor_1.private_key)) diff --git a/fittrackee/tests/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/test_federation_nodeinfo.py index 94021af2a..aeb48f845 100644 --- a/fittrackee/tests/federation/test_federation_nodeinfo.py +++ b/fittrackee/tests/federation/test_federation_nodeinfo.py @@ -8,7 +8,7 @@ class TestWellKnowNodeInfo: def test_it_returns_error_if_federation_is_disabled( - self, app: Flask, actor_1: Actor + self, app: Flask ) -> None: client = app.test_client() response = client.get( @@ -47,10 +47,26 @@ def test_it_returns_instance_nodeinfo_url_if_federation_is_enabled( ] } + def test_it_returns_error_if_domain_does_not_exist( + self, app_wo_domain: Flask + ) -> None: + client = app_wo_domain.test_client() + response = client.get( + '/.well-known/nodeinfo', + content_type='application/json', + ) + assert response.status_code == 500 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, please try again or contact the administrator' + in data['message'] + ) + class TestNodeInfo: def test_it_returns_error_if_federation_is_disabled( - self, app: Flask, actor_1: Actor + self, app: Flask ) -> None: client = app.test_client() response = client.get( diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/test_federation_webfinger.py index f1d1126dc..e9914dfa5 100644 --- a/fittrackee/tests/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/test_federation_webfinger.py @@ -85,7 +85,7 @@ def test_it_returns_json_resource_descriptor_as_content_type( client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain}', + f'{actor_1.preferred_username}@{actor_1.domain.name}', content_type='application/json', ) @@ -98,14 +98,14 @@ def test_it_returns_subject_with_user_data( client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain}', + f'{actor_1.preferred_username}@{actor_1.domain.name}', content_type='application/json', ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert ( - f'acct:{actor_1.preferred_username}@{actor_1.domain}' + f'acct:{actor_1.preferred_username}@{actor_1.domain.name}' in data['subject'] ) @@ -115,7 +115,7 @@ def test_it_returns_user_links( client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain}', + f'{actor_1.preferred_username}@{actor_1.domain.name}', content_type='application/json', ) @@ -128,12 +128,12 @@ def test_it_returns_user_links( } def test_it_returns_error_if_federation_is_disabled( - self, app: Flask, actor_1: Actor + self, app: Flask, app_actor: Actor ) -> None: client = app.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain}', + f'{app_actor.preferred_username}@{app_actor.domain.name}', content_type='application/json', ) diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index c4a21f9e7..405eede25 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -6,6 +6,7 @@ from fittrackee import create_app, db from fittrackee.application.models import AppConfig from fittrackee.application.utils import update_app_config_from_database +from fittrackee.federation.models import Domain def get_app_config( @@ -44,6 +45,7 @@ def get_app( max_zip_file_size: Optional[Union[int, float]] = None, max_users: Optional[int] = None, with_federation: Optional[bool] = False, + with_domain: Optional[bool] = True, ) -> Generator: app = create_app() with app.app_context(): @@ -59,6 +61,10 @@ def get_app( ) if app_db_config: update_app_config_from_database(app, app_db_config) + if with_domain: + domain = Domain(name=app.config['AP_DOMAIN']) + db.session.add(domain) + db.session.commit() yield app except Exception as e: print(f'Error with app configuration: {e}') @@ -152,7 +158,9 @@ def app_wo_email_auth(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture def app_wo_domain() -> Generator: - yield from get_app(with_config=True) + yield from get_app( + with_config=True, with_federation=True, with_domain=False + ) @pytest.fixture diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index e81b63493..94fa3e582 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -1,13 +1,26 @@ import pytest +from flask import Flask from fittrackee import db -from fittrackee.federation.models import Actor +from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import User @pytest.fixture() -def actor_1(user_1: User) -> Actor: - actor = Actor(user=user_1) +def actor_1(user_1: User, app_with_federation: Flask) -> Actor: + domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + actor = Actor(user=user_1, domain_id=domain.id) + db.session.add(actor) + db.session.commit() + return actor + + +@pytest.fixture() +def app_actor(user_1: User, app: Flask) -> Actor: + domain = Domain.query.filter_by(name=app.config['AP_DOMAIN']).first() + actor = Actor(user=user_1, domain_id=domain.id) db.session.add(actor) db.session.commit() return actor From 93c05c57cf83b9c15c56c664eaa8e122d63c0220 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:27:10 +0100 Subject: [PATCH 006/238] API - move BaseModel declaration --- fittrackee/__init__.py | 2 ++ fittrackee/application/models.py | 5 +---- fittrackee/federation/models.py | 5 +---- fittrackee/users/models.py | 5 +---- fittrackee/workouts/models.py | 4 +--- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index 1ad9cea60..eec1be648 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -17,11 +17,13 @@ from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from sqlalchemy.exc import ProgrammingError +from sqlalchemy.ext.declarative import DeclarativeMeta from fittrackee.emails.email import EmailService VERSION = __version__ = '0.5.7' db = SQLAlchemy() +BaseModel: DeclarativeMeta = db.Model bcrypt = Bcrypt() migrate = Migrate() email_service = EmailService() diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index 0a0a0a51d..b97fda882 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -4,15 +4,12 @@ from sqlalchemy import exc from sqlalchemy.engine.base import Connection from sqlalchemy.event import listens_for -from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.session import Session -from fittrackee import VERSION, db +from fittrackee import VERSION, BaseModel, db from fittrackee.users.models import User -BaseModel: DeclarativeMeta = db.Model - class AppConfig(BaseModel): __tablename__ = 'app_config' diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index b6ec9638a..f7dc46441 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -2,16 +2,13 @@ from typing import Dict, Optional from flask import current_app -from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.types import Enum -from fittrackee import db +from fittrackee import BaseModel, db from fittrackee.users.models import User from .utils import ACTOR_TYPES, AP_CTX, generate_keys, get_ap_url -BaseModel: DeclarativeMeta = db.Model - class Domain(BaseModel): """ActivityPub Domain""" diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index e1599dab0..e86cacba8 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -4,17 +4,14 @@ import jwt from flask import current_app from sqlalchemy import func -from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.sql.expression import select -from fittrackee import bcrypt, db +from fittrackee import BaseModel, bcrypt, db from fittrackee.workouts.models import Workout from .utils.token import decode_user_token, get_user_token -BaseModel: DeclarativeMeta = db.Model - class User(BaseModel): __tablename__ = 'users' diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index ac5e2273f..fd19c6d2f 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -6,19 +6,17 @@ from sqlalchemy.dialects import postgresql from sqlalchemy.engine.base import Connection from sqlalchemy.event import listens_for -from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.session import Session, object_session from sqlalchemy.types import JSON, Enum -from fittrackee import db +from fittrackee import BaseModel, db from fittrackee.files import get_absolute_file_path from .utils.convert import convert_in_duration, convert_value_to_integer from .utils.short_id import encode_uuid -BaseModel: DeclarativeMeta = db.Model record_types = [ 'AS', # 'Best Average Speed' 'FD', # 'Farthest Distance' From e888f276b380dd13a086be67aab92f3308ecd016 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:36:41 +0100 Subject: [PATCH 007/238] API - update relationship between User and Actor (wip) --- fittrackee/federation/models.py | 24 ++++++++----------- .../23_8842c351a2d8_init_federation.py | 11 ++++++--- .../tests/fixtures/fixtures_federation.py | 7 +++--- fittrackee/users/models.py | 5 ++++ 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index f7dc46441..f3c00f4c0 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -5,7 +5,6 @@ from sqlalchemy.types import Enum from fittrackee import BaseModel, db -from fittrackee.users.models import User from .utils import ACTOR_TYPES, AP_CTX, generate_keys, get_ap_url @@ -50,9 +49,6 @@ class Actor(BaseModel): __tablename__ = 'actors' id = db.Column(db.Integer, primary_key=True, autoincrement=True) ap_id = db.Column(db.String(255), unique=True, nullable=False) - user_id = db.Column( - db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False - ) domain_id = db.Column( db.Integer, db.ForeignKey('domains.id'), nullable=False ) @@ -75,27 +71,27 @@ class Actor(BaseModel): last_fetch_date = db.Column(db.DateTime, nullable=True) domain = db.relationship('Domain', back_populates='actors') + user = db.relationship('User', uselist=False, back_populates='actor') def __str__(self) -> str: return f'' def __init__( self, - user: User, + username: str, domain_id: int, created_at: Optional[datetime] = datetime.utcnow(), ) -> None: - self.ap_id = get_ap_url(user.username, 'user_url') + self.ap_id = get_ap_url(username, 'user_url') self.created_at = created_at self.domain_id = domain_id - self.followers_url = get_ap_url(user.username, 'followers') - self.following_url = get_ap_url(user.username, 'following') - self.inbox_url = get_ap_url(user.username, 'inbox') - self.name = user.username - self.outbox_url = get_ap_url(user.username, 'outbox') - self.preferred_username = user.username - self.shared_inbox_url = get_ap_url(user.username, 'shared_inbox') - self.user_id = user.id + self.followers_url = get_ap_url(username, 'followers') + self.following_url = get_ap_url(username, 'following') + self.inbox_url = get_ap_url(username, 'inbox') + self.name = username + self.outbox_url = get_ap_url(username, 'outbox') + self.preferred_username = username + self.shared_inbox_url = get_ap_url(username, 'shared_inbox') def generate_keys(self) -> None: self.public_key, self.private_key = generate_keys() diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 6740e54ed..697230d10 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -38,7 +38,6 @@ def upgrade(): op.create_table('actors', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('ap_id', sa.String(length=255), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), sa.Column('name', sa.String(length=255), nullable=False), @@ -53,15 +52,21 @@ def upgrade(): sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), sa.Column('last_fetch_date', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), sa.ForeignKeyConstraint(['domain_id'], ['domains.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('ap_id'), - sa.UniqueConstraint('user_id') ) + op.add_column('users', sa.Column('actor_id', sa.Integer(), nullable=True)) + op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) + op.create_foreign_key('users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id']) + def downgrade(): + op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') + op.drop_constraint('users_actor_id_key', 'users', type_='unique') + op.drop_column('users', 'actor_id') + op.drop_table('actors') op.execute('DROP TYPE actor_types') diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 94fa3e582..ffd769c79 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -11,16 +11,17 @@ def actor_1(user_1: User, app_with_federation: Flask) -> Actor: domain = Domain.query.filter_by( name=app_with_federation.config['AP_DOMAIN'] ).first() - actor = Actor(user=user_1, domain_id=domain.id) + actor = Actor(username=user_1.username, domain_id=domain.id) db.session.add(actor) + user_1.actor_id = actor.id db.session.commit() return actor @pytest.fixture() -def app_actor(user_1: User, app: Flask) -> Actor: +def app_actor(app: Flask) -> Actor: domain = Domain.query.filter_by(name=app.config['AP_DOMAIN']).first() - actor = Actor(user=user_1, domain_id=domain.id) + actor = Actor(username='test', domain_id=domain.id) db.session.add(actor) db.session.commit() return actor diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index e86cacba8..da55a9218 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -8,6 +8,7 @@ from sqlalchemy.sql.expression import select from fittrackee import BaseModel, bcrypt, db +from fittrackee.federation.models import Actor from fittrackee.workouts.models import Workout from .utils.token import decode_user_token, get_user_token @@ -16,6 +17,9 @@ class User(BaseModel): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True, autoincrement=True) + actor_id = db.Column( + db.Integer, db.ForeignKey('actors.id'), unique=True, nullable=True + ) username = db.Column(db.String(20), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password = db.Column(db.String(255), nullable=False) @@ -42,6 +46,7 @@ class User(BaseModel): ) language = db.Column(db.String(50), nullable=True) imperial_units = db.Column(db.Boolean, default=False, nullable=False) + actor = db.relationship(Actor, back_populates='user') def __repr__(self) -> str: return f'' From fb4fbf3f0911c9ae597298c0a57b1e4709b38fbd Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:42:27 +0100 Subject: [PATCH 008/238] API - init follow requests between actors (wip) --- fittrackee/federation/exceptions.py | 6 ++ fittrackee/federation/models.py | 77 +++++++++++++++ .../23_8842c351a2d8_init_federation.py | 13 +++ .../federation/test_federation_models.py | 97 ++++++++++++++++++- .../tests/fixtures/fixtures_federation.py | 50 +++++++++- 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 fittrackee/federation/exceptions.py diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py new file mode 100644 index 000000000..8cce59def --- /dev/null +++ b/fittrackee/federation/exceptions.py @@ -0,0 +1,6 @@ +class FollowRequestAlreadyProcessedError(Exception): + ... + + +class NotExistingFollowRequestError(Exception): + ... diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index f3c00f4c0..cfdeb2f10 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -6,6 +6,10 @@ from fittrackee import BaseModel, db +from .exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) from .utils import ACTOR_TYPES, AP_CTX, generate_keys, get_ap_url @@ -43,6 +47,27 @@ def serialize(self) -> Dict: } +class FollowRequest(BaseModel): + """Follow request between two actors""" + + __tablename__ = 'follow_requests' + follower_actor_id = db.Column( + db.Integer, + db.ForeignKey('actors.id'), + primary_key=True, + ) + followed_actor_id = db.Column( + db.Integer, + db.ForeignKey('actors.id'), + primary_key=True, + ) + is_approved = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow + ) + updated_at = db.Column(db.DateTime, nullable=True) + + class Actor(BaseModel): """ActivityPub Actor""" @@ -73,6 +98,19 @@ class Actor(BaseModel): domain = db.relationship('Domain', back_populates='actors') user = db.relationship('User', uselist=False, back_populates='actor') + received_follow_requests = db.relationship( + FollowRequest, + backref='to_actor', + primaryjoin=id == FollowRequest.followed_actor_id, + lazy='dynamic', + ) + sent_follow_requests = db.relationship( + FollowRequest, + backref='from_actor', + primaryjoin=id == FollowRequest.follower_actor_id, + lazy='dynamic', + ) + def __str__(self) -> str: return f'' @@ -100,6 +138,45 @@ def generate_keys(self) -> None: def is_remote(self) -> bool: return self.domain.is_remote + @property + def pending_follow_requests(self) -> FollowRequest: + return self.received_follow_requests.filter_by(updated_at=None).all() + + def send_follow_request_to(self, target: 'Actor') -> FollowRequest: + follow_request = FollowRequest( + follower_actor_id=self.id, followed_actor_id=target.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + def _processes_follow_request_from( + self, actor: 'Actor', approved: bool + ) -> FollowRequest: + follow_request = FollowRequest.query.filter_by( + follower_actor_id=actor.id, followed_actor_id=self.id + ).first() + if not follow_request: + raise NotExistingFollowRequestError() + if follow_request.updated_at is not None: + raise FollowRequestAlreadyProcessedError() + follow_request.is_approved = approved + follow_request.updated_at = datetime.now() + db.session.commit() + return follow_request + + def approves_follow_request_from(self, actor: 'Actor') -> FollowRequest: + follow_request = self._processes_follow_request_from( + actor=actor, approved=True + ) + return follow_request + + def refuses_follow_request_from(self, actor: 'Actor') -> FollowRequest: + follow_request = self._processes_follow_request_from( + actor=actor, approved=False + ) + return follow_request + def serialize(self) -> Dict: return { '@context': AP_CTX, diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 697230d10..018fe544e 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -61,8 +61,21 @@ def upgrade(): op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) op.create_foreign_key('users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id']) + op.create_table('follow_requests', + sa.Column('follower_actor_id', sa.Integer(), nullable=False), + sa.Column('followed_actor_id', sa.Integer(), nullable=False), + sa.Column('is_approved', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['followed_actor_id'], ['actors.id'], ), + sa.ForeignKeyConstraint(['follower_actor_id'], ['actors.id'], ), + sa.PrimaryKeyConstraint('follower_actor_id', 'followed_actor_id') + ) + def downgrade(): + op.drop_table('follow_requests') + op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') op.drop_constraint('users_actor_id_key', 'users', type_='unique') op.drop_column('users', 'actor_id') diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index f3d39b91a..379ec39d5 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import uuid4 import pytest @@ -6,7 +7,11 @@ from Crypto.Signature import pkcs1_15 from flask import Flask -from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) +from fittrackee.federation.models import Actor, Domain, FollowRequest from fittrackee.federation.utils import AP_CTX, get_ap_url @@ -114,3 +119,93 @@ def test_generated_key_is_valid( hashed_message = SHA256.new('test message'.encode()) # it raises ValueError if signature is invalid signer.verify(hashed_message, signer.sign(hashed_message)) + + +class TestActivityPubActorFollowingModel: + def test_actor_2_sends_follow_requests_to_actor_1( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + ) -> None: + follow_request = actor_2.send_follow_request_to(actor_1) + + assert follow_request in actor_2.sent_follow_requests.all() + + def test_actor_1_receives_follow_requests_from_actor_2( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + ) -> None: + follow_request = actor_2.send_follow_request_to(actor_1) + + assert follow_request in actor_1.received_follow_requests.all() + + def test_actor_has_pending_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + follow_request_from_actor_2_to_actor_1: FollowRequest, + ) -> None: + assert ( + follow_request_from_actor_2_to_actor_1 + in actor_1.pending_follow_requests + ) + + def test_actor_has_no_pending_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + follow_request_from_actor_2_to_actor_1: FollowRequest, + ) -> None: + follow_request_from_actor_2_to_actor_1.updated_at = datetime.now() + assert actor_1.pending_follow_requests == [] + + def test_actor_approves_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_actor_2_to_actor_1: FollowRequest, + follow_request_from_actor_3_to_actor_1: FollowRequest, + ) -> None: + follow_request = actor_1.approves_follow_request_from(actor_2) + + assert follow_request.is_approved + assert actor_1.pending_follow_requests == [ + follow_request_from_actor_3_to_actor_1 + ] + + def test_actor_refuses_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_actor_2_to_actor_1: FollowRequest, + ) -> None: + follow_request = actor_1.refuses_follow_request_from(actor_2) + + assert not follow_request.is_approved + assert actor_1.pending_follow_requests == [] + + def test_it_raises_error_if_follow_request_does_not_exists( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + ) -> None: + with pytest.raises(NotExistingFollowRequestError): + actor_1.approves_follow_request_from(actor_2) + + def test_it_raises_error_if_actor_approves_follow_request_already_processed( # noqa + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_actor_2_to_actor_1: FollowRequest, + ) -> None: + follow_request_from_actor_2_to_actor_1.updated_at = datetime.now() + + with pytest.raises(FollowRequestAlreadyProcessedError): + actor_1.approves_follow_request_from(actor_2) diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index ffd769c79..7faa4a791 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -2,7 +2,7 @@ from flask import Flask from fittrackee import db -from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.models import Actor, Domain, FollowRequest from fittrackee.users.models import User @@ -25,3 +25,51 @@ def app_actor(app: Flask) -> Actor: db.session.add(actor) db.session.commit() return actor + + +@pytest.fixture() +def actor_2(user_2: User, app_with_federation: Flask) -> Actor: + domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + actor = Actor(username=user_2.username, domain_id=domain.id) + db.session.add(actor) + user_2.actor_id = actor.id + db.session.commit() + return actor + + +@pytest.fixture() +def actor_3(user_3: User, app_with_federation: Flask) -> Actor: + domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + actor = Actor(username=user_3.username, domain_id=domain.id) + db.session.add(actor) + user_3.actor_id = actor.id + db.session.commit() + return actor + + +@pytest.fixture() +def follow_request_from_actor_2_to_actor_1( + actor_1: Actor, actor_2: Actor +) -> FollowRequest: + follow_request = FollowRequest( + followed_actor_id=actor_1.id, follower_actor_id=actor_2.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + +@pytest.fixture() +def follow_request_from_actor_3_to_actor_1( + actor_1: Actor, actor_3: Actor +) -> FollowRequest: + follow_request = FollowRequest( + followed_actor_id=actor_1.id, follower_actor_id=actor_3.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request From 9257b68857a3a111d29a4d425638d0567be6a886 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:45:18 +0100 Subject: [PATCH 009/238] API - Actor model update + refactor --- fittrackee/federation/constants.py | 4 +++ fittrackee/federation/enums.py | 7 ++++ fittrackee/federation/models.py | 21 ++++++++--- fittrackee/federation/utils.py | 7 ---- .../23_8842c351a2d8_init_federation.py | 4 +-- .../federation/test_federation_models.py | 36 ++++++++++++------- fittrackee/tests/fixtures/fixtures_app.py | 10 +++--- .../tests/fixtures/fixtures_federation.py | 21 ++++++----- 8 files changed, 70 insertions(+), 40 deletions(-) create mode 100644 fittrackee/federation/constants.py create mode 100644 fittrackee/federation/enums.py diff --git a/fittrackee/federation/constants.py b/fittrackee/federation/constants.py new file mode 100644 index 000000000..bf8b15e43 --- /dev/null +++ b/fittrackee/federation/constants.py @@ -0,0 +1,4 @@ +AP_CTX = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', +] diff --git a/fittrackee/federation/enums.py b/fittrackee/federation/enums.py new file mode 100644 index 000000000..eeb531ec8 --- /dev/null +++ b/fittrackee/federation/enums.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ActorType(Enum): + APPLICATION = 'Application' + GROUP = 'Group' + PERSON = 'Person' diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index cfdeb2f10..cf449c719 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -6,11 +6,13 @@ from fittrackee import BaseModel, db +from .constants import AP_CTX +from .enums import ActorType from .exceptions import ( FollowRequestAlreadyProcessedError, NotExistingFollowRequestError, ) -from .utils import ACTOR_TYPES, AP_CTX, generate_keys, get_ap_url +from .utils import generate_keys, get_ap_url class Domain(BaseModel): @@ -72,15 +74,19 @@ class Actor(BaseModel): """ActivityPub Actor""" __tablename__ = 'actors' + __table_args__ = ( + db.UniqueConstraint( + 'domain_id', 'preferred_username', name='domain_username_unique' + ), + ) id = db.Column(db.Integer, primary_key=True, autoincrement=True) ap_id = db.Column(db.String(255), unique=True, nullable=False) domain_id = db.Column( db.Integer, db.ForeignKey('domains.id'), nullable=False ) type = db.Column( - Enum(*ACTOR_TYPES, name='actor_types'), server_default='Person' + Enum(ActorType, name='actor_types'), server_default='PERSON' ) - name = db.Column(db.String(255), nullable=False) preferred_username = db.Column(db.String(255), nullable=False) public_key = db.Column(db.String(5000), nullable=True) private_key = db.Column(db.String(5000), nullable=True) @@ -126,7 +132,6 @@ def __init__( self.followers_url = get_ap_url(username, 'followers') self.following_url = get_ap_url(username, 'following') self.inbox_url = get_ap_url(username, 'inbox') - self.name = username self.outbox_url = get_ap_url(username, 'outbox') self.preferred_username = username self.shared_inbox_url = get_ap_url(username, 'shared_inbox') @@ -138,6 +143,12 @@ def generate_keys(self) -> None: def is_remote(self) -> bool: return self.domain.is_remote + @property + def name(self) -> Optional[str]: + if self.type == ActorType.PERSON and self.user: + return self.user.username + return None + @property def pending_follow_requests(self) -> FollowRequest: return self.received_follow_requests.filter_by(updated_at=None).all() @@ -181,7 +192,7 @@ def serialize(self) -> Dict: return { '@context': AP_CTX, 'id': self.ap_id, - 'type': self.type, + 'type': self.type.value, 'preferredUsername': self.preferred_username, 'name': self.name, 'inbox': self.inbox_url, diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 673302d7d..3d23cbddf 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -3,13 +3,6 @@ from Crypto.PublicKey import RSA from flask import current_app -ACTOR_TYPES = ['Application', 'Group', 'Person'] - -AP_CTX = [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', -] - def generate_keys() -> Tuple[str, str]: """ diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 018fe544e..5f3a11c6b 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -39,8 +39,7 @@ def upgrade(): sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('ap_id', sa.String(length=255), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), - sa.Column('type', sa.Enum('Application', 'Group', 'Person', name='actor_types'), server_default='Person', nullable=True), - sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('type', sa.Enum('APPLICATION', 'GROUP', 'PERSON', name='actor_types'), server_default='PERSON', nullable=True), sa.Column('preferred_username', sa.String(length=255), nullable=False), sa.Column('public_key', sa.String(length=5000), nullable=True), sa.Column('private_key', sa.String(length=5000), nullable=True), @@ -55,6 +54,7 @@ def upgrade(): sa.ForeignKeyConstraint(['domain_id'], ['domains.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('ap_id'), + sa.UniqueConstraint('domain_id', 'preferred_username', name='domain_username_unique'), ) op.add_column('users', sa.Column('actor_id', sa.Integer(), nullable=True)) diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index 379ec39d5..71f007436 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -7,12 +7,13 @@ from Crypto.Signature import pkcs1_15 from flask import Flask +from fittrackee.federation.constants import AP_CTX from fittrackee.federation.exceptions import ( FollowRequestAlreadyProcessedError, NotExistingFollowRequestError, ) from fittrackee.federation.models import Actor, Domain, FollowRequest -from fittrackee.federation.utils import AP_CTX, get_ap_url +from fittrackee.federation.utils import get_ap_url class TestGetApUrl: @@ -56,7 +57,7 @@ def test_it_returns_serialized_object( assert not serialized_domain['is_remote'] -class TestActivityPubActorModel: +class TestActivityPubPersonActorModel: def test_it_returns_string_representation( self, app_with_federation: Flask, actor_1: Actor ) -> None: @@ -78,26 +79,26 @@ def test_it_returns_serialized_object( assert ( serialized_actor['preferredUsername'] == actor_1.preferred_username ) - assert serialized_actor['name'] == actor_1.name + assert serialized_actor['name'] == actor_1.user.username assert ( serialized_actor['inbox'] - == f'{ap_url}/federation/user/{actor_1.name}/inbox' + == f'{ap_url}/federation/user/{actor_1.preferred_username}/inbox' ) assert ( serialized_actor['inbox'] - == f'{ap_url}/federation/user/{actor_1.name}/inbox' + == f'{ap_url}/federation/user/{actor_1.preferred_username}/inbox' ) assert ( serialized_actor['outbox'] - == f'{ap_url}/federation/user/{actor_1.name}/outbox' + == f'{ap_url}/federation/user/{actor_1.preferred_username}/outbox' ) - assert ( - serialized_actor['followers'] - == f'{ap_url}/federation/user/{actor_1.name}/followers' + assert serialized_actor['followers'] == ( + f'{ap_url}/federation/user/' + f'{actor_1.preferred_username}/followers' ) - assert ( - serialized_actor['following'] - == f'{ap_url}/federation/user/{actor_1.name}/following' + assert serialized_actor['following'] == ( + f'{ap_url}/federation/user/' + f'{actor_1.preferred_username}/following' ) assert serialized_actor['manuallyApprovesFollowers'] is True assert ( @@ -209,3 +210,14 @@ def test_it_raises_error_if_actor_approves_follow_request_already_processed( # with pytest.raises(FollowRequestAlreadyProcessedError): actor_1.approves_follow_request_from(actor_2) + + +class TestActivityPubActorModel: + def test_it_returns_actor_empty_name( + self, app_with_federation: Flask + ) -> None: + domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + actor = Actor(username=uuid4().hex, domain_id=domain.id) + assert actor.name is None diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index 405eede25..2429955d7 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -156,6 +156,11 @@ def app_wo_email_auth(monkeypatch: pytest.MonkeyPatch) -> Generator: yield from get_app(with_config=True) +@pytest.fixture +def app_with_federation() -> Generator: + yield from get_app(with_config=True, with_federation=True) + + @pytest.fixture def app_wo_domain() -> Generator: yield from get_app( @@ -163,11 +168,6 @@ def app_wo_domain() -> Generator: ) -@pytest.fixture -def app_with_federation() -> Generator: - yield from get_app(with_config=True, with_federation=True) - - @pytest.fixture() def app_config() -> AppConfig: config = AppConfig() diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 7faa4a791..d80713957 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -7,22 +7,23 @@ @pytest.fixture() -def actor_1(user_1: User, app_with_federation: Flask) -> Actor: - domain = Domain.query.filter_by( - name=app_with_federation.config['AP_DOMAIN'] - ).first() - actor = Actor(username=user_1.username, domain_id=domain.id) +def app_actor(app: Flask) -> Actor: + domain = Domain.query.filter_by(name=app.config['AP_DOMAIN']).first() + actor = Actor(username='test', domain_id=domain.id) db.session.add(actor) - user_1.actor_id = actor.id db.session.commit() return actor @pytest.fixture() -def app_actor(app: Flask) -> Actor: - domain = Domain.query.filter_by(name=app.config['AP_DOMAIN']).first() - actor = Actor(username='test', domain_id=domain.id) +def actor_1(user_1: User, app_with_federation: Flask) -> Actor: + domain = Domain.query.filter_by( + name=app_with_federation.config['AP_DOMAIN'] + ).first() + actor = Actor(username=user_1.username, domain_id=domain.id) db.session.add(actor) + db.session.flush() + user_1.actor_id = actor.id db.session.commit() return actor @@ -34,6 +35,7 @@ def actor_2(user_2: User, app_with_federation: Flask) -> Actor: ).first() actor = Actor(username=user_2.username, domain_id=domain.id) db.session.add(actor) + db.session.flush() user_2.actor_id = actor.id db.session.commit() return actor @@ -47,6 +49,7 @@ def actor_3(user_3: User, app_with_federation: Flask) -> Actor: actor = Actor(username=user_3.username, domain_id=domain.id) db.session.add(actor) user_3.actor_id = actor.id + db.session.flush() db.session.commit() return actor From 909072f18e82e56071fd9fc8a3f1aca91282b7a4 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 3 Feb 2021 17:42:21 +0100 Subject: [PATCH 010/238] API - rename ActivityPub id column --- fittrackee/federation/models.py | 10 +++++----- fittrackee/federation/webfinger.py | 2 +- .../versions/23_8842c351a2d8_init_federation.py | 4 ++-- fittrackee/tests/federation/test_federation_models.py | 7 ++++--- .../tests/federation/test_federation_webfinger.py | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index cf449c719..0f716ba2b 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -80,7 +80,7 @@ class Actor(BaseModel): ), ) id = db.Column(db.Integer, primary_key=True, autoincrement=True) - ap_id = db.Column(db.String(255), unique=True, nullable=False) + activitypub_id = db.Column(db.String(255), unique=True, nullable=False) domain_id = db.Column( db.Integer, db.ForeignKey('domains.id'), nullable=False ) @@ -126,7 +126,7 @@ def __init__( domain_id: int, created_at: Optional[datetime] = datetime.utcnow(), ) -> None: - self.ap_id = get_ap_url(username, 'user_url') + self.activitypub_id = get_ap_url(username, 'user_url') self.created_at = created_at self.domain_id = domain_id self.followers_url = get_ap_url(username, 'followers') @@ -191,7 +191,7 @@ def refuses_follow_request_from(self, actor: 'Actor') -> FollowRequest: def serialize(self) -> Dict: return { '@context': AP_CTX, - 'id': self.ap_id, + 'id': self.activitypub_id, 'type': self.type.value, 'preferredUsername': self.preferred_username, 'name': self.name, @@ -201,8 +201,8 @@ def serialize(self) -> Dict: 'following': self.following_url, 'manuallyApprovesFollowers': self.manually_approves_followers, 'publicKey': { - 'id': f'{self.ap_id}#main-key', - 'owner': self.ap_id, + 'id': f'{self.activitypub_id}#main-key', + 'owner': self.activitypub_id, 'publicKeyPem': self.public_key, }, 'endpoints': {'sharedInbox': self.shared_inbox_url}, diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index 0a48d0a27..99789ae3e 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -37,7 +37,7 @@ def webfinger(app_domain: Domain) -> HttpResponse: 'subject': f'acct:{actor.preferred_username}@{actor.domain.name}', 'links': [ { - 'href': actor.ap_id, + 'href': actor.activitypub_id, 'rel': 'self', 'type': 'application/activity+json', } diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 5f3a11c6b..0566eb6f6 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -37,7 +37,7 @@ def upgrade(): op.create_table('actors', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('ap_id', sa.String(length=255), nullable=False), + sa.Column('activitypub_id', sa.String(length=255), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), sa.Column('type', sa.Enum('APPLICATION', 'GROUP', 'PERSON', name='actor_types'), server_default='PERSON', nullable=True), sa.Column('preferred_username', sa.String(length=255), nullable=False), @@ -53,7 +53,7 @@ def upgrade(): sa.Column('last_fetch_date', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['domain_id'], ['domains.id'], ), sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('ap_id'), + sa.UniqueConstraint('activitypub_id'), sa.UniqueConstraint('domain_id', 'preferred_username', name='domain_username_unique'), ) diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index 71f007436..2c45b9b40 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -74,7 +74,7 @@ def test_it_returns_serialized_object( serialized_actor = actor_1.serialize() ap_url = app_with_federation.config['AP_DOMAIN'] assert serialized_actor['@context'] == AP_CTX - assert serialized_actor['id'] == actor_1.ap_id + assert serialized_actor['id'] == actor_1.activitypub_id assert serialized_actor['type'] == 'Person' assert ( serialized_actor['preferredUsername'] == actor_1.preferred_username @@ -102,9 +102,10 @@ def test_it_returns_serialized_object( ) assert serialized_actor['manuallyApprovesFollowers'] is True assert ( - serialized_actor['publicKey']['id'] == f'{actor_1.ap_id}#main-key' + serialized_actor['publicKey']['id'] + == f'{actor_1.activitypub_id}#main-key' ) - assert serialized_actor['publicKey']['owner'] == actor_1.ap_id + assert serialized_actor['publicKey']['owner'] == actor_1.activitypub_id assert 'publicKeyPem' in serialized_actor['publicKey'] assert ( serialized_actor['endpoints']['sharedInbox'] diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/test_federation_webfinger.py index e9914dfa5..b897ad7b1 100644 --- a/fittrackee/tests/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/test_federation_webfinger.py @@ -122,7 +122,7 @@ def test_it_returns_user_links( assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['links'][0] == { - 'href': actor_1.ap_id, + 'href': actor_1.activitypub_id, 'rel': 'self', 'type': 'application/activity+json', } From 39c3ce6e79931f4ee38f42d5be9ba8e720a0fdc0 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 11:53:01 +0100 Subject: [PATCH 011/238] API - add remote user --- fittrackee/federation/models.py | 23 +++++--- .../federation/test_federation_models.py | 54 ++++++++++++++++++- .../tests/fixtures/fixtures_federation.py | 43 +++++++++++++++ fittrackee/tests/utils.py | 4 ++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 0f716ba2b..291f6d8b4 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -125,16 +125,27 @@ def __init__( username: str, domain_id: int, created_at: Optional[datetime] = datetime.utcnow(), + remote_user_data: Optional[Dict] = None, ) -> None: - self.activitypub_id = get_ap_url(username, 'user_url') self.created_at = created_at self.domain_id = domain_id - self.followers_url = get_ap_url(username, 'followers') - self.following_url = get_ap_url(username, 'following') - self.inbox_url = get_ap_url(username, 'inbox') - self.outbox_url = get_ap_url(username, 'outbox') self.preferred_username = username - self.shared_inbox_url = get_ap_url(username, 'shared_inbox') + if remote_user_data: + self.activitypub_id = remote_user_data['id'] + self.followers_url = remote_user_data['followers'] + self.following_url = remote_user_data['following'] + self.inbox_url = remote_user_data['inbox'] + self.outbox_url = remote_user_data['outbox'] + self.shared_inbox_url = remote_user_data.get('endpoints', {}).get( + 'sharedInbox' + ) + else: + self.activitypub_id = get_ap_url(username, 'user_url') + self.followers_url = get_ap_url(username, 'followers') + self.following_url = get_ap_url(username, 'following') + self.inbox_url = get_ap_url(username, 'inbox') + self.outbox_url = get_ap_url(username, 'outbox') + self.shared_inbox_url = get_ap_url(username, 'shared_inbox') def generate_keys(self) -> None: self.public_key, self.private_key = generate_keys() diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index 2c45b9b40..fb573d9b8 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -39,6 +39,11 @@ def test_app_domain_is_local(self, app_with_federation: Flask) -> None: ).first() assert not local_domain.is_remote + def test_domain_is_remote( + self, app_with_federation: Flask, remote_domain: Domain + ) -> None: + assert remote_domain.is_remote + def test_it_returns_serialized_object( self, app_with_federation: Flask ) -> None: @@ -57,13 +62,13 @@ def test_it_returns_serialized_object( assert not serialized_domain['is_remote'] -class TestActivityPubPersonActorModel: +class TestActivityPubLocalPersonActorModel: def test_it_returns_string_representation( self, app_with_federation: Flask, actor_1: Actor ) -> None: assert '' == str(actor_1) - def test_actor_is_local_is_local( + def test_actor_is_local( self, app_with_federation: Flask, actor_1: Actor ) -> None: assert not actor_1.is_remote @@ -123,6 +128,51 @@ def test_generated_key_is_valid( signer.verify(hashed_message, signer.sign(hashed_message)) +class TestActivityPubRemotePersonActorModel: + def test_actor_is_remote( + self, app_with_federation: Flask, remote_actor: Actor + ) -> None: + assert remote_actor.is_remote + + def test_it_returns_serialized_object( + self, + app_with_federation: Flask, + remote_actor: Actor, + remote_domain: Domain, + ) -> None: + serialized_actor = remote_actor.serialize() + ap_url = remote_domain.name + user_url = ( + f'{remote_domain.name}/users/{remote_actor.preferred_username}' + ) + assert serialized_actor['@context'] == AP_CTX + assert serialized_actor['id'] == remote_actor.activitypub_id + assert serialized_actor['type'] == 'Person' + assert ( + serialized_actor['preferredUsername'] + == remote_actor.preferred_username + ) + assert serialized_actor['name'] == remote_actor.user.username + assert serialized_actor['inbox'] == f'{user_url}/inbox' + assert serialized_actor['inbox'] == f'{user_url}/inbox' + assert serialized_actor['outbox'] == f'{user_url}/outbox' + assert serialized_actor['followers'] == f'{user_url}/followers' + assert serialized_actor['following'] == f'{user_url}/following' + assert serialized_actor['manuallyApprovesFollowers'] is True + assert ( + serialized_actor['publicKey']['id'] + == f'{remote_actor.activitypub_id}#main-key' + ) + assert ( + serialized_actor['publicKey']['owner'] + == remote_actor.activitypub_id + ) + assert 'publicKeyPem' in serialized_actor['publicKey'] + assert ( + serialized_actor['endpoints']['sharedInbox'] == f'{ap_url}/inbox' + ) + + class TestActivityPubActorFollowingModel: def test_actor_2_sends_follow_requests_to_actor_1( self, diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index d80713957..d198ba347 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -5,6 +5,8 @@ from fittrackee.federation.models import Actor, Domain, FollowRequest from fittrackee.users.models import User +from ..utils import random_domain + @pytest.fixture() def app_actor(app: Flask) -> Actor: @@ -76,3 +78,44 @@ def follow_request_from_actor_3_to_actor_1( db.session.add(follow_request) db.session.commit() return follow_request + + +@pytest.fixture() +def remote_domain(app_with_federation: Flask) -> Domain: + remote_domain = Domain(name=random_domain()) + db.session.add(remote_domain) + db.session.commit() + return remote_domain + + +@pytest.fixture() +def remote_actor( + user_2: User, app_with_federation: Flask, remote_domain: Domain +) -> Actor: + user_name = user_2.username.capitalize() + user_url = f'{remote_domain.name}/users/{user_2.username}' + actor = Actor( + username=user_2.username, + domain_id=remote_domain.id, + remote_user_data={ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id': user_url, + 'type': 'Person', + 'following': f'{user_url}/following', + 'followers': f'{user_url}/followers', + 'inbox': f'{user_url}/inbox', + 'outbox': f'{user_url}/outbox', + 'name': user_name, + 'preferredUsername': user_2.username, + 'endpoints': {'sharedInbox': f'{remote_domain.name}/inbox'}, + }, + ) + db.session.add(actor) + db.session.flush() + user_2.name = user_name + user_2.actor_id = actor.id + db.session.commit() + return actor diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index ac0989f09..1948e37ff 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -10,3 +10,7 @@ def random_string(length: Optional[int] = None) -> str: random.choice(string.ascii_letters + string.digits) for _ in range(length) ) + + +def random_domain() -> str: + return f'https://{random_string()}.social' From 05d10153d9d887e02b760c276d3159b1577083fb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 3 Feb 2021 20:17:28 +0100 Subject: [PATCH 012/238] API - init domain and actors creation in migration --- .../23_8842c351a2d8_init_federation.py | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 0566eb6f6..fe72a5ce3 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -5,8 +5,13 @@ Create Date: 2021-01-10 16:02:43.811023 """ -from alembic import op +import os +from datetime import datetime + import sqlalchemy as sa +from alembic import op + +from fittrackee.federation.utils import get_ap_url # revision identifiers, used by Alembic. @@ -26,7 +31,7 @@ def upgrade(): op.execute('UPDATE app_config SET federation_enabled = false') op.alter_column('app_config', 'federation_enabled', nullable=False) - op.create_table('domains', + domain_table = op.create_table('domains', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=1000), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), @@ -35,7 +40,14 @@ def upgrade(): sa.UniqueConstraint('name'), ) - op.create_table('actors', + domain = os.environ['UI_URL'] + created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + op.execute( + "INSERT INTO domains (name, created_at, is_allowed)" + f"VALUES ('{domain}', '{created_at}'::timestamp, True)" + ) + + actors_table = op.create_table('actors', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('activitypub_id', sa.String(length=255), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), @@ -61,6 +73,39 @@ def upgrade(): op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) op.create_foreign_key('users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id']) + # create local actors + user_helper = sa.Table( + 'users', + sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=20), nullable=False), + ) + connection = op.get_bind() + domain = connection.execute(domain_table.select()).fetchone() + for user in connection.execute(user_helper.select()): + created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + op.execute( + "INSERT INTO actors (" + "activitypub_id, domain_id, preferred_username, followers_url, " + "following_url, inbox_url, outbox_url, shared_inbox_url, " + "created_at, manually_approves_followers) " + "VALUES (" + f"'{get_ap_url(user.username, 'user_url')}', " + f"{domain.id}, '{user.username}', " + f"'{get_ap_url(user.username, 'followers')}', " + f"'{get_ap_url(user.username, 'following')}', " + f"'{get_ap_url(user.username, 'inbox')}', " + f"'{get_ap_url(user.username, 'outbox')}', " + f"'{get_ap_url(user.username, 'shared_inbox')}', " + f"'{created_at}'::timestamp, {True}) RETURNING id" + ) + actor = connection.execute(actors_table.select().where( + actors_table.c.preferred_username == user.username)).fetchone() + op.execute( + f'UPDATE users SET actor_id = {actor.id} WHERE users.id = {user.id}' + ) + + op.create_table('follow_requests', sa.Column('follower_actor_id', sa.Integer(), nullable=False), sa.Column('followed_actor_id', sa.Integer(), nullable=False), From 2c8f806e9e78faa071948e2e53ac36130847c64a Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 3 Feb 2021 20:33:16 +0100 Subject: [PATCH 013/238] API - migration lint fix --- .../23_8842c351a2d8_init_federation.py | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index fe72a5ce3..498c4af7c 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -31,7 +31,8 @@ def upgrade(): op.execute('UPDATE app_config SET federation_enabled = false') op.alter_column('app_config', 'federation_enabled', nullable=False) - domain_table = op.create_table('domains', + domain_table = op.create_table( + 'domains', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=1000), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), @@ -47,11 +48,17 @@ def upgrade(): f"VALUES ('{domain}', '{created_at}'::timestamp, True)" ) - actors_table = op.create_table('actors', + actors_table = op.create_table( + 'actors', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('activitypub_id', sa.String(length=255), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), - sa.Column('type', sa.Enum('APPLICATION', 'GROUP', 'PERSON', name='actor_types'), server_default='PERSON', nullable=True), + sa.Column( + 'type', + sa.Enum('APPLICATION', 'GROUP', 'PERSON', name='actor_types'), + server_default='PERSON', + nullable=True, + ), sa.Column('preferred_username', sa.String(length=255), nullable=False), sa.Column('public_key', sa.String(length=5000), nullable=True), sa.Column('private_key', sa.String(length=5000), nullable=True), @@ -63,15 +70,22 @@ def upgrade(): sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), sa.Column('last_fetch_date', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['domain_id'], ['domains.id'], ), + sa.ForeignKeyConstraint( + ['domain_id'], + ['domains.id'], + ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('activitypub_id'), - sa.UniqueConstraint('domain_id', 'preferred_username', name='domain_username_unique'), + sa.UniqueConstraint( + 'domain_id', 'preferred_username', name='domain_username_unique' + ), ) op.add_column('users', sa.Column('actor_id', sa.Integer(), nullable=True)) op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) - op.create_foreign_key('users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id']) + op.create_foreign_key( + 'users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id'] + ) # create local actors user_helper = sa.Table( @@ -99,22 +113,31 @@ def upgrade(): f"'{get_ap_url(user.username, 'shared_inbox')}', " f"'{created_at}'::timestamp, {True}) RETURNING id" ) - actor = connection.execute(actors_table.select().where( - actors_table.c.preferred_username == user.username)).fetchone() + actor = connection.execute( + actors_table.select().where( + actors_table.c.preferred_username == user.username + ) + ).fetchone() op.execute( f'UPDATE users SET actor_id = {actor.id} WHERE users.id = {user.id}' ) - - op.create_table('follow_requests', + op.create_table( + 'follow_requests', sa.Column('follower_actor_id', sa.Integer(), nullable=False), sa.Column('followed_actor_id', sa.Integer(), nullable=False), sa.Column('is_approved', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['followed_actor_id'], ['actors.id'], ), - sa.ForeignKeyConstraint(['follower_actor_id'], ['actors.id'], ), - sa.PrimaryKeyConstraint('follower_actor_id', 'followed_actor_id') + sa.ForeignKeyConstraint( + ['followed_actor_id'], + ['actors.id'], + ), + sa.ForeignKeyConstraint( + ['follower_actor_id'], + ['actors.id'], + ), + sa.PrimaryKeyConstraint('follower_actor_id', 'followed_actor_id'), ) From d577c3b73b97d32a4b14ab4610375011972b1407 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 12:00:44 +0100 Subject: [PATCH 014/238] API - move follow request in user since federation can be disabled (wip) --- fittrackee/federation/exceptions.py | 6 -- fittrackee/federation/models.py | 77 --------------- .../23_8842c351a2d8_init_federation.py | 14 +-- .../federation/test_federation_models.py | 97 +----------------- .../tests/fixtures/fixtures_federation.py | 26 +---- fittrackee/tests/fixtures/fixtures_users.py | 26 ++++- fittrackee/tests/users/test_users_model.py | 99 ++++++++++++++++++- fittrackee/users/exceptions.py | 8 ++ fittrackee/users/models.py | 80 +++++++++++++++ 9 files changed, 220 insertions(+), 213 deletions(-) delete mode 100644 fittrackee/federation/exceptions.py diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py deleted file mode 100644 index 8cce59def..000000000 --- a/fittrackee/federation/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class FollowRequestAlreadyProcessedError(Exception): - ... - - -class NotExistingFollowRequestError(Exception): - ... diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 291f6d8b4..fd858f9cd 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -8,10 +8,6 @@ from .constants import AP_CTX from .enums import ActorType -from .exceptions import ( - FollowRequestAlreadyProcessedError, - NotExistingFollowRequestError, -) from .utils import generate_keys, get_ap_url @@ -49,27 +45,6 @@ def serialize(self) -> Dict: } -class FollowRequest(BaseModel): - """Follow request between two actors""" - - __tablename__ = 'follow_requests' - follower_actor_id = db.Column( - db.Integer, - db.ForeignKey('actors.id'), - primary_key=True, - ) - followed_actor_id = db.Column( - db.Integer, - db.ForeignKey('actors.id'), - primary_key=True, - ) - is_approved = db.Column(db.Boolean, default=False, nullable=False) - created_at = db.Column( - db.DateTime, nullable=False, default=datetime.utcnow - ) - updated_at = db.Column(db.DateTime, nullable=True) - - class Actor(BaseModel): """ActivityPub Actor""" @@ -104,19 +79,6 @@ class Actor(BaseModel): domain = db.relationship('Domain', back_populates='actors') user = db.relationship('User', uselist=False, back_populates='actor') - received_follow_requests = db.relationship( - FollowRequest, - backref='to_actor', - primaryjoin=id == FollowRequest.followed_actor_id, - lazy='dynamic', - ) - sent_follow_requests = db.relationship( - FollowRequest, - backref='from_actor', - primaryjoin=id == FollowRequest.follower_actor_id, - lazy='dynamic', - ) - def __str__(self) -> str: return f'' @@ -160,45 +122,6 @@ def name(self) -> Optional[str]: return self.user.username return None - @property - def pending_follow_requests(self) -> FollowRequest: - return self.received_follow_requests.filter_by(updated_at=None).all() - - def send_follow_request_to(self, target: 'Actor') -> FollowRequest: - follow_request = FollowRequest( - follower_actor_id=self.id, followed_actor_id=target.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - def _processes_follow_request_from( - self, actor: 'Actor', approved: bool - ) -> FollowRequest: - follow_request = FollowRequest.query.filter_by( - follower_actor_id=actor.id, followed_actor_id=self.id - ).first() - if not follow_request: - raise NotExistingFollowRequestError() - if follow_request.updated_at is not None: - raise FollowRequestAlreadyProcessedError() - follow_request.is_approved = approved - follow_request.updated_at = datetime.now() - db.session.commit() - return follow_request - - def approves_follow_request_from(self, actor: 'Actor') -> FollowRequest: - follow_request = self._processes_follow_request_from( - actor=actor, approved=True - ) - return follow_request - - def refuses_follow_request_from(self, actor: 'Actor') -> FollowRequest: - follow_request = self._processes_follow_request_from( - actor=actor, approved=False - ) - return follow_request - def serialize(self) -> Dict: return { '@context': AP_CTX, diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 498c4af7c..e254f1256 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -124,20 +124,20 @@ def upgrade(): op.create_table( 'follow_requests', - sa.Column('follower_actor_id', sa.Integer(), nullable=False), - sa.Column('followed_actor_id', sa.Integer(), nullable=False), + sa.Column('follower_user_id', sa.Integer(), nullable=False), + sa.Column('followed_user_id', sa.Integer(), nullable=False), sa.Column('is_approved', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint( - ['followed_actor_id'], - ['actors.id'], + ['followed_user_id'], + ['users.id'], ), sa.ForeignKeyConstraint( - ['follower_actor_id'], - ['actors.id'], + ['follower_user_id'], + ['users.id'], ), - sa.PrimaryKeyConstraint('follower_actor_id', 'followed_actor_id'), + sa.PrimaryKeyConstraint('follower_user_id', 'followed_user_id'), ) diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/test_federation_models.py index fb573d9b8..23da42bd2 100644 --- a/fittrackee/tests/federation/test_federation_models.py +++ b/fittrackee/tests/federation/test_federation_models.py @@ -1,4 +1,3 @@ -from datetime import datetime from uuid import uuid4 import pytest @@ -8,11 +7,7 @@ from flask import Flask from fittrackee.federation.constants import AP_CTX -from fittrackee.federation.exceptions import ( - FollowRequestAlreadyProcessedError, - NotExistingFollowRequestError, -) -from fittrackee.federation.models import Actor, Domain, FollowRequest +from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import get_ap_url @@ -173,96 +168,6 @@ def test_it_returns_serialized_object( ) -class TestActivityPubActorFollowingModel: - def test_actor_2_sends_follow_requests_to_actor_1( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - ) -> None: - follow_request = actor_2.send_follow_request_to(actor_1) - - assert follow_request in actor_2.sent_follow_requests.all() - - def test_actor_1_receives_follow_requests_from_actor_2( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - ) -> None: - follow_request = actor_2.send_follow_request_to(actor_1) - - assert follow_request in actor_1.received_follow_requests.all() - - def test_actor_has_pending_follow_request( - self, - app_with_federation: Flask, - actor_1: Actor, - follow_request_from_actor_2_to_actor_1: FollowRequest, - ) -> None: - assert ( - follow_request_from_actor_2_to_actor_1 - in actor_1.pending_follow_requests - ) - - def test_actor_has_no_pending_follow_request( - self, - app_with_federation: Flask, - actor_1: Actor, - follow_request_from_actor_2_to_actor_1: FollowRequest, - ) -> None: - follow_request_from_actor_2_to_actor_1.updated_at = datetime.now() - assert actor_1.pending_follow_requests == [] - - def test_actor_approves_follow_request( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_actor_2_to_actor_1: FollowRequest, - follow_request_from_actor_3_to_actor_1: FollowRequest, - ) -> None: - follow_request = actor_1.approves_follow_request_from(actor_2) - - assert follow_request.is_approved - assert actor_1.pending_follow_requests == [ - follow_request_from_actor_3_to_actor_1 - ] - - def test_actor_refuses_follow_request( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_actor_2_to_actor_1: FollowRequest, - ) -> None: - follow_request = actor_1.refuses_follow_request_from(actor_2) - - assert not follow_request.is_approved - assert actor_1.pending_follow_requests == [] - - def test_it_raises_error_if_follow_request_does_not_exists( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - ) -> None: - with pytest.raises(NotExistingFollowRequestError): - actor_1.approves_follow_request_from(actor_2) - - def test_it_raises_error_if_actor_approves_follow_request_already_processed( # noqa - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_actor_2_to_actor_1: FollowRequest, - ) -> None: - follow_request_from_actor_2_to_actor_1.updated_at = datetime.now() - - with pytest.raises(FollowRequestAlreadyProcessedError): - actor_1.approves_follow_request_from(actor_2) - - class TestActivityPubActorModel: def test_it_returns_actor_empty_name( self, app_with_federation: Flask diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index d198ba347..c2dccd064 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -2,7 +2,7 @@ from flask import Flask from fittrackee import db -from fittrackee.federation.models import Actor, Domain, FollowRequest +from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import User from ..utils import random_domain @@ -56,30 +56,6 @@ def actor_3(user_3: User, app_with_federation: Flask) -> Actor: return actor -@pytest.fixture() -def follow_request_from_actor_2_to_actor_1( - actor_1: Actor, actor_2: Actor -) -> FollowRequest: - follow_request = FollowRequest( - followed_actor_id=actor_1.id, follower_actor_id=actor_2.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - -@pytest.fixture() -def follow_request_from_actor_3_to_actor_1( - actor_1: Actor, actor_3: Actor -) -> FollowRequest: - follow_request = FollowRequest( - followed_actor_id=actor_1.id, follower_actor_id=actor_3.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - @pytest.fixture() def remote_domain(app_with_federation: Flask) -> Domain: remote_domain = Domain(name=random_domain()) diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index d1f7ce15b..6e238a8fe 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -3,7 +3,7 @@ import pytest from fittrackee import db -from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.models import FollowRequest, User, UserSportPreference from fittrackee.workouts.models import Sport @@ -110,3 +110,27 @@ def user_admin_sport_1_preference( db.session.add(user_sport) db.session.commit() return user_sport + + +@pytest.fixture() +def follow_request_from_user_2_to_user_1( + user_1: User, user_2: User +) -> FollowRequest: + follow_request = FollowRequest( + followed_user_id=user_1.id, follower_user_id=user_2.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + +@pytest.fixture() +def follow_request_from_user_3_to_user_1( + user_1: User, user_3: User +) -> FollowRequest: + follow_request = FollowRequest( + followed_user_id=user_1.id, follower_user_id=user_3.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 129c7866b..0aaf6de67 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -1,6 +1,13 @@ +from datetime import datetime + +import pytest from flask import Flask -from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) +from fittrackee.users.models import FollowRequest, User, UserSportPreference from fittrackee.workouts.models import Sport, Workout @@ -76,3 +83,93 @@ def test_user_model( assert serialized_user_sport['color'] is None assert serialized_user_sport['is_active'] assert serialized_user_sport['stopped_speed_threshold'] == 1 + + +class TestUserFollowingModel: + def test_user_2_sends_follow_requests_to_user_1( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_2.sent_follow_requests.all() + + def test_user_1_receives_follow_requests_from_user_2( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_1.received_follow_requests.all() + + def test_user_has_pending_follow_request( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + assert ( + follow_request_from_user_2_to_user_1 + in user_1.pending_follow_requests + ) + + def test_user_has_no_pending_follow_request( + self, + app_with_federation: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.now() + assert user_1.pending_follow_requests == [] + + def test_user_approves_follow_request( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + follow_request = user_1.approves_follow_request_from(user_2) + + assert follow_request.is_approved + assert user_1.pending_follow_requests == [ + follow_request_from_user_3_to_user_1 + ] + + def test_user_refuses_follow_request( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request = user_1.refuses_follow_request_from(user_2) + + assert not follow_request.is_approved + assert user_1.pending_follow_requests == [] + + def test_it_raises_error_if_follow_request_does_not_exists( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + with pytest.raises(NotExistingFollowRequestError): + user_1.approves_follow_request_from(user_2) + + def test_it_raises_error_if_user_approves_follow_request_already_processed( # noqa + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.now() + + with pytest.raises(FollowRequestAlreadyProcessedError): + user_1.approves_follow_request_from(user_2) diff --git a/fittrackee/users/exceptions.py b/fittrackee/users/exceptions.py index 08d045376..9e1b542dd 100644 --- a/fittrackee/users/exceptions.py +++ b/fittrackee/users/exceptions.py @@ -1,2 +1,10 @@ +class FollowRequestAlreadyProcessedError(Exception): + ... + + +class NotExistingFollowRequestError(Exception): + ... + + class UserNotFoundException(Exception): ... diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index da55a9218..7c2b203c9 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -11,9 +11,38 @@ from fittrackee.federation.models import Actor from fittrackee.workouts.models import Workout +from .exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) from .utils.token import decode_user_token, get_user_token +class FollowRequest(BaseModel): + """Follow request between two users""" + + __tablename__ = 'follow_requests' + follower_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + primary_key=True, + ) + followed_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + primary_key=True, + ) + is_approved = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow + ) + updated_at = db.Column(db.DateTime, nullable=True) + + def __init__(self, follower_user_id: int, followed_user_id: int): + self.follower_user_id = follower_user_id + self.followed_user_id = followed_user_id + + class User(BaseModel): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -47,6 +76,18 @@ class User(BaseModel): language = db.Column(db.String(50), nullable=True) imperial_units = db.Column(db.Boolean, default=False, nullable=False) actor = db.relationship(Actor, back_populates='user') + received_follow_requests = db.relationship( + FollowRequest, + backref='to_user', + primaryjoin=id == FollowRequest.followed_user_id, + lazy='dynamic', + ) + sent_follow_requests = db.relationship( + FollowRequest, + backref='from_user', + primaryjoin=id == FollowRequest.follower_user_id, + lazy='dynamic', + ) def __repr__(self) -> str: return f'' @@ -109,6 +150,45 @@ def workouts_count(self) -> int: .label('workouts_count') ) + @property + def pending_follow_requests(self) -> FollowRequest: + return self.received_follow_requests.filter_by(updated_at=None).all() + + def send_follow_request_to(self, target: 'User') -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=self.id, followed_user_id=target.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + def _processes_follow_request_from( + self, user: 'User', approved: bool + ) -> FollowRequest: + follow_request = FollowRequest.query.filter_by( + follower_user_id=user.id, followed_user_id=self.id + ).first() + if not follow_request: + raise NotExistingFollowRequestError() + if follow_request.updated_at is not None: + raise FollowRequestAlreadyProcessedError() + follow_request.is_approved = approved + follow_request.updated_at = datetime.now() + db.session.commit() + return follow_request + + def approves_follow_request_from(self, user: 'User') -> FollowRequest: + follow_request = self._processes_follow_request_from( + user=user, approved=True + ) + return follow_request + + def refuses_follow_request_from(self, user: 'User') -> FollowRequest: + follow_request = self._processes_follow_request_from( + user=user, approved=False + ) + return follow_request + def serialize(self) -> Dict: sports = [] total = (0, '0:00:00') From 63cbcae93a5f2dda0ae2a36e5465f17acc8520b8 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 12:10:59 +0100 Subject: [PATCH 015/238] API - get pending follow requests --- fittrackee/__init__.py | 2 + .../tests/fixtures/fixtures_federation.py | 2 +- fittrackee/tests/fixtures/fixtures_users.py | 12 + .../users/test_users_follow_request_api.py | 265 ++++++++++++++++++ fittrackee/users/follow_requests.py | 51 ++++ fittrackee/users/models.py | 10 + 6 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 fittrackee/tests/users/test_users_follow_request_api.py create mode 100644 fittrackee/users/follow_requests.py diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index eec1be648..c02687169 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -83,6 +83,7 @@ def create_app() -> Flask: from .application.app_config import config_blueprint # noqa from .users.auth import auth_blueprint # noqa + from .users.follow_requests import follow_requests_blueprint # noqa from .users.users import users_blueprint # noqa from .workouts.records import records_blueprint # noqa from .workouts.sports import sports_blueprint # noqa @@ -96,6 +97,7 @@ def create_app() -> Flask: app.register_blueprint(stats_blueprint, url_prefix='/api') app.register_blueprint(users_blueprint, url_prefix='/api') app.register_blueprint(workouts_blueprint, url_prefix='/api') + app.register_blueprint(follow_requests_blueprint, url_prefix='/api') # ActivityPub federation from .federation.federation import ap_federation_blueprint # noqa diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index c2dccd064..82d20a4ef 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -50,8 +50,8 @@ def actor_3(user_3: User, app_with_federation: Flask) -> Actor: ).first() actor = Actor(username=user_3.username, domain_id=domain.id) db.session.add(actor) - user_3.actor_id = actor.id db.session.flush() + user_3.actor_id = actor.id db.session.commit() return actor diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index 6e238a8fe..c867576d9 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -134,3 +134,15 @@ def follow_request_from_user_3_to_user_1( db.session.add(follow_request) db.session.commit() return follow_request + + +@pytest.fixture() +def follow_request_from_user_3_to_user_2( + user_2: User, user_3: User +) -> FollowRequest: + follow_request = FollowRequest( + followed_user_id=user_2.id, follower_user_id=user_3.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py new file mode 100644 index 000000000..0e01691a5 --- /dev/null +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -0,0 +1,265 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest, User + +from ..api_test_case import ApiTestCaseMixin + + +class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): + def test_it_returns_empty_list_if_no_follow_request( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['follow_requests'] == [] + + def test_it_returns_current_user_pending_follow_requests( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert '@context' not in data['data']['follow_requests'][0] + + +class TestGetFollowRequestWithFederation(ApiTestCaseMixin): + def test_it_returns_empty_list_if_no_follow_request( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['follow_requests'] == [] + + def test_it_returns_current_user_follow_requests_with_actors( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_3_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['name'] == 'toto' + assert '@context' in data['data']['follow_requests'][0] + + +class TestGetFollowRequestPagination(ApiTestCaseMixin): + def test_it_returns_pagination( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 2 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } + + @patch('fittrackee.users.follow_requests.FOLLOW_REQUESTS_PER_PAGE', 1) + def test_it_returns_second_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests?page=2', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.follow_requests.MAX_FOLLOW_REQUESTS_PER_PAGE', 1) + def test_it_returns_max_follow_request_per_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests?per_page=10', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + def test_it_returns_follow_requests_with_descending_order( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests?order=desc', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 2 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert data['data']['follow_requests'][1]['username'] == 'toto' + + def test_it_returns_one_request_per_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests?per_page=1', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + def test_it_returns_second_page_with_one_request_per_page_with_descending_order( # noqa + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.get( + '/api/follow_requests?page=2&per_page=1&order=desc', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py new file mode 100644 index 000000000..9ffef1ef8 --- /dev/null +++ b/fittrackee/users/follow_requests.py @@ -0,0 +1,51 @@ +from typing import Dict + +from flask import Blueprint, current_app, request + +from .decorators import authenticate +from .models import FollowRequest, User + +follow_requests_blueprint = Blueprint('follow_requests', __name__) + +FOLLOW_REQUESTS_PER_PAGE = 10 +MAX_FOLLOW_REQUESTS_PER_PAGE = 50 + + +@follow_requests_blueprint.route('/follow_requests', methods=['GET']) +@authenticate +def get_users(auth_user: User) -> Dict: + params = request.args.copy() + page = int(params.get('page', 1)) + per_page = int(params.get('per_page', FOLLOW_REQUESTS_PER_PAGE)) + order = params.get('order', 'asc') + if per_page > MAX_FOLLOW_REQUESTS_PER_PAGE: + per_page = MAX_FOLLOW_REQUESTS_PER_PAGE + follow_requests_pagination = ( + FollowRequest.query.filter_by( + followed_user_id=auth_user.id, + updated_at=None, + ) + .order_by( + FollowRequest.created_at.asc() if order == 'asc' else True, + FollowRequest.created_at.desc() if order == 'desc' else True, + ) + .paginate(page, per_page, False) + ) + follow_requests = follow_requests_pagination.items + federation_enabled = current_app.config['federation_enabled'] + return { + 'status': 'success', + 'data': { + 'follow_requests': [ + follow_request.serialize(federation_enabled)['from_user'] + for follow_request in follow_requests + ] + }, + 'pagination': { + 'has_next': follow_requests_pagination.has_next, + 'has_prev': follow_requests_pagination.has_prev, + 'page': follow_requests_pagination.page, + 'pages': follow_requests_pagination.pages, + 'total': follow_requests_pagination.total, + }, + } diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 7c2b203c9..524d07676 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -42,6 +42,16 @@ def __init__(self, follower_user_id: int, followed_user_id: int): self.follower_user_id = follower_user_id self.followed_user_id = followed_user_id + def serialize(self, federation_enabled: bool) -> Dict: + return { + 'from_user': self.from_user.actor.serialize() + if federation_enabled + else self.from_user.serialize(), + 'to_user': self.to_user.actor.serialize() + if federation_enabled + else self.to_user.serialize(), + } + class User(BaseModel): __tablename__ = 'users' From d759186ad11ef9b6c46d1a5612cfb2f18a4177c5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 12:21:58 +0100 Subject: [PATCH 016/238] API - init follow route (wip) --- fittrackee/tests/fixtures/fixtures_users.py | 12 ++++ .../tests/users/test_users_follow_api.py | 68 +++++++++++++++++++ fittrackee/tests/utils.py | 15 +++- fittrackee/users/follow_requests.py | 2 +- fittrackee/users/models.py | 3 + fittrackee/users/users.py | 27 +++++++- 6 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 fittrackee/tests/users/test_users_follow_api.py diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index c867576d9..88e83e011 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -112,6 +112,18 @@ def user_admin_sport_1_preference( return user_sport +@pytest.fixture() +def follow_request_from_user_1_to_user_2( + user_1: User, user_2: User +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=user_1.id, followed_user_id=user_2.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + @pytest.fixture() def follow_request_from_user_2_to_user_1( user_1: User, user_2: User diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py new file mode 100644 index 000000000..fdd9167c1 --- /dev/null +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -0,0 +1,68 @@ +import json +from datetime import datetime + +from flask import Flask + +from fittrackee.users.models import FollowRequest, User + +from ..api_test_case import ApiTestCaseMixin +from ..utils import random_string + + +class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): + def test_it_raises_error_if_target_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.post( + f'/api/users/{random_string()}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_raises_error_if_target_user_has_already_rejected_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = False + follow_request_from_user_1_to_user_2.updated_at = datetime.now() + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'you do not have permissions' + + def test_it_creates_follow_request( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{user_2.username}' is sent." + ) diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 1948e37ff..460dc91b2 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -3,14 +3,23 @@ from typing import Optional -def random_string(length: Optional[int] = None) -> str: +def random_string( + length: Optional[int] = None, + prefix: Optional[str] = None, + suffix: Optional[str] = None, +) -> str: if length is None: length = 10 - return ''.join( + random_str = ''.join( random.choice(string.ascii_letters + string.digits) for _ in range(length) ) + return ( + f'{"" if prefix is None else prefix}' + f'{random_str}' + f'{"" if suffix is None else suffix}' + ) def random_domain() -> str: - return f'https://{random_string()}.social' + return random_string(prefix='https://', suffix='.social') diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py index 9ffef1ef8..642c3ad84 100644 --- a/fittrackee/users/follow_requests.py +++ b/fittrackee/users/follow_requests.py @@ -13,7 +13,7 @@ @follow_requests_blueprint.route('/follow_requests', methods=['GET']) @authenticate -def get_users(auth_user: User) -> Dict: +def get_follow_requests(auth_user: User) -> Dict: params = request.args.copy() page = int(params.get('page', 1)) per_page = int(params.get('per_page', FOLLOW_REQUESTS_PER_PAGE)) diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 524d07676..cb1451238 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -42,6 +42,9 @@ def __init__(self, follower_user_id: int, followed_user_id: int): self.follower_user_id = follower_user_id self.followed_user_id = followed_user_id + def is_rejected(self) -> bool: + return not self.is_approved and self.updated_at is not None + def serialize(self, federation_enabled: bool) -> Dict: return { 'from_user': self.from_user.actor.serialize() diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index b321c866d..12957a523 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -20,7 +20,7 @@ from .decorators import authenticate, authenticate_as_admin from .exceptions import UserNotFoundException -from .models import User, UserSportPreference +from .models import FollowRequest, User, UserSportPreference from .utils.admin import set_admin_rights users_blueprint = Blueprint('users', __name__) @@ -597,3 +597,28 @@ def delete_user( OSError, ) as e: return handle_error_and_return_response(e, db=db) + + +@users_blueprint.route('/users//follow', methods=['POST']) +@authenticate +def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: + successful_response_dict = { + 'status': 'success', + 'message': f"Follow request to user '{user_name}' is sent.", + } + target_user = User.query.filter_by(username=user_name).first() + if target_user: + existing_follow_request = FollowRequest.query.filter_by( + follower_user_id=auth_user.id, followed_user_id=target_user.id + ).first() + if existing_follow_request: + if existing_follow_request.is_rejected(): + return ForbiddenErrorResponse() + else: + return successful_response_dict + + auth_user = User.query.filter_by(id=auth_user.id).first() + auth_user.send_follow_request_to(target_user) + return successful_response_dict + + return UserNotFoundErrorResponse() From 49626f8c7a90161c6b4ffb3e8f07f4f7b67070de Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 13 Jun 2021 15:03:42 +0200 Subject: [PATCH 017/238] API - minor refactor (move all federation-related tests in federation directory) --- .../tests/federation/federation/__init__.py | 0 .../test_federation_federation.py | 0 .../test_federation_models.py | 0 .../test_federation_nodeinfo.py | 0 .../test_federation_webfinger.py | 0 fittrackee/tests/federation/users/__init__.py | 0 .../users/test_users_follow_request_api.py | 57 +++++++++++++++++++ .../users/test_users_follow_request_api.py | 49 ---------------- fittrackee/tests/users/test_users_model.py | 10 ++-- 9 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 fittrackee/tests/federation/federation/__init__.py rename fittrackee/tests/federation/{ => federation}/test_federation_federation.py (100%) rename fittrackee/tests/federation/{ => federation}/test_federation_models.py (100%) rename fittrackee/tests/federation/{ => federation}/test_federation_nodeinfo.py (100%) rename fittrackee/tests/federation/{ => federation}/test_federation_webfinger.py (100%) create mode 100644 fittrackee/tests/federation/users/__init__.py create mode 100644 fittrackee/tests/federation/users/test_users_follow_request_api.py diff --git a/fittrackee/tests/federation/federation/__init__.py b/fittrackee/tests/federation/federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py similarity index 100% rename from fittrackee/tests/federation/test_federation_federation.py rename to fittrackee/tests/federation/federation/test_federation_federation.py diff --git a/fittrackee/tests/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py similarity index 100% rename from fittrackee/tests/federation/test_federation_models.py rename to fittrackee/tests/federation/federation/test_federation_models.py diff --git a/fittrackee/tests/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py similarity index 100% rename from fittrackee/tests/federation/test_federation_nodeinfo.py rename to fittrackee/tests/federation/federation/test_federation_nodeinfo.py diff --git a/fittrackee/tests/federation/test_federation_webfinger.py b/fittrackee/tests/federation/federation/test_federation_webfinger.py similarity index 100% rename from fittrackee/tests/federation/test_federation_webfinger.py rename to fittrackee/tests/federation/federation/test_federation_webfinger.py diff --git a/fittrackee/tests/federation/users/__init__.py b/fittrackee/tests/federation/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/users/test_users_follow_request_api.py b/fittrackee/tests/federation/users/test_users_follow_request_api.py new file mode 100644 index 000000000..f9d896fba --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -0,0 +1,57 @@ +import json +from datetime import datetime + +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest + +from ...api_test_case import ApiTestCaseMixin + + +class TestGetFollowRequestWithFederation(ApiTestCaseMixin): + def test_it_returns_empty_list_if_no_follow_request( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['follow_requests'] == [] + + def test_it_returns_current_user_follow_requests_with_actors( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_3_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.get( + '/api/follow_requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['name'] == 'toto' + assert '@context' in data['data']['follow_requests'][0] diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py index 0e01691a5..23ed21981 100644 --- a/fittrackee/tests/users/test_users_follow_request_api.py +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -4,7 +4,6 @@ from flask import Flask -from fittrackee.federation.models import Actor from fittrackee.users.models import FollowRequest, User from ..api_test_case import ApiTestCaseMixin @@ -52,54 +51,6 @@ def test_it_returns_current_user_pending_follow_requests( assert '@context' not in data['data']['follow_requests'][0] -class TestGetFollowRequestWithFederation(ApiTestCaseMixin): - def test_it_returns_empty_list_if_no_follow_request( - self, app_with_federation: Flask, actor_1: Actor - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - - response = client.get( - '/api/follow_requests', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data['status'] == 'success' - assert data['data']['follow_requests'] == [] - - def test_it_returns_current_user_follow_requests_with_actors( - self, - app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_2_to_user_1: FollowRequest, - follow_request_from_user_3_to_user_1: FollowRequest, - follow_request_from_user_3_to_user_2: FollowRequest, - ) -> None: - follow_request_from_user_3_to_user_1.updated_at = datetime.utcnow() - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - - response = client.get( - '/api/follow_requests', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data['status'] == 'success' - assert len(data['data']['follow_requests']) == 1 - assert data['data']['follow_requests'][0]['name'] == 'toto' - assert '@context' in data['data']['follow_requests'][0] - - class TestGetFollowRequestPagination(ApiTestCaseMixin): def test_it_returns_pagination( self, diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 0aaf6de67..247cbcd39 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -119,7 +119,7 @@ def test_user_has_pending_follow_request( def test_user_has_no_pending_follow_request( self, - app_with_federation: Flask, + app: Flask, user_1: User, follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: @@ -128,7 +128,7 @@ def test_user_has_no_pending_follow_request( def test_user_approves_follow_request( self, - app_with_federation: Flask, + app: Flask, user_1: User, user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, @@ -143,7 +143,7 @@ def test_user_approves_follow_request( def test_user_refuses_follow_request( self, - app_with_federation: Flask, + app: Flask, user_1: User, user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, @@ -155,7 +155,7 @@ def test_user_refuses_follow_request( def test_it_raises_error_if_follow_request_does_not_exists( self, - app_with_federation: Flask, + app: Flask, user_1: User, user_2: User, ) -> None: @@ -164,7 +164,7 @@ def test_it_raises_error_if_follow_request_does_not_exists( def test_it_raises_error_if_user_approves_follow_request_already_processed( # noqa self, - app_with_federation: Flask, + app: Flask, user_1: User, user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, From 0ffa4d8565c6a807d11a517048eb5450e8861382 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 13 Jun 2021 18:06:08 +0200 Subject: [PATCH 018/238] API - add missing tests --- fittrackee/tests/conftest.py | 1 + .../federation/users/test_users_model.py | 23 +++++++++++++++++++ .../fixtures/fixtures_federation_users.py | 18 +++++++++++++++ fittrackee/tests/users/test_users_model.py | 19 +++++++++++++++ fittrackee/users/follow_requests.py | 5 ++-- fittrackee/users/models.py | 21 +++++++++++------ 6 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 fittrackee/tests/federation/users/test_users_model.py create mode 100644 fittrackee/tests/fixtures/fixtures_federation_users.py diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index 98230d445..a36ba6d9f 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -8,6 +8,7 @@ pytest_plugins = [ 'fittrackee.tests.fixtures.fixtures_app', 'fittrackee.tests.fixtures.fixtures_federation', + 'fittrackee.tests.fixtures.fixtures_federation_users', 'fittrackee.tests.fixtures.fixtures_workouts', 'fittrackee.tests.fixtures.fixtures_users', ] diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py new file mode 100644 index 000000000..c243d8f9b --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -0,0 +1,23 @@ +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest + + +class TestFollowRequestModelWithFederation: + def test_follow_request_model( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2_with_federation: FollowRequest, + ) -> None: + assert '' == str( + follow_request_from_user_1_to_user_2_with_federation + ) + + serialized_follow_request = ( + follow_request_from_user_1_to_user_2_with_federation.serialize() + ) + assert serialized_follow_request['from_user'] == actor_1.serialize() + assert serialized_follow_request['to_user'] == actor_2.serialize() diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py new file mode 100644 index 000000000..39c8eff99 --- /dev/null +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -0,0 +1,18 @@ +import pytest + +from fittrackee import db +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest + + +@pytest.fixture() +def follow_request_from_user_1_to_user_2_with_federation( + actor_1: Actor, + actor_2: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=actor_1.user.id, followed_user_id=actor_2.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 247cbcd39..c75670785 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -85,6 +85,25 @@ def test_user_model( assert serialized_user_sport['stopped_speed_threshold'] == 1 +class TestFollowRequestModel: + def test_follow_request_model( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + assert '' == str( + follow_request_from_user_1_to_user_2 + ) + + serialized_follow_request = ( + follow_request_from_user_1_to_user_2.serialize() + ) + assert serialized_follow_request['from_user'] == user_1.serialize() + assert serialized_follow_request['to_user'] == user_2.serialize() + + class TestUserFollowingModel: def test_user_2_sends_follow_requests_to_user_1( self, diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py index 642c3ad84..a54ea943c 100644 --- a/fittrackee/users/follow_requests.py +++ b/fittrackee/users/follow_requests.py @@ -1,6 +1,6 @@ from typing import Dict -from flask import Blueprint, current_app, request +from flask import Blueprint, request from .decorators import authenticate from .models import FollowRequest, User @@ -32,12 +32,11 @@ def get_follow_requests(auth_user: User) -> Dict: .paginate(page, per_page, False) ) follow_requests = follow_requests_pagination.items - federation_enabled = current_app.config['federation_enabled'] return { 'status': 'success', 'data': { 'follow_requests': [ - follow_request.serialize(federation_enabled)['from_user'] + follow_request.serialize()['from_user'] for follow_request in follow_requests ] }, diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index cb1451238..346fdaacb 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -38,6 +38,12 @@ class FollowRequest(BaseModel): ) updated_at = db.Column(db.DateTime, nullable=True) + def __repr__(self) -> str: + return ( + f'' + ) + def __init__(self, follower_user_id: int, followed_user_id: int): self.follower_user_id = follower_user_id self.followed_user_id = followed_user_id @@ -45,14 +51,15 @@ def __init__(self, follower_user_id: int, followed_user_id: int): def is_rejected(self) -> bool: return not self.is_approved and self.updated_at is not None - def serialize(self, federation_enabled: bool) -> Dict: + def serialize(self) -> Dict: + if current_app.config['federation_enabled']: + return { + 'from_user': self.from_user.actor.serialize(), + 'to_user': self.to_user.actor.serialize(), + } return { - 'from_user': self.from_user.actor.serialize() - if federation_enabled - else self.from_user.serialize(), - 'to_user': self.to_user.actor.serialize() - if federation_enabled - else self.to_user.serialize(), + 'from_user': self.from_user.serialize(), + 'to_user': self.to_user.serialize(), } From 3f35a00e19c0c01bf3e44ee23eff93532462e5b0 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 12:55:27 +0100 Subject: [PATCH 019/238] API - add signature verification for federation (wip) --- fittrackee/federation/exceptions.py | 9 + fittrackee/federation/signature.py | 110 ++++++ .../tests/federation/users/test_signature.py | 336 ++++++++++++++++++ fittrackee/tests/utils.py | 20 +- 4 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 fittrackee/federation/exceptions.py create mode 100644 fittrackee/federation/signature.py create mode 100644 fittrackee/tests/federation/users/test_signature.py diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py new file mode 100644 index 000000000..1519996a5 --- /dev/null +++ b/fittrackee/federation/exceptions.py @@ -0,0 +1,9 @@ +from fittrackee.exceptions import GenericException + + +class InvalidSignatureException(GenericException): + def __init__(self) -> None: + super().__init__( + status='error', + message='Invalid signature.', + ) diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py new file mode 100644 index 000000000..2d71496c4 --- /dev/null +++ b/fittrackee/federation/signature.py @@ -0,0 +1,110 @@ +import base64 +from datetime import datetime +from typing import Dict, Optional + +import requests +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from flask import Request + +from fittrackee import appLog + +from .exceptions import InvalidSignatureException + +VALID_DATE_DELTA = 30 # in seconds +VALID_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' +VALID_SIG_KEYS = ['keyId', 'headers', 'signature'] + + +class SignatureVerification: + def __init__(self, request: Request, signature_dict: Dict): + self.request = request + self.host = request.headers.get('Host', 'undefined') + self.date_str = request.headers.get('Date') + self.key_id = signature_dict['keyId'] + self.headers = signature_dict['headers'] + self.signature = base64.b64decode(signature_dict['signature']) + + @classmethod + def get_signature(cls, request: Request) -> 'SignatureVerification': + signature_dict = {} + host = request.headers.get('Host', 'undefined') + try: + header_signature = request.headers['Signature'].split(',') + for part in header_signature: + key, value = part.split('=', 1) + signature_dict[key] = value.strip('"') + except Exception as e: + appLog.error(f'Invalid signature headers: {e} (host: {host}).') + raise InvalidSignatureException() + + if list(signature_dict.keys()) != VALID_SIG_KEYS: + appLog.error( + f'Invalid signature headers: invalid keys (host: {host}).' + ) + raise InvalidSignatureException() + + return cls(request, signature_dict) + + def get_actor_public_key(self) -> Optional[str]: + response = requests.get( + self.key_id, + headers={'Accept': 'application/activity+json'}, + ) + if response.status_code >= 400: + return None + try: + public_key = response.json()['publicKey']['publicKeyPem'] + except Exception: + return None + return public_key + + def is_date_invalid(self) -> bool: + if not self.date_str: + return True + try: + date = datetime.strptime(self.date_str, VALID_DATE_FORMAT) + except ValueError: + return True + delta = datetime.utcnow() - date + return delta.total_seconds() > VALID_DATE_DELTA + + def verify(self) -> None: + public_key = self.get_actor_public_key() + if not public_key: + appLog.error( + f'Invalid signature: invalid public key (host: {self.host}).' + ) + raise InvalidSignatureException() + + if self.is_date_invalid(): + appLog.error( + f'Invalid signature: invalid date header (host: {self.host}).' + ) + raise InvalidSignatureException() + + comparison = [] + for headers_part in self.headers.split(' '): + if headers_part == '(request-target)': + comparison.append( + '(request-target): post %s' % self.request.path + ) + else: + comparison.append( + "%s: %s" + % ( + headers_part, + self.request.headers[headers_part.capitalize()], + ) + ) + comparison_string: str = '\n'.join(comparison) + + signer = pkcs1_15.new(RSA.import_key(public_key)) + digest = SHA256.new() + digest.update(comparison_string.encode()) + try: + signer.verify(digest, self.signature) + except ValueError: + appLog.error(f'Invalid signature (host: {self.host}).') + raise InvalidSignatureException() diff --git a/fittrackee/tests/federation/users/test_signature.py b/fittrackee/tests/federation/users/test_signature.py new file mode 100644 index 000000000..603eb67ed --- /dev/null +++ b/fittrackee/tests/federation/users/test_signature.py @@ -0,0 +1,336 @@ +import base64 +from datetime import datetime, timedelta +from typing import Dict, Optional +from unittest.mock import MagicMock, patch + +import pytest +import requests +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from flask import Flask + +from fittrackee.federation.exceptions import InvalidSignatureException +from fittrackee.federation.models import Actor +from fittrackee.federation.signature import ( + VALID_DATE_DELTA, + VALID_DATE_FORMAT, + SignatureVerification, +) + +from ...utils import generate_response, random_string + + +class SignatureVerificationTestCase: + @staticmethod + def get_date_string(date: Optional[datetime] = None) -> str: + date = date if date else datetime.utcnow() + return date.strftime(VALID_DATE_FORMAT) + + @staticmethod + def random_signature() -> str: + return str(base64.b64encode(random_string().encode())) + + def generate_headers( + self, + key_id: Optional[str] = None, + signature: Optional[str] = None, + date_str: Optional[str] = None, # overrides date + date: Optional[datetime] = None, + host: Optional[str] = None, + ) -> Dict: + key_id = key_id if key_id else random_string() + signature = signature if signature else self.random_signature() + signature_headers = ( + f'keyId="{key_id}",headers="(request-target) host date",' + f'signature="' + signature + '"' + ) + if date_str is None: + date_str = self.get_date_string(date) + return { + 'Host': host if host else random_string(), + 'Date': date_str, + 'Signature': signature_headers, + 'Content-Type': 'application/ld+json', + } + + def generate_valid_headers( + self, host: str, actor: Actor, date_str: Optional[str] = None + ) -> Dict: + if date_str is None: + now = datetime.utcnow() + date_str = now.strftime(VALID_DATE_FORMAT) + signed_string = ( + f'(request-target): post /inbox\nhost: {host}\n' + f'date: {date_str}' + ) + key = RSA.import_key(actor.private_key) + key_signer = pkcs1_15.new(key) + encoded_string = signed_string.encode('utf-8') + h = SHA256.new(encoded_string) + signature = base64.b64encode(key_signer.sign(h)) + return self.generate_headers( + key_id=actor.activitypub_id, + signature=signature.decode(), + date_str=date_str, + host=host, + ) + + @staticmethod + def get_request_mock(headers: Optional[Dict] = None) -> MagicMock: + request_mock = MagicMock() + request_mock.headers = headers if headers else {} + request_mock.path = '/inbox' + return request_mock + + +class TestSignatureVerificationInstantiation(SignatureVerificationTestCase): + def test_it_raises_error_if_headers_are_empty(self) -> None: + request_with_empty_headers = self.get_request_mock() + + with pytest.raises(InvalidSignatureException): + SignatureVerification.get_signature(request_with_empty_headers) + + @pytest.mark.parametrize( + 'input_description,input_signature_headers', + [ + ( + 'missing keyId', + 'headers="(request-target) host date",' + f'signature="{random_string()}"', + ), + ( + 'missing headers', + f'keyId="{random_string()}", signature="{random_string()}"', + ), + ( + 'missing signature', + f'keyId="{random_string()}",' + 'headers="(request-target) host date"', + ), + ], + ) + def test_it_raises_error_if_a_signature_key_is_missing( + self, input_description: str, input_signature_headers: str + ) -> None: + request_with_empty_headers = self.get_request_mock( + headers={ + 'Host': random_string(), + 'Date': self.get_date_string(), + 'Signature': input_signature_headers, + } + ) + + with pytest.raises(InvalidSignatureException): + SignatureVerification.get_signature(request_with_empty_headers) + + def test_it_instantiates_signature_verification(self) -> None: + key_id = random_string() + signature = self.random_signature() + signature_headers = ( + f'keyId="{key_id}",headers="(request-target) host date",' + f'signature="' + signature + '"' + ) + date_str = self.get_date_string() + valid_request_mock = self.get_request_mock( + headers={ + 'Host': random_string(), + 'Date': date_str, + 'Signature': signature_headers, + } + ) + + sig_verification = SignatureVerification.get_signature( + valid_request_mock + ) + + assert sig_verification.request == valid_request_mock + assert sig_verification.date_str == date_str + assert sig_verification.key_id == key_id + assert sig_verification.headers == '(request-target) host date' + assert sig_verification.signature == base64.b64decode(signature) + + +class TestSignatureDateVerification(SignatureVerificationTestCase): + def test_it_returns_date_is_invalid_if_date_is_empty(self) -> None: + headers = self.generate_headers(date_str='') + request_mock = self.get_request_mock(headers=headers) + + sig_verification = SignatureVerification.get_signature(request_mock) + + assert sig_verification.is_date_invalid() is True + + def test_it_returns_date_is_invalid_if_date_format_is_invalid( + self, + ) -> None: + date_str = datetime.utcnow().strftime('%d %b %Y %H:%M:%S') + headers = self.generate_headers(date_str=date_str) + request_mock = self.get_request_mock(headers=headers) + + sig_verification = SignatureVerification.get_signature(request_mock) + + assert sig_verification.is_date_invalid() is True + + def test_it_returns_date_is_invalid_if_delay_exceeds_limit(self) -> None: + headers = self.generate_headers( + date=datetime.utcnow() - timedelta(seconds=VALID_DATE_DELTA + 1) + ) + request_mock = self.get_request_mock(headers=headers) + + sig_verification = SignatureVerification.get_signature(request_mock) + + assert sig_verification.is_date_invalid() is True + + def test_it_returns_date_is_valid_if_dela_is_below_limit(self) -> None: + headers = self.generate_headers( + date=datetime.utcnow() - timedelta(seconds=VALID_DATE_DELTA - 1) + ) + request_mock = self.get_request_mock(headers=headers) + + sig_verification = SignatureVerification.get_signature(request_mock) + + assert sig_verification.is_date_invalid() is False + + +class TestGetActorPublicKey(SignatureVerificationTestCase): + def test_it_calls_requests_with_key_id(self) -> None: + key_id = random_string() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock(self.generate_headers(key_id=key_id)) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response() + + sig_verification.get_actor_public_key() + + requests_mock.assert_called_with( + key_id, headers={'Accept': 'application/activity+json'} + ) + + def test_it_returns_none_if_requests_returns_error_status_code( + self, + ) -> None: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock(self.generate_headers()) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response(status_code=404) + + public_key = sig_verification.get_actor_public_key() + + assert public_key is None + + def test_it_returns_none_if_requests_returns_invalid_actor_dict( + self, + ) -> None: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock(self.generate_headers()) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, content={random_string(): random_string()} + ) + + public_key = sig_verification.get_actor_public_key() + + assert public_key is None + + def test_it_returns_public_key(self) -> None: + public_key = random_string() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock(self.generate_headers()) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content={'publicKey': {'publicKeyPem': public_key}}, + ) + + key = sig_verification.get_actor_public_key() + + assert key == public_key + + +class TestSignatureVerify(SignatureVerificationTestCase): + def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config['AP_DOMAIN'], + actor=actor_1, + ) + ) + ) + # update actor keys + actor_1.generate_keys() + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + with pytest.raises(InvalidSignatureException): + sig_verification.verify() + + def test_verify_raises_error_if_header_date_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config['AP_DOMAIN'], + actor=actor_1, + date_str='', + ) + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + with pytest.raises(InvalidSignatureException): + sig_verification.verify() + + def test_verify_raises_error_if_public_key_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config['AP_DOMAIN'], + actor=actor_1, + ) + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response(status_code=404) + + with pytest.raises(InvalidSignatureException): + sig_verification.verify() + + def test_verify_does_not_raise_error_if_signature_is_valid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config['AP_DOMAIN'], + actor=actor_1, + ) + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + sig_verification.verify() diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 460dc91b2..4e830dbb0 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -1,6 +1,9 @@ import random import string -from typing import Optional +from json import dumps +from typing import Dict, Optional, Union + +from requests import Response def random_string( @@ -23,3 +26,18 @@ def random_string( def random_domain() -> str: return random_string(prefix='https://', suffix='.social') + + +def generate_response( + content: Optional[Union[str, Dict]] = None, + status_code: Optional[int] = None, +) -> Response: + content = content if content else {} + response = Response() + response._content = ( + dumps(content).encode() + if isinstance(content, dict) + else content.encode() + ) + response.status_code = status_code if status_code else 200 + return response From 13f3fc49ef9c1c1d95224d23f1ec670c62595cdd Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 26 Jun 2021 12:07:35 +0200 Subject: [PATCH 020/238] API - minor tests refacto --- fittrackee/tests/application/test_app_config_api.py | 2 +- fittrackee/tests/emails/test_email_service.py | 2 +- .../tests/federation/federation/test_federation_models.py | 5 ----- .../tests/federation/users/test_users_follow_request_api.py | 2 +- fittrackee/tests/{api_test_case.py => test_case_mixins.py} | 0 fittrackee/tests/users/test_auth_api.py | 2 +- fittrackee/tests/users/test_users_api.py | 2 +- fittrackee/tests/users/test_users_follow_api.py | 2 +- fittrackee/tests/users/test_users_follow_request_api.py | 2 +- fittrackee/tests/workouts/test_records_api.py | 2 +- fittrackee/tests/workouts/test_sports_api.py | 2 +- fittrackee/tests/workouts/test_stats_api.py | 2 +- fittrackee/tests/workouts/test_workouts_api_0_get.py | 2 +- fittrackee/tests/workouts/test_workouts_api_1_post.py | 2 +- fittrackee/tests/workouts/test_workouts_api_2_patch.py | 2 +- fittrackee/tests/workouts/test_workouts_api_3_delete.py | 2 +- 16 files changed, 14 insertions(+), 19 deletions(-) rename fittrackee/tests/{api_test_case.py => test_case_mixins.py} (100%) diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index 75dccb2cf..c7da3fed8 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -5,7 +5,7 @@ import fittrackee from fittrackee.users.models import User -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestGetConfig(ApiTestCaseMixin): diff --git a/fittrackee/tests/emails/test_email_service.py b/fittrackee/tests/emails/test_email_service.py index 9d999d71b..3a348410d 100644 --- a/fittrackee/tests/emails/test_email_service.py +++ b/fittrackee/tests/emails/test_email_service.py @@ -7,7 +7,7 @@ from fittrackee.emails.email import EmailMessage from fittrackee.emails.exceptions import InvalidEmailUrlScheme -from ..api_test_case import CallArgsMixin +from ..test_case_mixins import CallArgsMixin from .template_results.password_reset_request import expected_en_text_body diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index 23da42bd2..eddc6c635 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -84,10 +84,6 @@ def test_it_returns_serialized_object( serialized_actor['inbox'] == f'{ap_url}/federation/user/{actor_1.preferred_username}/inbox' ) - assert ( - serialized_actor['inbox'] - == f'{ap_url}/federation/user/{actor_1.preferred_username}/inbox' - ) assert ( serialized_actor['outbox'] == f'{ap_url}/federation/user/{actor_1.preferred_username}/outbox' @@ -149,7 +145,6 @@ def test_it_returns_serialized_object( ) assert serialized_actor['name'] == remote_actor.user.username assert serialized_actor['inbox'] == f'{user_url}/inbox' - assert serialized_actor['inbox'] == f'{user_url}/inbox' assert serialized_actor['outbox'] == f'{user_url}/outbox' assert serialized_actor['followers'] == f'{user_url}/followers' assert serialized_actor['following'] == f'{user_url}/following' diff --git a/fittrackee/tests/federation/users/test_users_follow_request_api.py b/fittrackee/tests/federation/users/test_users_follow_request_api.py index f9d896fba..4d976585d 100644 --- a/fittrackee/tests/federation/users/test_users_follow_request_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -6,7 +6,7 @@ from fittrackee.federation.models import Actor from fittrackee.users.models import FollowRequest -from ...api_test_case import ApiTestCaseMixin +from ...test_case_mixins import ApiTestCaseMixin class TestGetFollowRequestWithFederation(ApiTestCaseMixin): diff --git a/fittrackee/tests/api_test_case.py b/fittrackee/tests/test_case_mixins.py similarity index 100% rename from fittrackee/tests/api_test_case.py rename to fittrackee/tests/test_case_mixins.py diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 75241be21..c68e31c6b 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -11,7 +11,7 @@ from fittrackee.users.utils.token import get_user_token from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestUserRegistration: diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index d6c41b52f..d75cd5ad8 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -8,7 +8,7 @@ from fittrackee.users.models import User, UserSportPreference from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestGetUser(ApiTestCaseMixin): diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index fdd9167c1..97cc1202d 100644 --- a/fittrackee/tests/users/test_users_follow_api.py +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -5,7 +5,7 @@ from fittrackee.users.models import FollowRequest, User -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin from ..utils import random_string diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py index 23ed21981..c0b929809 100644 --- a/fittrackee/tests/users/test_users_follow_request_api.py +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -6,7 +6,7 @@ from fittrackee.users.models import FollowRequest, User -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): diff --git a/fittrackee/tests/workouts/test_records_api.py b/fittrackee/tests/workouts/test_records_api.py index f6e6f259f..361e082f4 100644 --- a/fittrackee/tests/workouts/test_records_api.py +++ b/fittrackee/tests/workouts/test_records_api.py @@ -5,7 +5,7 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestGetRecords(ApiTestCaseMixin): diff --git a/fittrackee/tests/workouts/test_sports_api.py b/fittrackee/tests/workouts/test_sports_api.py index 765e883e1..b55045602 100644 --- a/fittrackee/tests/workouts/test_sports_api.py +++ b/fittrackee/tests/workouts/test_sports_api.py @@ -6,7 +6,7 @@ from fittrackee.users.models import User, UserSportPreference from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin expected_sport_1_cycling_result = { 'id': 1, diff --git a/fittrackee/tests/workouts/test_stats_api.py b/fittrackee/tests/workouts/test_stats_api.py index 810fe50cc..7d0bf79d1 100644 --- a/fittrackee/tests/workouts/test_stats_api.py +++ b/fittrackee/tests/workouts/test_stats_api.py @@ -5,7 +5,7 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin class TestGetStatsByTime(ApiTestCaseMixin): diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get.py b/fittrackee/tests/workouts/test_workouts_api_0_get.py index 5aa08b0e4..f7266652e 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get.py @@ -7,7 +7,7 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin from .utils import get_random_short_id diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index 740581f83..38f015e49 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -13,7 +13,7 @@ from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.utils.short_id import decode_short_id -from ..api_test_case import ApiTestCaseMixin, CallArgsMixin +from ..test_case_mixins import ApiTestCaseMixin, CallArgsMixin def assert_workout_data_with_gpx(data: Dict) -> None: diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index 456765369..34ecc38fb 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -8,7 +8,7 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin from .utils import get_random_short_id, post_an_workout diff --git a/fittrackee/tests/workouts/test_workouts_api_3_delete.py b/fittrackee/tests/workouts/test_workouts_api_3_delete.py index 4b1aba911..f393cccfc 100644 --- a/fittrackee/tests/workouts/test_workouts_api_3_delete.py +++ b/fittrackee/tests/workouts/test_workouts_api_3_delete.py @@ -7,7 +7,7 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..api_test_case import ApiTestCaseMixin +from ..test_case_mixins import ApiTestCaseMixin from .utils import get_random_short_id, post_an_workout From 51703315aed2bd5e261ed8b3fdce6f6fe0190481 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:03:05 +0100 Subject: [PATCH 021/238] API - add tests on follow for users in same federated instance --- .../federation/users/test_users_follow_api.py | 104 ++++++++++++++++++ .../tests/users/test_users_follow_api.py | 2 +- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 fittrackee/tests/federation/users/test_users_follow_api.py diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py new file mode 100644 index 000000000..d28ba9358 --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -0,0 +1,104 @@ +import json +from datetime import datetime + +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest + +from ...test_case_mixins import ApiTestCaseMixin +from ...utils import random_string + + +class TestFollowWithFederation(ApiTestCaseMixin): + """Follow user belonging to the same instance""" + + def test_it_raises_error_if_target_user_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.post( + f'/api/users/{random_string()}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_raises_error_if_target_user_has_already_rejected_request( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = False + follow_request_from_user_1_to_user_2.updated_at = datetime.now() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.post( + f'/api/users/{actor_2.preferred_username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'you do not have permissions' + + def test_it_creates_follow_request( + self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.post( + f'/api/users/{actor_2.preferred_username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{actor_2.preferred_username}' " + f"is sent." + ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + response = client.post( + f'/api/users/{actor_2.preferred_username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{actor_2.preferred_username}' " + f"is sent." + ) diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index 97cc1202d..6b8eed6e7 100644 --- a/fittrackee/tests/users/test_users_follow_api.py +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -9,7 +9,7 @@ from ..utils import random_string -class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): +class TestFollowWithoutFederation(ApiTestCaseMixin): def test_it_raises_error_if_target_user_does_not_exist( self, app: Flask, user_1: User ) -> None: From 70fc2d86383a2bf4f6b47d74e5b2ed78877e4d47 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:12:51 +0100 Subject: [PATCH 022/238] API - follow user from another instance (wip) --- fittrackee/federation/enums.py | 4 + fittrackee/federation/exceptions.py | 16 ++ fittrackee/federation/inbox.py | 40 +++++ fittrackee/federation/signature.py | 17 ++ fittrackee/federation/tasks.py | 20 +++ fittrackee/federation/utils.py | 17 +- .../test_federation_remote_inbox.py | 104 +++++++++++ .../federation/test_federation_tasks.py | 66 +++++++ .../tests/federation/users/test_signature.py | 13 +- .../federation/users/test_users_follow_api.py | 163 +++++++++++++++++- .../federation/users/test_users_model.py | 19 ++ fittrackee/tests/test_case_mixins.py | 24 ++- .../tests/users/test_users_follow_api.py | 42 +++++ fittrackee/tests/users/test_users_model.py | 12 ++ fittrackee/tests/utils.py | 14 +- fittrackee/users/models.py | 28 +++ fittrackee/users/users.py | 19 +- 17 files changed, 602 insertions(+), 16 deletions(-) create mode 100644 fittrackee/federation/inbox.py create mode 100644 fittrackee/federation/tasks.py create mode 100644 fittrackee/tests/federation/federation/test_federation_remote_inbox.py create mode 100644 fittrackee/tests/federation/federation/test_federation_tasks.py diff --git a/fittrackee/federation/enums.py b/fittrackee/federation/enums.py index eeb531ec8..bf950af56 100644 --- a/fittrackee/federation/enums.py +++ b/fittrackee/federation/enums.py @@ -1,6 +1,10 @@ from enum import Enum +class ActivityType(Enum): + FOLLOW = 'Follow' + + class ActorType(Enum): APPLICATION = 'Application' GROUP = 'Group' diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py index 1519996a5..a8398aa37 100644 --- a/fittrackee/federation/exceptions.py +++ b/fittrackee/federation/exceptions.py @@ -1,9 +1,25 @@ from fittrackee.exceptions import GenericException +class FederationDisabledException(GenericException): + def __init__(self) -> None: + super().__init__( + status='error', + message='Can not create activity, federation is disabled.', + ) + + class InvalidSignatureException(GenericException): def __init__(self) -> None: super().__init__( status='error', message='Invalid signature.', ) + + +class SenderNotFoundException(GenericException): + def __init__(self) -> None: + super().__init__( + status='error', + message='Sender not found.', + ) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py new file mode 100644 index 000000000..47baa996e --- /dev/null +++ b/fittrackee/federation/inbox.py @@ -0,0 +1,40 @@ +from datetime import datetime +from json import dumps +from typing import Dict +from urllib.parse import urlparse + +import requests + +from fittrackee import appLog + +from .models import Actor +from .signature import VALID_DATE_FORMAT, signature_header + + +def send_to_remote_user_inbox( + sender: Actor, activity: Dict, recipient: Actor +) -> None: + now_str = datetime.utcnow().strftime(VALID_DATE_FORMAT) + parsed_inbox_url = urlparse(recipient.inbox_url) + signed_header = signature_header( + host=parsed_inbox_url.netloc, + path=parsed_inbox_url.path, + date_str=now_str, + actor=sender, + ) + response = requests.post( + recipient.inbox_url, + data=dumps(activity), + headers={ + "Host": parsed_inbox_url.netloc, + "Date": now_str, + "Signature": signed_header, + "Content-Type": "application/ld+json", + }, + ) + if response.status_code >= 400: + appLog.error( + f"Error when send to user inbox '{recipient.inbox_url}', " + f"status code: {response.status_code}, " + f"content: {response.content.decode()}" + ) diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index 2d71496c4..fc7dc5404 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -11,12 +11,29 @@ from fittrackee import appLog from .exceptions import InvalidSignatureException +from .models import Actor VALID_DATE_DELTA = 30 # in seconds VALID_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' VALID_SIG_KEYS = ['keyId', 'headers', 'signature'] +def signature_header(host: str, path: str, date_str: str, actor: Actor) -> str: + signed_string = ( + f'(request-target): post {path}\nhost: {host}\ndate: {date_str}' + ) + key = RSA.import_key(actor.private_key) + key_signer = pkcs1_15.new(key) + encoded_string = signed_string.encode('utf-8') + h = SHA256.new(encoded_string) + signature = base64.b64encode(key_signer.sign(h)) + return ( + f'keyId="{actor.activitypub_id}",' + 'headers="(request-target) host date",' + f'signature="' + signature.decode() + '"' + ) + + class SignatureVerification: def __init__(self, request: Request, signature_dict: Dict): self.request = request diff --git a/fittrackee/federation/tasks.py b/fittrackee/federation/tasks.py new file mode 100644 index 000000000..f1c9f8c51 --- /dev/null +++ b/fittrackee/federation/tasks.py @@ -0,0 +1,20 @@ +from typing import Dict, List + +from fittrackee import appLog, dramatiq +from fittrackee.federation.exceptions import SenderNotFoundException +from fittrackee.federation.inbox import send_to_remote_user_inbox +from fittrackee.federation.models import Actor + + +@dramatiq.actor(queue_name='fittrackee_users_inbox') +def send_to_users_inbox( + sender_id: int, activity: Dict, recipients: List +) -> None: + sender = Actor.query.filter_by(id=sender_id).first() + if not sender: + appLog.error('Sender not found when sending to users inbox.') + raise SenderNotFoundException() + for recipient in recipients: + send_to_remote_user_inbox( + sender=sender, activity=activity, recipient=recipient + ) diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 3d23cbddf..7d3c763ce 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,4 +1,6 @@ -from typing import Tuple +import re +from typing import Optional, Tuple +from uuid import uuid4 from Crypto.PublicKey import RSA from flask import current_app @@ -35,3 +37,16 @@ def get_ap_url(username: str, url_type: str) -> str: if url_type == 'shared_inbox': return f'{ap_url}inbox' raise Exception('Invalid \'url_type\'.') + + +def remove_url_scheme(url: str) -> str: + return re.sub(r'https?://', '', url) + + +def generate_activity_id() -> str: + return f"{current_app.config['UI_URL']}/{uuid4()}" + + +def get_username_and_domain(full_name: str) -> Optional[re.Match]: + full_name_pattern = r'([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})' + return re.match(full_name_pattern, full_name) diff --git a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py new file mode 100644 index 000000000..3e0bb569f --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -0,0 +1,104 @@ +from datetime import datetime +from json import dumps +from unittest.mock import Mock, patch +from urllib.parse import urlparse + +from _pytest.logging import LogCaptureFixture +from flask import Flask +from freezegun import freeze_time + +from fittrackee.federation.inbox import send_to_remote_user_inbox +from fittrackee.federation.models import Actor + +from ...test_case_mixins import BaseTestMixin +from ...utils import generate_response, get_date_string, random_string + + +class TestSendToRemoteInbox(BaseTestMixin): + @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.requests') + def test_it_calls_signature_header( + self, + requests_mock: Mock, + signature_header_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + now = datetime.utcnow() + parsed_inbox_url = urlparse(remote_actor.inbox_url) + requests_mock.post.return_value = generate_response(status_code=200) + + with freeze_time(now): + send_to_remote_user_inbox( + sender=actor_1, activity={'foo': 'bar'}, recipient=remote_actor + ) + + signature_header_mock.assert_called_with( + host=parsed_inbox_url.netloc, + path=parsed_inbox_url.path, + date_str=get_date_string(now), + actor=actor_1, + ) + + @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.requests') + def test_it_calls_requests_post( + self, + requests_mock: Mock, + signature_header_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + activity = {'foo': 'bar'} + now = datetime.utcnow() + parsed_inbox_url = urlparse(remote_actor.inbox_url) + requests_mock.post.return_value = generate_response(status_code=200) + signed_header = random_string() + signature_header_mock.return_value = signed_header + + with freeze_time(now): + send_to_remote_user_inbox( + sender=actor_1, activity=activity, recipient=remote_actor + ) + + requests_mock.post.assert_called_with( + remote_actor.inbox_url, + data=dumps(activity), + headers={ + "Host": parsed_inbox_url.netloc, + "Date": get_date_string(now), + "Signature": signed_header, + "Content-Type": "application/ld+json", + }, + ) + + @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.requests') + def test_it_logs_error_if_remote_inbox_returns_error( + self, + requests_mock: Mock, + signature_header_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + caplog: LogCaptureFixture, + ) -> None: + status_code = 404 + content = 'error' + requests_mock.post.return_value = generate_response( + status_code=status_code, content=content + ) + + send_to_remote_user_inbox( + sender=actor_1, activity={'foo': 'bar'}, recipient=remote_actor + ) + + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == 'ERROR' + assert caplog.records[0].message == ( + f"Error when send to user inbox '{remote_actor.inbox_url}', " + f"status code: {status_code}, " + f"content: {content}" + ) diff --git a/fittrackee/tests/federation/federation/test_federation_tasks.py b/fittrackee/tests/federation/federation/test_federation_tasks.py new file mode 100644 index 000000000..e72ffb4d4 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_tasks.py @@ -0,0 +1,66 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.federation.exceptions import SenderNotFoundException +from fittrackee.federation.models import Actor +from fittrackee.federation.tasks import send_to_users_inbox +from fittrackee.users.models import FollowRequest + +from ...utils import random_domain_with_scheme, random_string + + +class TestSendToUsersInbox: + def test_it_raises_error_if_sender_does_not_exist( + self, + app_with_federation: Flask, + follow_request_from_user_1_to_user_2: FollowRequest, + remote_actor: Actor, + ) -> None: + with pytest.raises(SenderNotFoundException): + send_to_users_inbox( + sender_id=0, + activity=follow_request_from_user_1_to_user_2.get_activity(), + recipients=[remote_actor.inbox_url], + ) + + @patch('fittrackee.federation.tasks.send_to_remote_user_inbox') + def test_it_calls_send_to_remote_user_inbox( + self, + send_to_remote_user_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + activity = {'foo': 'bar'} + send_to_users_inbox( + sender_id=actor_1.id, + activity=activity, + recipients=[remote_actor.inbox_url], + ) + + send_to_remote_user_inbox_mock.assert_called_with( + sender=actor_1, activity=activity, recipient=remote_actor.inbox_url + ) + + @patch('fittrackee.federation.tasks.send_to_remote_user_inbox') + def test_it_calls_send_to_remote_user_inbox_for_each_recipent( + self, + send_to_remote_user_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + nb_recipients = 3 + recipients = [ + f'{random_domain_with_scheme}/{random_string()}/inbox' + for _ in range(nb_recipients) + ] + + send_to_users_inbox( + sender_id=actor_1.id, + activity={}, + recipients=recipients, + ) + + assert send_to_remote_user_inbox_mock.call_count == nb_recipients diff --git a/fittrackee/tests/federation/users/test_signature.py b/fittrackee/tests/federation/users/test_signature.py index 603eb67ed..7abd05b36 100644 --- a/fittrackee/tests/federation/users/test_signature.py +++ b/fittrackee/tests/federation/users/test_signature.py @@ -18,15 +18,10 @@ SignatureVerification, ) -from ...utils import generate_response, random_string +from ...utils import generate_response, get_date_string, random_string class SignatureVerificationTestCase: - @staticmethod - def get_date_string(date: Optional[datetime] = None) -> str: - date = date if date else datetime.utcnow() - return date.strftime(VALID_DATE_FORMAT) - @staticmethod def random_signature() -> str: return str(base64.b64encode(random_string().encode())) @@ -46,7 +41,7 @@ def generate_headers( f'signature="' + signature + '"' ) if date_str is None: - date_str = self.get_date_string(date) + date_str = get_date_string(date) return { 'Host': host if host else random_string(), 'Date': date_str, @@ -116,7 +111,7 @@ def test_it_raises_error_if_a_signature_key_is_missing( request_with_empty_headers = self.get_request_mock( headers={ 'Host': random_string(), - 'Date': self.get_date_string(), + 'Date': get_date_string(), 'Signature': input_signature_headers, } ) @@ -131,7 +126,7 @@ def test_it_instantiates_signature_verification(self) -> None: f'keyId="{key_id}",headers="(request-target) host date",' f'signature="' + signature + '"' ) - date_str = self.get_date_string() + date_str = get_date_string() valid_request_mock = self.get_request_mock( headers={ 'Host': random_string(), diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index d28ba9358..4dca3533f 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -1,13 +1,14 @@ import json from datetime import datetime +from unittest.mock import Mock, patch from flask import Flask -from fittrackee.federation.models import Actor +from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import FollowRequest -from ...test_case_mixins import ApiTestCaseMixin -from ...utils import random_string +from ...test_case_mixins import ApiTestCaseMixin, BaseTestMixin +from ...utils import random_domain, random_string class TestFollowWithFederation(ApiTestCaseMixin): @@ -102,3 +103,159 @@ def test_it_returns_success_if_follow_request_already_exists( == f"Follow request to user '{actor_2.preferred_username}' " f"is sent." ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_does_not_call_send_to_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + client.post( + f'/api/users/{actor_2.preferred_username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + send_to_users_inbox_mock.send.assert_not_called() + + +class TestRemoteFollowWithFederation(BaseTestMixin, ApiTestCaseMixin): + """Follow user from another instance""" + + def test_it_raise_error_if_remote_actor_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + remote_account = f'{random_string()}@{random_domain()}' + + response = client.post( + f'/api/users/{remote_account}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domain( # noqa + self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + remote_account = f'{random_string()}@{remote_domain.name}' + + response = client.post( + f'/api/users/{remote_account}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + @patch('fittrackee.federation.tasks.send_to_users_inbox') + def test_it_creates_follow_request( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + remote_account = ( + f'{remote_actor.preferred_username}@{remote_actor.domain.name}' + ) + + response = client.post( + f'/api/users/{remote_account}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{remote_account}' is sent." + ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + remote_account = ( + f'{remote_actor.preferred_username}@{remote_actor.domain.name}' + ) + + response = client.post( + f'/api/users/{remote_account}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{remote_account}' is sent." + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_calls_send_to_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + remote_account = ( + f'{remote_actor.preferred_username}@{remote_actor.domain.name}' + ) + + client.post( + f'/api/users/{remote_account}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + send_to_users_inbox_mock.send.assert_called_once() + self.assert_call_args_keys_equal( + send_to_users_inbox_mock.send, + ['sender_id', 'activity', 'recipients'], + ) + call_args = self.get_call_kwargs(send_to_users_inbox_mock.send) + assert call_args['sender_id'] == actor_1.id + assert call_args['recipients'] == [remote_actor.inbox_url] + follow_request = FollowRequest.query.filter_by( + follower_user_id=actor_1.user.id, + followed_user_id=remote_actor.user.id, + ).first() + activity = follow_request.get_activity() + del activity['id'] + self.assert_dict_contains_subset(call_args['activity'], activity) diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index c243d8f9b..8ab584f91 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -1,5 +1,6 @@ from flask import Flask +from fittrackee.federation.constants import AP_CTX from fittrackee.federation.models import Actor from fittrackee.users.models import FollowRequest @@ -21,3 +22,21 @@ def test_follow_request_model( ) assert serialized_follow_request['from_user'] == actor_1.serialize() assert serialized_follow_request['to_user'] == actor_2.serialize() + + def test_it_returns_activity_object_when_federation_is_enabled( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + activity_object = follow_request_from_user_1_to_user_2.get_activity() + + assert app_with_federation.config['UI_URL'] in activity_object['id'] + expected_object_subset = { + '@context': AP_CTX, + 'type': 'Follow', + 'actor': actor_1.user.get_user_url(), + 'object': f'https://{actor_2.activitypub_id}', + } + assert {**activity_object, **expected_object_subset} == activity_object diff --git a/fittrackee/tests/test_case_mixins.py b/fittrackee/tests/test_case_mixins.py index e62fffccc..d485bfd66 100644 --- a/fittrackee/tests/test_case_mixins.py +++ b/fittrackee/tests/test_case_mixins.py @@ -1,10 +1,32 @@ import json -from typing import Any, Tuple +import sys +from typing import Any, Dict, List, Tuple +from unittest.mock import Mock from flask import Flask from flask.testing import FlaskClient +class BaseTestMixin: + @staticmethod + def get_call_kwargs(mock: Mock) -> Dict: + return ( + mock.call_args[1] + if sys.version_info < (3, 8, 0) + else mock.call_args.kwargs + ) + + def assert_call_args_keys_equal( + self, mock: Mock, expected_keys: List + ) -> None: + args_list = self.get_call_kwargs(mock) + assert list(args_list.keys()) == expected_keys + + @staticmethod + def assert_dict_contains_subset(container: Dict, subset: Dict) -> None: + assert subset.items() <= container.items() + + class ApiTestCaseMixin: @staticmethod def get_test_client_and_auth_token( diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index 6b8eed6e7..5163ea6d2 100644 --- a/fittrackee/tests/users/test_users_follow_api.py +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from unittest.mock import Mock, patch from flask import Flask @@ -66,3 +67,44 @@ def test_it_creates_follow_request( data['message'] == f"Follow request to user '{user_2.username}' is sent." ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert ( + data['message'] + == f"Follow request to user '{user_2.username}' is sent." + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_does_not_call_send_to_inbox( + self, + send_to_users_inbox_mock: Mock, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + send_to_users_inbox_mock.send.assert_not_called() diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index c75670785..08a2e36b2 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -3,6 +3,7 @@ import pytest from flask import Flask +from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.users.exceptions import ( FollowRequestAlreadyProcessedError, NotExistingFollowRequestError, @@ -103,6 +104,17 @@ def test_follow_request_model( assert serialized_follow_request['from_user'] == user_1.serialize() assert serialized_follow_request['to_user'] == user_2.serialize() + def test_it_raises_error_if_getting_activity_object_when_federation_is_disabled( # noqa + self, + app: Flask, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + with pytest.raises( + FederationDisabledException, + match='Can not create activity, federation is disabled.', + ): + follow_request_from_user_1_to_user_2.get_activity() + class TestUserFollowingModel: def test_user_2_sends_follow_requests_to_user_1( diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 4e830dbb0..3711f4c7d 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -1,10 +1,13 @@ import random import string +from datetime import datetime from json import dumps from typing import Dict, Optional, Union from requests import Response +from fittrackee.federation.signature import VALID_DATE_FORMAT + def random_string( length: Optional[int] = None, @@ -24,10 +27,19 @@ def random_string( ) -def random_domain() -> str: +def random_domain_with_scheme() -> str: return random_string(prefix='https://', suffix='.social') +def random_domain() -> str: + return random_string(suffix='.social') + + +def get_date_string(date: Optional[datetime] = None) -> str: + date = date if date else datetime.utcnow() + return date.strftime(VALID_DATE_FORMAT) + + def generate_response( content: Optional[Union[str, Dict]] = None, status_code: Optional[int] = None, diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 346fdaacb..5c3064644 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -8,7 +8,12 @@ from sqlalchemy.sql.expression import select from fittrackee import BaseModel, bcrypt, db +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.federation.models import Actor +from fittrackee.federation.tasks import send_to_users_inbox +from fittrackee.federation.utils import generate_activity_id from fittrackee.workouts.models import Workout from .exceptions import ( @@ -62,6 +67,17 @@ def serialize(self) -> Dict: 'to_user': self.to_user.serialize(), } + def get_activity(self) -> Dict: + if not current_app.config['federation_enabled']: + raise FederationDisabledException() + return { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': self.from_user.get_user_url(), + 'object': f'https://{self.to_user.actor.activitypub_id}', + } + class User(BaseModel): __tablename__ = 'users' @@ -180,6 +196,14 @@ def send_follow_request_to(self, target: 'User') -> FollowRequest: ) db.session.add(follow_request) db.session.commit() + + if current_app.config['federation_enabled'] and target.actor.is_remote: + send_to_users_inbox.send( + sender_id=self.actor.id, + activity=follow_request.get_activity(), + recipients=[target.actor.inbox_url], + ) + return follow_request def _processes_follow_request_from( @@ -209,6 +233,10 @@ def refuses_follow_request_from(self, user: 'User') -> FollowRequest: ) return follow_request + def get_user_url(self) -> str: + """Return user url on user interface""" + return f"{current_app.config['UI_URL']}/users/{self.username}" + def serialize(self) -> Dict: sports = [] total = (0, '0:00:00') diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 12957a523..ac37557f6 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -7,6 +7,8 @@ from sqlalchemy import exc from fittrackee import db +from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.utils import get_username_and_domain from fittrackee.files import get_absolute_file_path from fittrackee.responses import ( ForbiddenErrorResponse, @@ -606,7 +608,22 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: 'status': 'success', 'message': f"Follow request to user '{user_name}' is sent.", } - target_user = User.query.filter_by(username=user_name).first() + + user_name_and_domain = get_username_and_domain(user_name) + if user_name_and_domain is None: # local actor + target_user = User.query.filter_by(username=user_name).first() + else: # remote actor + name, domain_name = user_name_and_domain.groups() + domain = Domain.query.filter_by(name=domain_name).first() + if not domain: + return UserNotFoundErrorResponse() + actor = Actor.query.filter_by( + preferred_username=name, domain_id=domain.id + ).first() + if not actor: + return UserNotFoundErrorResponse() + target_user = actor.user + if target_user: existing_follow_request = FollowRequest.query.filter_by( follower_user_id=auth_user.id, followed_user_id=target_user.id From 20507cba0c768c77dd223851dbfee07afadf5d90 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:18:08 +0100 Subject: [PATCH 023/238] API - init user inbox (wip) --- fittrackee/federation/inbox.py | 46 ++++- fittrackee/federation/utils.py | 12 +- .../federation/users/test_users_inbox.py | 167 ++++++++++++++++++ fittrackee/users/users.py | 10 ++ 4 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 fittrackee/tests/federation/users/test_users_inbox.py diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index 47baa996e..2265dfd3c 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -1,14 +1,54 @@ from datetime import datetime from json import dumps -from typing import Dict +from typing import Dict, Optional, Union from urllib.parse import urlparse import requests +from flask import Request from fittrackee import appLog +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + UnauthorizedErrorResponse, + UserNotFoundErrorResponse, +) -from .models import Actor -from .signature import VALID_DATE_FORMAT, signature_header +from .exceptions import InvalidSignatureException +from .models import Actor, Domain +from .signature import ( + VALID_DATE_FORMAT, + SignatureVerification, + signature_header, +) +from .utils import is_invalid_activity_data + + +def inbox( + request: Request, app_domain: Domain, username: Optional[str] +) -> Union[Dict, HttpResponse]: + # if user inbox + if username: + recipient = Actor.query.filter_by( + preferred_username=username, + domain_id=app_domain.id, + ).first() + if not recipient: + return UserNotFoundErrorResponse() + + activity_data = request.get_json() + if not activity_data or is_invalid_activity_data(activity_data): + return InvalidPayloadErrorResponse() + + try: + signature_verification = SignatureVerification.get_signature(request) + signature_verification.verify() + except InvalidSignatureException: + return UnauthorizedErrorResponse(message='Invalid signature.') + + # TODO handle activity + + return {'status': 'success'} def send_to_remote_user_inbox( diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 7d3c763ce..619af654a 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,10 +1,12 @@ import re -from typing import Optional, Tuple +from typing import Dict, Optional, Tuple from uuid import uuid4 from Crypto.PublicKey import RSA from flask import current_app +from .enums import ActivityType + def generate_keys() -> Tuple[str, str]: """ @@ -50,3 +52,11 @@ def generate_activity_id() -> str: def get_username_and_domain(full_name: str) -> Optional[re.Match]: full_name_pattern = r'([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})' return re.match(full_name_pattern, full_name) + + +def is_invalid_activity_data(activity_data: Dict) -> bool: + return ( + 'type' not in activity_data + or 'object' not in activity_data + or activity_data['type'] not in [a.value for a in ActivityType] + ) diff --git a/fittrackee/tests/federation/users/test_users_inbox.py b/fittrackee/tests/federation/users/test_users_inbox.py new file mode 100644 index 000000000..e19e7af86 --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_inbox.py @@ -0,0 +1,167 @@ +import json +from typing import Dict +from unittest.mock import patch + +import pytest +import requests +from flask import Flask + +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.models import Actor +from fittrackee.federation.signature import signature_header + +from ...test_case_mixins import ApiTestCaseMixin +from ...utils import ( + generate_response, + get_date_string, + random_domain, + random_string, +) + + +class TestUserInbox(ApiTestCaseMixin): + def test_it_returns_404_if_user_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.post( + f'/api/users/{random_string()}/inbox', + content_type='application/json', + data=json.dumps({}), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + @pytest.mark.parametrize( + 'input_description, input_activity', + [ + ('empty dict', {}), + ( + 'missing object', + {'type': ActivityType.FOLLOW.value}, + ), + ('missing type', {'object': random_string()}), + ( + 'invalid type', + {'type': random_string(), 'object': random_string()}, + ), + ], + ) + def test_it_returns_400_if_activity_is_invalid( + self, + app_with_federation: Flask, + actor_1: Actor, + input_description: str, + input_activity: Dict, + ) -> None: + client = app_with_federation.test_client() + + response = client.post( + f'/api/users/{actor_1.preferred_username}/inbox', + content_type='application/json', + data=json.dumps(input_activity), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'invalid payload' in data['message'] + + def test_it_returns_401_if_headers_are_missing( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + follow_activity = { + '@context': AP_CTX, + 'id': random_string(), + 'type': ActivityType.FOLLOW.value, + 'actor': random_string(), + 'object': random_string(), + } + + response = client.post( + f'/api/users/{actor_1.preferred_username}/inbox', + content_type='application/json', + data=json.dumps(follow_activity), + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Invalid signature.' in data['message'] + + def test_it_returns_401_if_signature_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + follow_activity = { + '@context': AP_CTX, + 'id': random_string(), + 'type': ActivityType.FOLLOW.value, + 'actor': random_string(), + 'object': random_string(), + } + + response = client.post( + f'/api/users/{actor_1.preferred_username}/inbox', + content_type='application/json', + headers={ + 'Host': random_string(), + 'Date': random_string(), + 'Signature': random_string(), + }, + data=json.dumps(follow_activity), + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Invalid signature.' in data['message'] + + def test_it_returns_200_if_activity_and_signature_are_valid( + self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> None: + actor_2.generate_keys() + host = random_domain() + date_str = get_date_string() + client = app_with_federation.test_client() + path = f'/api/users/{actor_1.preferred_username}/inbox' + follow_activity = { + '@context': AP_CTX, + 'id': random_string(), + 'type': ActivityType.FOLLOW.value, + 'actor': actor_2.activitypub_id, + 'object': actor_1.activitypub_id, + } + + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_2.serialize(), + ) + requests_mock.path = path + response = client.post( + path, + content_type='application/json', + headers={ + 'Host': host, + 'Date': date_str, + 'Signature': signature_header( + host=host, + path=path, + date_str=date_str, + actor=actor_2, + ), + 'Content-Type': 'application/ld+json', + }, + data=json.dumps(follow_activity), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index ac37557f6..3c547f2c0 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -7,6 +7,8 @@ from sqlalchemy import exc from fittrackee import db +from fittrackee.federation.decorators import federation_required +from fittrackee.federation.inbox import inbox from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import get_username_and_domain from fittrackee.files import get_absolute_file_path @@ -639,3 +641,11 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: return successful_response_dict return UserNotFoundErrorResponse() + + +@users_blueprint.route('/users//inbox', methods=['POST']) +@federation_required +def user_inbox( + app_domain: Domain, user_name: str +) -> Union[Dict, HttpResponse]: + return inbox(request, app_domain, user_name) From d8919b378bcc01e78b7acff8daec55bb7b20e17d Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:26:16 +0100 Subject: [PATCH 024/238] API - handle Follow activity in user inbox --- fittrackee/federation/activities.py | 45 ++++++ fittrackee/federation/exceptions.py | 18 +++ fittrackee/federation/inbox.py | 3 +- fittrackee/federation/tasks/__init__.py | 0 fittrackee/federation/tasks/activity.py | 12 ++ .../{tasks.py => tasks/user_inbox.py} | 0 fittrackee/federation/utils.py | 16 +- .../federation/test_federation_activities.py | 151 ++++++++++++++++++ .../test_federation_tasks_activity.py | 28 ++++ ...py => test_federation_tasks_user_inbox.py} | 8 +- .../federation/users/test_users_follow_api.py | 2 +- .../federation/users/test_users_inbox.py | 109 ++++++++----- fittrackee/users/exceptions.py | 4 + fittrackee/users/models.py | 2 +- fittrackee/users/users.py | 26 +-- fittrackee/users/utils/follow.py | 15 ++ 16 files changed, 380 insertions(+), 59 deletions(-) create mode 100644 fittrackee/federation/activities.py create mode 100644 fittrackee/federation/tasks/__init__.py create mode 100644 fittrackee/federation/tasks/activity.py rename fittrackee/federation/{tasks.py => tasks/user_inbox.py} (100%) create mode 100644 fittrackee/tests/federation/federation/test_federation_activities.py create mode 100644 fittrackee/tests/federation/federation/test_federation_tasks_activity.py rename fittrackee/tests/federation/federation/{test_federation_tasks.py => test_federation_tasks_user_inbox.py} (86%) create mode 100644 fittrackee/users/utils/follow.py diff --git a/fittrackee/federation/activities.py b/fittrackee/federation/activities.py new file mode 100644 index 000000000..953ee250a --- /dev/null +++ b/fittrackee/federation/activities.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod +from typing import Dict + +from fittrackee import appLog +from fittrackee.federation.exceptions import ActorNotFoundException +from fittrackee.federation.models import Actor +from fittrackee.users.exceptions import FollowRequestAlreadyRejectedError +from fittrackee.users.utils.follow import create_follow_request + + +class AbstractActivity(ABC): + def __init__(self, activity_dict: Dict) -> None: + self.activity = activity_dict + + @abstractmethod + def process_activity(self) -> None: + pass + + +class FollowActivity(AbstractActivity): + def process_activity(self) -> None: + followed_actor = Actor.query.filter_by( + activitypub_id=self.activity['object'] + ).first() + if not followed_actor: + raise ActorNotFoundException( + message='followed actor not found for Follow Activity' + ) + + follower_actor = Actor.query.filter_by( + activitypub_id=self.activity['actor'] + ).first() + if not follower_actor: + raise ActorNotFoundException( + message='followed actor not found for Follow Activity' + ) + + try: + create_follow_request( + follower_user_id=follower_actor.user.id, + followed_user=followed_actor.user, + ) + except FollowRequestAlreadyRejectedError as e: + appLog.error('Follow activity: follow request already rejected.') + raise e diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py index a8398aa37..c02b415f4 100644 --- a/fittrackee/federation/exceptions.py +++ b/fittrackee/federation/exceptions.py @@ -1,6 +1,16 @@ +from typing import Optional + from fittrackee.exceptions import GenericException +class ActorNotFoundException(GenericException): + def __init__(self, message: Optional[str] = None) -> None: + super().__init__( + status='error', + message=f'Actor not found{ f": {message}" if message else ""}.', + ) + + class FederationDisabledException(GenericException): def __init__(self) -> None: super().__init__( @@ -23,3 +33,11 @@ def __init__(self) -> None: status='error', message='Sender not found.', ) + + +class UnsupportedActivityException(GenericException): + def __init__(self, activity_type: str) -> None: + super().__init__( + status='error', + message=f"Unsupported activity '{activity_type}'.", + ) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index 2265dfd3c..e55ef6ee9 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -21,6 +21,7 @@ SignatureVerification, signature_header, ) +from .tasks.activity import handle_activity from .utils import is_invalid_activity_data @@ -46,7 +47,7 @@ def inbox( except InvalidSignatureException: return UnauthorizedErrorResponse(message='Invalid signature.') - # TODO handle activity + handle_activity.send(activity=activity_data) return {'status': 'success'} diff --git a/fittrackee/federation/tasks/__init__.py b/fittrackee/federation/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/federation/tasks/activity.py b/fittrackee/federation/tasks/activity.py new file mode 100644 index 000000000..2d081ff04 --- /dev/null +++ b/fittrackee/federation/tasks/activity.py @@ -0,0 +1,12 @@ +from typing import Dict + +from fittrackee import dramatiq +from fittrackee.federation.utils import get_activity_instance + + +@dramatiq.actor(queue_name='fittrackee_activities') +def handle_activity(activity: Dict) -> None: + activity = get_activity_instance({'type': activity['type']})( + activity_dict=activity + ) + activity.process_activity() # type: ignore diff --git a/fittrackee/federation/tasks.py b/fittrackee/federation/tasks/user_inbox.py similarity index 100% rename from fittrackee/federation/tasks.py rename to fittrackee/federation/tasks/user_inbox.py diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 619af654a..200848696 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,11 +1,13 @@ import re -from typing import Dict, Optional, Tuple +from importlib import import_module +from typing import Callable, Dict, Optional, Tuple from uuid import uuid4 from Crypto.PublicKey import RSA from flask import current_app from .enums import ActivityType +from .exceptions import UnsupportedActivityException def generate_keys() -> Tuple[str, str]: @@ -60,3 +62,15 @@ def is_invalid_activity_data(activity_data: Dict) -> bool: or 'object' not in activity_data or activity_data['type'] not in [a.value for a in ActivityType] ) + + +def get_activity_instance(activity_dict: Dict) -> Callable: + activity_type = activity_dict["type"] + try: + Activity = getattr( + import_module('fittrackee.federation.activities'), + f'{activity_type}Activity', + ) + except AttributeError: + raise UnsupportedActivityException(activity_type) + return Activity diff --git a/fittrackee/tests/federation/federation/test_federation_activities.py b/fittrackee/tests/federation/federation/test_federation_activities.py new file mode 100644 index 000000000..91211b771 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_activities.py @@ -0,0 +1,151 @@ +import pytest +from flask import Flask + +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + UnsupportedActivityException, +) +from fittrackee.federation.models import Actor +from fittrackee.federation.utils import ( + generate_activity_id, + get_activity_instance, +) +from fittrackee.users.exceptions import FollowRequestAlreadyRejectedError +from fittrackee.users.models import FollowRequest + +from ...utils import random_domain_with_scheme, random_string + +SUPPORTED_ACTIVITIES = [(f'{a.value} activity', a.value) for a in ActivityType] + + +class TestActivityInstantiation: + @pytest.mark.parametrize( + 'input_description,input_type', SUPPORTED_ACTIVITIES + ) + def test_it_instantiates_activity_from_activity_dict( + self, input_description: str, input_type: str + ) -> None: + activity = get_activity_instance({'type': input_type}) + activity_instance = activity(activity_dict={}) + assert activity_instance.__class__.__name__ == f'{input_type}Activity' + + def test_it_raises_exception_if_activity_type_is_invalid(self) -> None: + with pytest.raises(UnsupportedActivityException): + get_activity_instance({'type': random_string()}) + + +class TestFollowActivity: + def test_it_raises_error_if_target_actor_does_not_exists( + self, app_with_federation: Flask, remote_actor: Actor + ) -> None: + follow_activity = { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': remote_actor.activitypub_id, + 'object': f'{random_domain_with_scheme}/users/{random_string()}', + } + + activity = get_activity_instance({'type': follow_activity['type']})( + activity_dict=follow_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='followed actor not found for Follow Activity', + ): + activity.process_activity() + + def test_it_raises_error_if_remote_actor_does_not_exists( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + follow_activity = { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': f'{random_domain_with_scheme}/users/{random_string()}', + 'object': actor_1.activitypub_id, + } + + activity = get_activity_instance({'type': follow_activity['type']})( + activity_dict=follow_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='followed actor not found for Follow Activity', + ): + activity.process_activity() + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + actor_1.user.refuses_follow_request_from(remote_actor.user) + follow_activity = { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': remote_actor.activitypub_id, + 'object': actor_1.activitypub_id, + } + + activity = get_activity_instance({'type': follow_activity['type']})( + activity_dict=follow_activity + ) + + with pytest.raises(FollowRequestAlreadyRejectedError): + activity.process_activity() + + def test_it_creates_follow_request( + self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor + ) -> None: + follow_activity = { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': remote_actor.activitypub_id, + 'object': actor_1.activitypub_id, + } + + activity = get_activity_instance({'type': follow_activity['type']})( + activity_dict=follow_activity + ) + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=remote_actor.user.id, + followed_user_id=actor_1.user.id, + ).first() + assert follow_request is not None + + def test_it_does_raise_error_if_pending_follow_request_already_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_activity = { + '@context': AP_CTX, + 'id': generate_activity_id(), + 'type': ActivityType.FOLLOW.value, + 'actor': remote_actor.activitypub_id, + 'object': actor_1.activitypub_id, + } + + activity = get_activity_instance({'type': follow_activity['type']})( + activity_dict=follow_activity + ) + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=remote_actor.user.id, + followed_user_id=actor_1.user.id, + ).first() + assert follow_request.updated_at is None diff --git a/fittrackee/tests/federation/federation/test_federation_tasks_activity.py b/fittrackee/tests/federation/federation/test_federation_tasks_activity.py new file mode 100644 index 000000000..f383b566e --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_tasks_activity.py @@ -0,0 +1,28 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from fittrackee.federation.exceptions import UnsupportedActivityException +from fittrackee.federation.tasks.activity import handle_activity + +from ...utils import random_string + + +class TestHandleActivity: + def test_it_raises_error_if_activity_not_supported(self) -> None: + with pytest.raises(UnsupportedActivityException): + handle_activity(activity={'type': random_string()}) + + @patch('fittrackee.federation.tasks.activity.get_activity_instance') + def test_it_calls_process_activity( + self, get_activity_instance_mock: Mock + ) -> None: + activity_dict = {'type': random_string()} + activity_mock = MagicMock() + activity_mock.process_activity = Mock() + get_activity_instance_mock.return_value = activity_mock + + handle_activity(activity=activity_dict) + + activity_mock.assert_called_with(activity_dict=activity_dict) + activity_mock().process_activity.assert_called() diff --git a/fittrackee/tests/federation/federation/test_federation_tasks.py b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py similarity index 86% rename from fittrackee/tests/federation/federation/test_federation_tasks.py rename to fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py index e72ffb4d4..bb7cdeb82 100644 --- a/fittrackee/tests/federation/federation/test_federation_tasks.py +++ b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py @@ -5,7 +5,7 @@ from fittrackee.federation.exceptions import SenderNotFoundException from fittrackee.federation.models import Actor -from fittrackee.federation.tasks import send_to_users_inbox +from fittrackee.federation.tasks.user_inbox import send_to_users_inbox from fittrackee.users.models import FollowRequest from ...utils import random_domain_with_scheme, random_string @@ -25,7 +25,7 @@ def test_it_raises_error_if_sender_does_not_exist( recipients=[remote_actor.inbox_url], ) - @patch('fittrackee.federation.tasks.send_to_remote_user_inbox') + @patch('fittrackee.federation.tasks.user_inbox.send_to_remote_user_inbox') def test_it_calls_send_to_remote_user_inbox( self, send_to_remote_user_inbox_mock: Mock, @@ -44,8 +44,8 @@ def test_it_calls_send_to_remote_user_inbox( sender=actor_1, activity=activity, recipient=remote_actor.inbox_url ) - @patch('fittrackee.federation.tasks.send_to_remote_user_inbox') - def test_it_calls_send_to_remote_user_inbox_for_each_recipent( + @patch('fittrackee.federation.tasks.user_inbox.send_to_remote_user_inbox') + def test_it_calls_send_to_remote_user_inbox_for_each_recipient( self, send_to_remote_user_inbox_mock: Mock, app_with_federation: Flask, diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index 4dca3533f..278b279b3 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -166,7 +166,7 @@ def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domai assert data['status'] == 'not found' assert data['message'] == 'user does not exist' - @patch('fittrackee.federation.tasks.send_to_users_inbox') + @patch('fittrackee.users.models.send_to_users_inbox') def test_it_creates_follow_request( self, send_to_users_inbox_mock: Mock, diff --git a/fittrackee/tests/federation/users/test_users_inbox.py b/fittrackee/tests/federation/users/test_users_inbox.py index e19e7af86..578fb5515 100644 --- a/fittrackee/tests/federation/users/test_users_inbox.py +++ b/fittrackee/tests/federation/users/test_users_inbox.py @@ -1,10 +1,11 @@ import json -from typing import Dict -from unittest.mock import patch +from typing import Dict, Tuple +from unittest.mock import Mock, patch import pytest import requests from flask import Flask +from werkzeug.test import TestResponse from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType @@ -19,8 +20,53 @@ random_string, ) +# Prevent pytest from collecting TestResponse as test +TestResponse.__test__ = False # type: ignore + class TestUserInbox(ApiTestCaseMixin): + @staticmethod + def post_to_user_inbox( + app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> Tuple[Dict, TestResponse]: + actor_2.generate_keys() + host = random_domain() + date_str = get_date_string() + client = app_with_federation.test_client() + inbox_path = f'/api/users/{actor_1.preferred_username}/inbox' + follow_activity: Dict = { + '@context': AP_CTX, + 'id': random_string(), + 'type': ActivityType.FOLLOW.value, + 'actor': actor_2.activitypub_id, + 'object': actor_1.activitypub_id, + } + + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_2.serialize(), + ) + requests_mock.path = inbox_path + response = client.post( + inbox_path, + content_type='application/json', + headers={ + 'Host': host, + 'Date': date_str, + 'Signature': signature_header( + host=host, + path=inbox_path, + date_str=date_str, + actor=actor_2, + ), + 'Content-Type': 'application/ld+json', + }, + data=json.dumps(follow_activity), + ) + + return follow_activity, response + def test_it_returns_404_if_user_does_not_exist( self, app_with_federation: Flask, actor_1: Actor ) -> None: @@ -123,45 +169,32 @@ def test_it_returns_401_if_signature_is_invalid( assert 'error' in data['status'] assert 'Invalid signature.' in data['message'] + @patch('fittrackee.federation.inbox.handle_activity') def test_it_returns_200_if_activity_and_signature_are_valid( - self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + self, + handle_activity: Mock, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, ) -> None: - actor_2.generate_keys() - host = random_domain() - date_str = get_date_string() - client = app_with_federation.test_client() - path = f'/api/users/{actor_1.preferred_username}/inbox' - follow_activity = { - '@context': AP_CTX, - 'id': random_string(), - 'type': ActivityType.FOLLOW.value, - 'actor': actor_2.activitypub_id, - 'object': actor_1.activitypub_id, - } - - with patch.object(requests, 'get') as requests_mock: - requests_mock.return_value = generate_response( - status_code=200, - content=actor_2.serialize(), - ) - requests_mock.path = path - response = client.post( - path, - content_type='application/json', - headers={ - 'Host': host, - 'Date': date_str, - 'Signature': signature_header( - host=host, - path=path, - date_str=date_str, - actor=actor_2, - ), - 'Content-Type': 'application/ld+json', - }, - data=json.dumps(follow_activity), - ) + _, response = self.post_to_user_inbox( + app_with_federation, actor_1, actor_2 + ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert 'success' in data['status'] + + @patch('fittrackee.federation.inbox.handle_activity') + def test_it_calls_handle_activity_task( + self, + handle_activity: Mock, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + ) -> None: + activity_dict, response = self.post_to_user_inbox( + app_with_federation, actor_1, actor_2 + ) + + handle_activity.send.assert_called_with(activity=activity_dict) diff --git a/fittrackee/users/exceptions.py b/fittrackee/users/exceptions.py index 9e1b542dd..91ec2f679 100644 --- a/fittrackee/users/exceptions.py +++ b/fittrackee/users/exceptions.py @@ -2,6 +2,10 @@ class FollowRequestAlreadyProcessedError(Exception): ... +class FollowRequestAlreadyRejectedError(Exception): + ... + + class NotExistingFollowRequestError(Exception): ... diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 5c3064644..27ed9e4fb 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -12,7 +12,7 @@ from fittrackee.federation.enums import ActivityType from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.federation.models import Actor -from fittrackee.federation.tasks import send_to_users_inbox +from fittrackee.federation.tasks.user_inbox import send_to_users_inbox from fittrackee.federation.utils import generate_activity_id from fittrackee.workouts.models import Workout diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 3c547f2c0..57a5328bc 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -23,9 +23,13 @@ from fittrackee.workouts.models import Record, Workout, WorkoutSegment from .decorators import authenticate, authenticate_as_admin -from .exceptions import UserNotFoundException -from .models import FollowRequest, User, UserSportPreference +from .exceptions import ( + FollowRequestAlreadyRejectedError, + UserNotFoundException, +) +from .models import User, UserSportPreference from .utils.admin import set_admin_rights +from .utils.follow import create_follow_request users_blueprint = Blueprint('users', __name__) @@ -627,17 +631,13 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: target_user = actor.user if target_user: - existing_follow_request = FollowRequest.query.filter_by( - follower_user_id=auth_user.id, followed_user_id=target_user.id - ).first() - if existing_follow_request: - if existing_follow_request.is_rejected(): - return ForbiddenErrorResponse() - else: - return successful_response_dict - - auth_user = User.query.filter_by(id=auth_user.id).first() - auth_user.send_follow_request_to(target_user) + try: + create_follow_request( + follower_user_id=auth_user.id, + followed_user=target_user, + ) + except FollowRequestAlreadyRejectedError: + return ForbiddenErrorResponse() return successful_response_dict return UserNotFoundErrorResponse() diff --git a/fittrackee/users/utils/follow.py b/fittrackee/users/utils/follow.py new file mode 100644 index 000000000..ef4193e86 --- /dev/null +++ b/fittrackee/users/utils/follow.py @@ -0,0 +1,15 @@ +from ..exceptions import FollowRequestAlreadyRejectedError +from ..models import FollowRequest, User + + +def create_follow_request(follower_user_id: int, followed_user: User) -> None: + existing_follow_request = FollowRequest.query.filter_by( + follower_user_id=follower_user_id, followed_user_id=followed_user.id + ).first() + if existing_follow_request: + if existing_follow_request.is_rejected(): + raise FollowRequestAlreadyRejectedError() + return + + auth_user = User.query.filter_by(id=follower_user_id).first() + auth_user.send_follow_request_to(followed_user) From f9d009c6cca676f2d282df5458d8a5fc43ffd954 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 26 Jun 2021 21:15:17 +0200 Subject: [PATCH 025/238] API - add keys generation in migration and fix actor urls --- fittrackee/federation/utils.py | 2 +- .../23_8842c351a2d8_init_federation.py | 16 ++++++++++------ .../federation/test_federation_models.py | 18 +++++++++--------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 200848696..1ef8636f1 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -32,7 +32,7 @@ def get_ap_url(username: str, url_type: str) -> str: - 'followers' - 'shared_inbox' """ - ap_url = f"{current_app.config['AP_DOMAIN']}/federation/" + ap_url = f"https://{current_app.config['AP_DOMAIN']}/federation/" ap_url_user = f'{ap_url}user/{username}' if url_type == 'user_url': return ap_url_user diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index e254f1256..fe394de27 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -11,7 +11,7 @@ import sqlalchemy as sa from alembic import op -from fittrackee.federation.utils import get_ap_url +from fittrackee.federation.utils import generate_keys, get_ap_url, remove_url_scheme # revision identifiers, used by Alembic. @@ -41,7 +41,8 @@ def upgrade(): sa.UniqueConstraint('name'), ) - domain = os.environ['UI_URL'] + # create local domain (even if federation is not enabled) + domain = remove_url_scheme(os.environ['UI_URL']) created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') op.execute( "INSERT INTO domains (name, created_at, is_allowed)" @@ -87,7 +88,7 @@ def upgrade(): 'users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id'] ) - # create local actors + # create local actors with keys (even if federation is not enabled) user_helper = sa.Table( 'users', sa.MetaData(), @@ -98,14 +99,17 @@ def upgrade(): domain = connection.execute(domain_table.select()).fetchone() for user in connection.execute(user_helper.select()): created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + public_key, private_key = generate_keys() op.execute( "INSERT INTO actors (" - "activitypub_id, domain_id, preferred_username, followers_url, " - "following_url, inbox_url, outbox_url, shared_inbox_url, " - "created_at, manually_approves_followers) " + "activitypub_id, domain_id, preferred_username, public_key, " + "private_key, followers_url, following_url, inbox_url, " + "outbox_url, shared_inbox_url, created_at, " + "manually_approves_followers) " "VALUES (" f"'{get_ap_url(user.username, 'user_url')}', " f"{domain.id}, '{user.username}', " + f"'{public_key}', '{private_key}', " f"'{get_ap_url(user.username, 'followers')}', " f"'{get_ap_url(user.username, 'following')}', " f"'{get_ap_url(user.username, 'inbox')}', " diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index eddc6c635..6a926c4fc 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -80,20 +80,20 @@ def test_it_returns_serialized_object( serialized_actor['preferredUsername'] == actor_1.preferred_username ) assert serialized_actor['name'] == actor_1.user.username - assert ( - serialized_actor['inbox'] - == f'{ap_url}/federation/user/{actor_1.preferred_username}/inbox' + assert serialized_actor['inbox'] == ( + f'https://{ap_url}/federation/user/' + f'{actor_1.preferred_username}/inbox' ) - assert ( - serialized_actor['outbox'] - == f'{ap_url}/federation/user/{actor_1.preferred_username}/outbox' + assert serialized_actor['outbox'] == ( + f'https://{ap_url}/federation/user/' + f'{actor_1.preferred_username}/outbox' ) assert serialized_actor['followers'] == ( - f'{ap_url}/federation/user/' + f'https://{ap_url}/federation/user/' f'{actor_1.preferred_username}/followers' ) assert serialized_actor['following'] == ( - f'{ap_url}/federation/user/' + f'https://{ap_url}/federation/user/' f'{actor_1.preferred_username}/following' ) assert serialized_actor['manuallyApprovesFollowers'] is True @@ -105,7 +105,7 @@ def test_it_returns_serialized_object( assert 'publicKeyPem' in serialized_actor['publicKey'] assert ( serialized_actor['endpoints']['sharedInbox'] - == f'{ap_url}/federation/inbox' + == f'https://{ap_url}/federation/inbox' ) def test_generated_key_is_valid( From 2cdcc6fdf67d04bf89e306df9d4374c41d82cd09 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 26 Jun 2021 21:55:23 +0200 Subject: [PATCH 026/238] API - create actor on user registration --- .../tests/federation/users/test_auth_api.py | 35 +++++++++++++++++++ fittrackee/tests/users/test_auth_api.py | 5 +++ fittrackee/users/auth.py | 2 ++ fittrackee/users/models.py | 13 ++++++- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 fittrackee/tests/federation/users/test_auth_api.py diff --git a/fittrackee/tests/federation/users/test_auth_api.py b/fittrackee/tests/federation/users/test_auth_api.py new file mode 100644 index 000000000..4ec21baa8 --- /dev/null +++ b/fittrackee/tests/federation/users/test_auth_api.py @@ -0,0 +1,35 @@ +import json + +from flask import Flask + +from fittrackee.users.models import User + + +def assert_actor_is_created(app: Flask) -> None: + client = app.test_client() + username = 'justatest' + + client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=username, + email='test@test.com', + password='12345678', + password_conf='12345678', + ) + ), + content_type='application/json', + ) + + user = User.query.filter_by(username=username).first() + assert user.actor.preferred_username == username + assert user.actor.public_key is not None + assert user.actor.private_key is not None + + +class TestUserRegistration: + def test_it_creates_actor_on_user_registration( + self, app_with_federation: Flask + ) -> None: + assert_actor_is_created(app=app_with_federation) diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index c68e31c6b..e71660650 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -11,6 +11,7 @@ from fittrackee.users.utils.token import get_user_token from fittrackee.workouts.models import Sport, Workout +from ..federation.users.test_auth_api import assert_actor_is_created from ..test_case_mixins import ApiTestCaseMixin @@ -38,6 +39,10 @@ def test_user_can_register(self, app: Flask) -> None: assert response.content_type == 'application/json' assert response.status_code == 201 + def test_it_creates_actor_on_user_registration(self, app: Flask) -> None: + """it must create actor even if federation is disabled""" + assert_actor_is_created(app=app) + @pytest.mark.parametrize( 'input_username', ['test', 'TEST'], diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 5855cf124..35e1c558e 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -138,6 +138,8 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]: new_user.timezone = 'Europe/Paris' db.session.add(new_user) db.session.commit() + # create actor even if federation is disabled + new_user.create_actor() # generate auth token auth_token = new_user.encode_auth_token(new_user.id) return { diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 27ed9e4fb..5d9df6d43 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -11,7 +11,7 @@ from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType from fittrackee.federation.exceptions import FederationDisabledException -from fittrackee.federation.models import Actor +from fittrackee.federation.models import Actor, Domain from fittrackee.federation.tasks.user_inbox import send_to_users_inbox from fittrackee.federation.utils import generate_activity_id from fittrackee.workouts.models import Workout @@ -237,6 +237,17 @@ def get_user_url(self) -> str: """Return user url on user interface""" return f"{current_app.config['UI_URL']}/users/{self.username}" + def create_actor(self) -> None: + app_domain = Domain.query.filter_by( + name=current_app.config['AP_DOMAIN'] + ).first() + actor = Actor(username=self.username, domain_id=app_domain.id) + db.session.add(actor) + db.session.flush() + self.actor_id = actor.id + actor.generate_keys() + db.session.commit() + def serialize(self) -> Dict: sports = [] total = (0, '0:00:00') From a130070e11303bf604d7ead7fec01ef88ec9aa61 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:53:46 +0100 Subject: [PATCH 027/238] API - create remote actor --- fittrackee/federation/federation.py | 91 ++++- fittrackee/federation/inbox.py | 8 +- fittrackee/federation/models.py | 43 ++- fittrackee/federation/remote_user.py | 16 + fittrackee/federation/tasks/user_inbox.py | 6 +- .../23_8842c351a2d8_init_federation.py | 30 +- fittrackee/tests/conftest.py | 2 + .../federation/test_federation_federation.py | 341 +++++++++++++++++- .../federation/test_federation_models.py | 13 +- .../test_federation_remote_inbox.py | 12 +- .../test_federation_tasks_user_inbox.py | 4 +- .../federation/federation/test_remote_user.py | 41 +++ .../tests/fixtures/fixtures_federation.py | 36 +- fittrackee/tests/utils.py | 54 +++ fittrackee/users/models.py | 33 +- 15 files changed, 661 insertions(+), 69 deletions(-) create mode 100644 fittrackee/federation/remote_user.py create mode 100644 fittrackee/tests/federation/federation/test_remote_user.py diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 4de1de76b..bc29e6326 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -1,6 +1,18 @@ -from flask import Blueprint +from typing import Dict, Union +from urllib.parse import urlparse -from fittrackee.responses import HttpResponse, UserNotFoundErrorResponse +from flask import Blueprint, request + +from fittrackee import db +from fittrackee.federation.exceptions import ActorNotFoundException +from fittrackee.federation.remote_user import get_remote_user +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + UserNotFoundErrorResponse, +) +from fittrackee.users.decorators import authenticate +from fittrackee.users.models import User from .decorators import federation_required from .models import Actor, Domain @@ -24,3 +36,78 @@ def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: response=actor.serialize(), content_type='application/jrd+json; charset=utf-8', ) + + +@ap_federation_blueprint.route('/remote-user', methods=['POST']) +@federation_required +@authenticate +def remote_actor( + app_domain: Domain, auth_user: User +) -> Union[Dict, HttpResponse]: + remote_actor_url = ( + request.get_json(silent=True).get('actor_url') # type: ignore + if request.get_json(silent=True) is not None + else None + ) + if not remote_actor_url: + return InvalidPayloadErrorResponse() + + # check if domain already exists + remote_domain_name = urlparse(remote_actor_url).netloc + remote_domain = Domain.query.filter_by(name=remote_domain_name).first() + if not remote_domain: + remote_domain = Domain(name=remote_domain_name) + db.session.add(remote_domain) + db.session.flush() + + if not remote_domain.is_remote: + return InvalidPayloadErrorResponse( + message='The provided account is not a remote account.' + ) + + try: + remote_actor_object = get_remote_user(remote_actor_url) + except ActorNotFoundException: + return InvalidPayloadErrorResponse( + message='Can not fetch remote actor.' + ) + + # check if actor already exists + try: + actor = Actor.query.filter_by( + preferred_username=remote_actor_object['preferredUsername'], + domain_id=remote_domain.id, + ).first() + except KeyError: + return InvalidPayloadErrorResponse( + message='Invalid remote actor object.' + ) + if not actor: + try: + actor = Actor( + preferred_username=remote_actor_object['preferredUsername'], + domain_id=remote_domain.id, + remote_user_data=remote_actor_object, + ) + except KeyError: + return InvalidPayloadErrorResponse( + message='Invalid remote actor object.' + ) + db.session.add(actor) + db.session.flush() + user = User( + username=remote_actor_object['name'], + email=None, + password=None, + ) + db.session.add(user) + user.actor_id = actor.id + else: + actor.update_remote_data(remote_actor_object) + actor.user.username = remote_actor_object['name'] + db.session.commit() + + return HttpResponse( + response=actor.serialize(), + content_type='application/jrd+json; charset=utf-8', + ) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index e55ef6ee9..50c2f0649 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -53,10 +53,10 @@ def inbox( def send_to_remote_user_inbox( - sender: Actor, activity: Dict, recipient: Actor + sender: Actor, activity: Dict, recipient_inbox_url: str ) -> None: now_str = datetime.utcnow().strftime(VALID_DATE_FORMAT) - parsed_inbox_url = urlparse(recipient.inbox_url) + parsed_inbox_url = urlparse(recipient_inbox_url) signed_header = signature_header( host=parsed_inbox_url.netloc, path=parsed_inbox_url.path, @@ -64,7 +64,7 @@ def send_to_remote_user_inbox( actor=sender, ) response = requests.post( - recipient.inbox_url, + recipient_inbox_url, data=dumps(activity), headers={ "Host": parsed_inbox_url.netloc, @@ -75,7 +75,7 @@ def send_to_remote_user_inbox( ) if response.status_code >= 400: appLog.error( - f"Error when send to user inbox '{recipient.inbox_url}', " + f"Error when send to user inbox '{recipient_inbox_url}', " f"status code: {response.status_code}, " f"content: {response.content.decode()}" ) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index fd858f9cd..50090e82f 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -84,30 +84,25 @@ def __str__(self) -> str: def __init__( self, - username: str, + preferred_username: str, domain_id: int, created_at: Optional[datetime] = datetime.utcnow(), remote_user_data: Optional[Dict] = None, ) -> None: self.created_at = created_at self.domain_id = domain_id - self.preferred_username = username + self.preferred_username = preferred_username if remote_user_data: - self.activitypub_id = remote_user_data['id'] - self.followers_url = remote_user_data['followers'] - self.following_url = remote_user_data['following'] - self.inbox_url = remote_user_data['inbox'] - self.outbox_url = remote_user_data['outbox'] - self.shared_inbox_url = remote_user_data.get('endpoints', {}).get( - 'sharedInbox' - ) + self.update_remote_data(remote_user_data) else: - self.activitypub_id = get_ap_url(username, 'user_url') - self.followers_url = get_ap_url(username, 'followers') - self.following_url = get_ap_url(username, 'following') - self.inbox_url = get_ap_url(username, 'inbox') - self.outbox_url = get_ap_url(username, 'outbox') - self.shared_inbox_url = get_ap_url(username, 'shared_inbox') + self.activitypub_id = get_ap_url(preferred_username, 'user_url') + self.followers_url = get_ap_url(preferred_username, 'followers') + self.following_url = get_ap_url(preferred_username, 'following') + self.inbox_url = get_ap_url(preferred_username, 'inbox') + self.outbox_url = get_ap_url(preferred_username, 'outbox') + self.shared_inbox_url = get_ap_url( + preferred_username, 'shared_inbox' + ) def generate_keys(self) -> None: self.public_key, self.private_key = generate_keys() @@ -122,6 +117,22 @@ def name(self) -> Optional[str]: return self.user.username return None + def update_remote_data(self, remote_user_data: Dict) -> None: + self.activitypub_id = remote_user_data['id'] + self.type = ActorType(remote_user_data['type']) + self.manually_approves_followers = remote_user_data[ + 'manuallyApprovesFollowers' + ] + self.followers_url = remote_user_data['followers'] + self.following_url = remote_user_data['following'] + self.inbox_url = remote_user_data['inbox'] + self.outbox_url = remote_user_data['outbox'] + self.shared_inbox_url = remote_user_data.get('endpoints', {}).get( + 'sharedInbox' + ) + self.public_key = remote_user_data['publicKey']['publicKeyPem'] + self.last_fetch_date = datetime.utcnow() + def serialize(self) -> Dict: return { '@context': AP_CTX, diff --git a/fittrackee/federation/remote_user.py b/fittrackee/federation/remote_user.py new file mode 100644 index 000000000..675b0f377 --- /dev/null +++ b/fittrackee/federation/remote_user.py @@ -0,0 +1,16 @@ +from typing import Dict + +import requests + +from fittrackee.federation.exceptions import ActorNotFoundException + + +def get_remote_user(actor_url: str) -> Dict: + response = requests.get( + actor_url, + headers={'Accept': 'application/activity+json'}, + ) + if response.status_code >= 400: + raise ActorNotFoundException() + + return response.json() diff --git a/fittrackee/federation/tasks/user_inbox.py b/fittrackee/federation/tasks/user_inbox.py index f1c9f8c51..e53b36b07 100644 --- a/fittrackee/federation/tasks/user_inbox.py +++ b/fittrackee/federation/tasks/user_inbox.py @@ -14,7 +14,9 @@ def send_to_users_inbox( if not sender: appLog.error('Sender not found when sending to users inbox.') raise SenderNotFoundException() - for recipient in recipients: + for recipient_inbox_url in recipients: send_to_remote_user_inbox( - sender=sender, activity=activity, recipient=recipient + sender=sender, + activity=activity, + recipient_inbox_url=recipient_inbox_url, ) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index fe394de27..532d51bf9 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -87,7 +87,19 @@ def upgrade(): op.create_foreign_key( 'users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id'] ) - + op.drop_constraint('users_username_key', 'users', type_='unique') + op.alter_column( + 'users', 'username', existing_type=sa.String(length=20), + type_=sa.String(length=50), existing_nullable=False + ) + # user email and password are empty for remote actors + op.alter_column( + 'users', 'email', existing_type=sa.VARCHAR(length=120), nullable=True + ) + op.alter_column( + 'users', 'password', existing_type=sa.VARCHAR(length=255), + nullable=True + ) # create local actors with keys (even if federation is not enabled) user_helper = sa.Table( 'users', @@ -125,6 +137,9 @@ def upgrade(): op.execute( f'UPDATE users SET actor_id = {actor.id} WHERE users.id = {user.id}' ) + op.create_unique_constraint( + 'username_actor_id_unique', 'users', ['username', 'actor_id'] + ) op.create_table( 'follow_requests', @@ -148,12 +163,25 @@ def upgrade(): def downgrade(): op.drop_table('follow_requests') + op.drop_constraint('username_actor_id_unique', 'users', type_='unique') + op.alter_column( + 'users', 'username', existing_type=sa.String(length=50), + type_=sa.String(length=20), existing_nullable=False + ) + op.alter_column( + 'users', 'password', existing_type=sa.VARCHAR(length=255), + nullable=False + ) + op.alter_column( + 'users', 'email', existing_type=sa.VARCHAR(length=120), nullable=False + ) op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') op.drop_constraint('users_actor_id_key', 'users', type_='unique') op.drop_column('users', 'actor_id') op.drop_table('actors') op.execute('DROP TYPE actor_types') + op.create_unique_constraint('users_username_key', 'users', ['username']) op.drop_table('domains') diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index a36ba6d9f..ae34cb95d 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -2,6 +2,8 @@ os.environ['FLASK_ENV'] = 'testing' os.environ['APP_SETTINGS'] = 'fittrackee.config.TestingConfig' +os.environ['UI_URL'] = 'https://0.0.0.0:5000' +os.environ['SENDER_EMAIL'] = 'fittrackee@example.com' # to avoid resetting dev database during tests os.environ['DATABASE_URL'] = os.environ['DATABASE_TEST_URL'] diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index 6b840af3d..f8eb3d1fa 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -1,9 +1,20 @@ import json +from unittest.mock import patch from uuid import uuid4 from flask import Flask -from fittrackee.federation.models import Actor +from fittrackee.federation.exceptions import ActorNotFoundException +from fittrackee.federation.models import Actor, Domain +from fittrackee.users.models import User + +from ...test_case_mixins import ApiTestCaseMixin +from ...utils import ( + get_remote_user_object, + random_actor_url, + random_domain_with_scheme, + random_string, +) class TestFederationUser: @@ -57,3 +68,331 @@ def test_it_returns_error_if_federation_is_disabled( 'error, federation is disabled for this instance' in data['message'] ) + + +class TestRemoteUser(ApiTestCaseMixin): + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps({'actor_url': random_actor_url()}), + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, federation is disabled for this instance' + in data['message'] + ) + + def test_it_returns_error_if_user_is_not_logged( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() + response = client.post( + '/federation/remote-user', + content_type='application/json', + data=json.dumps({'actor_url': random_actor_url()}), + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'provide a valid auth token' in data['message'] + + def test_it_returns_400_if_remote_user_url_is_missing( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'invalid payload' in data['message'] + + def test_it_returns_error_if_remote_instance_returns_error( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.side_effect = ActorNotFoundException() + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps({'actor_url': random_actor_url()}), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Can not fetch remote actor.' in data['message'] + + def test_it_returns_error_if_remote_actor_object_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = {} + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps({'actor_url': random_actor_url()}), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Invalid remote actor object.' in data['message'] + + def test_it_returns_error_if_keys_are_missing_in_remote_actor_object( + self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + ) -> None: + remote_username = random_string() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = { + 'preferredUsername': remote_username, + } + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_username, + domain_with_scheme=f'https://{remote_domain.name}', + ) + } + ), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert 'Invalid remote actor object.' in data['message'] + + def test_it_returns_error_if_remote_domain_is_local_domain( + self, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + remote_username = random_string() + domain = f"https://{ app_with_federation.config['AP_DOMAIN']}" + remote_user_object = get_remote_user_object( + username=remote_username, domain_with_scheme=domain + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_username, domain_with_scheme=domain + ) + } + ), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'The provided account is not a remote account.' + in data['message'] + ) + + def test_it_creates_remote_actor_if_actor_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + ) -> None: + remote_username = random_string() + remote_preferred_username = random_string() + domain = f'https://{remote_domain.name}' + remote_user_object = get_remote_user_object( + username=remote_username, + preferred_username=remote_preferred_username, + domain_with_scheme=domain, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_preferred_username, + domain_with_scheme=domain, + ) + } + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == remote_user_object + + def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + remote_username = random_string() + remote_preferred_username = random_string() + domain = random_domain_with_scheme() + remote_user_object = get_remote_user_object( + username=remote_username, + preferred_username=remote_preferred_username, + domain_with_scheme=domain, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_preferred_username, + domain_with_scheme=domain, + ) + } + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == remote_user_object + + def test_it_returns_updated_remote_actor_if_remote_domain_exists( + self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor + ) -> None: + remote_user_object = remote_actor.serialize() + updated_name = random_string() + remote_user_object['name'] = updated_name + last_fetched = remote_actor.last_fetch_date + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps({'actor_url': remote_actor.activitypub_id}), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == remote_user_object + assert remote_actor.name == updated_name + assert remote_actor.last_fetch_date != last_fetched + + def test_it_creates_several_remote_actors( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + """ + check constrains on User model (especially empty password and email) + """ + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + with patch( + 'fittrackee.federation.federation.get_remote_user' + ) as get_remote_user_mock: + remote_preferred_username = random_string() + domain = random_domain_with_scheme() + remote_user_object = get_remote_user_object( + preferred_username=remote_preferred_username, + domain_with_scheme=domain, + ) + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_preferred_username, + domain_with_scheme=domain, + ) + } + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == remote_user_object + + remote_preferred_username = random_string() + domain = random_domain_with_scheme() + remote_user_object = get_remote_user_object( + preferred_username=remote_preferred_username, + domain_with_scheme=domain, + ) + get_remote_user_mock.return_value = remote_user_object + response = client.post( + '/federation/remote-user', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + data=json.dumps( + { + 'actor_url': random_actor_url( + username=remote_preferred_username, + domain_with_scheme=domain, + ) + } + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == remote_user_object diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index 6a926c4fc..f5e079fbf 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -10,6 +10,8 @@ from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import get_ap_url +from ...utils import random_actor_url + class TestGetApUrl: def test_it_raises_error_if_url_type_is_invalid(self, app: Flask) -> None: @@ -132,9 +134,9 @@ def test_it_returns_serialized_object( remote_domain: Domain, ) -> None: serialized_actor = remote_actor.serialize() - ap_url = remote_domain.name - user_url = ( - f'{remote_domain.name}/users/{remote_actor.preferred_username}' + remote_domain_url = f'https://{remote_domain.name}' + user_url = random_actor_url( + remote_actor.preferred_username, remote_domain_url ) assert serialized_actor['@context'] == AP_CTX assert serialized_actor['id'] == remote_actor.activitypub_id @@ -159,7 +161,8 @@ def test_it_returns_serialized_object( ) assert 'publicKeyPem' in serialized_actor['publicKey'] assert ( - serialized_actor['endpoints']['sharedInbox'] == f'{ap_url}/inbox' + serialized_actor['endpoints']['sharedInbox'] + == f'{remote_domain_url}/inbox' ) @@ -170,5 +173,5 @@ def test_it_returns_actor_empty_name( domain = Domain.query.filter_by( name=app_with_federation.config['AP_DOMAIN'] ).first() - actor = Actor(username=uuid4().hex, domain_id=domain.id) + actor = Actor(preferred_username=uuid4().hex, domain_id=domain.id) assert actor.name is None diff --git a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py index 3e0bb569f..dd4dae4ce 100644 --- a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -31,7 +31,9 @@ def test_it_calls_signature_header( with freeze_time(now): send_to_remote_user_inbox( - sender=actor_1, activity={'foo': 'bar'}, recipient=remote_actor + sender=actor_1, + activity={'foo': 'bar'}, + recipient_inbox_url=remote_actor.inbox_url, ) signature_header_mock.assert_called_with( @@ -60,7 +62,9 @@ def test_it_calls_requests_post( with freeze_time(now): send_to_remote_user_inbox( - sender=actor_1, activity=activity, recipient=remote_actor + sender=actor_1, + activity=activity, + recipient_inbox_url=remote_actor.inbox_url, ) requests_mock.post.assert_called_with( @@ -92,7 +96,9 @@ def test_it_logs_error_if_remote_inbox_returns_error( ) send_to_remote_user_inbox( - sender=actor_1, activity={'foo': 'bar'}, recipient=remote_actor + sender=actor_1, + activity={'foo': 'bar'}, + recipient_inbox_url=remote_actor.inbox_url, ) assert len(caplog.records) == 1 diff --git a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py index bb7cdeb82..4738029c3 100644 --- a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py @@ -41,7 +41,9 @@ def test_it_calls_send_to_remote_user_inbox( ) send_to_remote_user_inbox_mock.assert_called_with( - sender=actor_1, activity=activity, recipient=remote_actor.inbox_url + sender=actor_1, + activity=activity, + recipient_inbox_url=remote_actor.inbox_url, ) @patch('fittrackee.federation.tasks.user_inbox.send_to_remote_user_inbox') diff --git a/fittrackee/tests/federation/federation/test_remote_user.py b/fittrackee/tests/federation/federation/test_remote_user.py new file mode 100644 index 000000000..12462896e --- /dev/null +++ b/fittrackee/tests/federation/federation/test_remote_user.py @@ -0,0 +1,41 @@ +from unittest.mock import patch + +import pytest +import requests + +from fittrackee.federation.exceptions import ActorNotFoundException +from fittrackee.federation.remote_user import get_remote_user + +from ...utils import ( + generate_response, + get_remote_user_object, + random_actor_url, + random_domain_with_scheme, + random_string, +) + + +class TestGetRemoteUser: + def test_it_returns_error_if_remote_instance_returns_error(self) -> None: + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response(status_code=404) + + with pytest.raises(ActorNotFoundException): + get_remote_user(random_actor_url()) + + def test_it_returns_user_object_if_remote_response_is_successful( + self, + ) -> None: + username = random_string() + remote_domain = random_domain_with_scheme() + remote_user = get_remote_user_object(username, remote_domain) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, content=remote_user + ) + + expected_user = get_remote_user( + random_actor_url(username, remote_domain) + ) + + assert remote_user == expected_user diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 82d20a4ef..6c7e5682d 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -5,13 +5,13 @@ from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import User -from ..utils import random_domain +from ..utils import get_remote_user_object, random_domain @pytest.fixture() def app_actor(app: Flask) -> Actor: domain = Domain.query.filter_by(name=app.config['AP_DOMAIN']).first() - actor = Actor(username='test', domain_id=domain.id) + actor = Actor(preferred_username='test', domain_id=domain.id) db.session.add(actor) db.session.commit() return actor @@ -22,7 +22,7 @@ def actor_1(user_1: User, app_with_federation: Flask) -> Actor: domain = Domain.query.filter_by( name=app_with_federation.config['AP_DOMAIN'] ).first() - actor = Actor(username=user_1.username, domain_id=domain.id) + actor = Actor(preferred_username=user_1.username, domain_id=domain.id) db.session.add(actor) db.session.flush() user_1.actor_id = actor.id @@ -35,7 +35,7 @@ def actor_2(user_2: User, app_with_federation: Flask) -> Actor: domain = Domain.query.filter_by( name=app_with_federation.config['AP_DOMAIN'] ).first() - actor = Actor(username=user_2.username, domain_id=domain.id) + actor = Actor(preferred_username=user_2.username, domain_id=domain.id) db.session.add(actor) db.session.flush() user_2.actor_id = actor.id @@ -48,7 +48,7 @@ def actor_3(user_3: User, app_with_federation: Flask) -> Actor: domain = Domain.query.filter_by( name=app_with_federation.config['AP_DOMAIN'] ).first() - actor = Actor(username=user_3.username, domain_id=domain.id) + actor = Actor(preferred_username=user_3.username, domain_id=domain.id) db.session.add(actor) db.session.flush() user_3.actor_id = actor.id @@ -68,30 +68,18 @@ def remote_domain(app_with_federation: Flask) -> Domain: def remote_actor( user_2: User, app_with_federation: Flask, remote_domain: Domain ) -> Actor: - user_name = user_2.username.capitalize() - user_url = f'{remote_domain.name}/users/{user_2.username}' + domain = f'https://{remote_domain.name}' + remote_user_object = get_remote_user_object( + username=user_2.username, domain_with_scheme=domain + ) actor = Actor( - username=user_2.username, + preferred_username=user_2.username, domain_id=remote_domain.id, - remote_user_data={ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - ], - 'id': user_url, - 'type': 'Person', - 'following': f'{user_url}/following', - 'followers': f'{user_url}/followers', - 'inbox': f'{user_url}/inbox', - 'outbox': f'{user_url}/outbox', - 'name': user_name, - 'preferredUsername': user_2.username, - 'endpoints': {'sharedInbox': f'{remote_domain.name}/inbox'}, - }, + remote_user_data=remote_user_object, ) db.session.add(actor) db.session.flush() - user_2.name = user_name + user_2.name = user_2.username.capitalize() user_2.actor_id = actor.id db.session.commit() return actor diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 3711f4c7d..8e3ecd948 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -53,3 +53,57 @@ def generate_response( ) response.status_code = status_code if status_code else 200 return response + + +def random_full_username() -> str: + return f'{random_string()}@{random_domain()}' + + +def random_actor_url( + username: Optional[str] = None, domain_with_scheme: Optional[str] = None +) -> str: + username = username if username else random_string() + remote_domain = ( + domain_with_scheme + if domain_with_scheme + else random_domain_with_scheme() + ) + return f'{remote_domain}/users/{username}' + + +def get_remote_user_object( + username: Optional[str] = None, + preferred_username: Optional[str] = None, + domain_with_scheme: Optional[str] = None, +) -> Dict: + username = username if username else random_string() + preferred_username = ( + preferred_username if preferred_username else random_string() + ) + remote_domain = ( + domain_with_scheme + if domain_with_scheme + else random_domain_with_scheme() + ) + user_url = random_actor_url(username, remote_domain) + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id': user_url, + 'type': 'Person', + 'following': f'{user_url}/following', + 'followers': f'{user_url}/followers', + 'inbox': f'{user_url}/inbox', + 'outbox': f'{user_url}/outbox', + 'name': username, + 'preferredUsername': preferred_username, + 'manuallyApprovesFollowers': True, + 'publicKey': { + 'id': f'{user_url}#main-key', + 'owner': user_url, + 'publicKeyPem': random_string(), + }, + 'endpoints': {'sharedInbox': f'{remote_domain}/inbox'}, + } diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 5d9df6d43..777f9e43a 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -81,13 +81,20 @@ def get_activity(self) -> Dict: class User(BaseModel): __tablename__ = 'users' + __table_args__ = ( + db.UniqueConstraint( + 'username', 'actor_id', name='username_actor_id_unique' + ), + ) id = db.Column(db.Integer, primary_key=True, autoincrement=True) actor_id = db.Column( db.Integer, db.ForeignKey('actors.id'), unique=True, nullable=True ) - username = db.Column(db.String(20), unique=True, nullable=False) - email = db.Column(db.String(120), unique=True, nullable=False) - password = db.Column(db.String(255), nullable=False) + username = db.Column(db.String(50), nullable=False) + # Note: Null values are not considered equal + # source: https://www.postgresql.org/docs/current/indexes-unique.html + email = db.Column(db.String(120), unique=True, nullable=True) + password = db.Column(db.String(255), nullable=True) created_at = db.Column(db.DateTime, nullable=False) admin = db.Column(db.Boolean, default=False, nullable=False) first_name = db.Column(db.String(80), nullable=True) @@ -131,15 +138,19 @@ def __repr__(self) -> str: def __init__( self, username: str, - email: str, - password: str, + email: Optional[str], + password: Optional[str], created_at: Optional[datetime] = datetime.utcnow(), ) -> None: self.username = username - self.email = email - self.password = bcrypt.generate_password_hash( - password, current_app.config.get('BCRYPT_LOG_ROUNDS') - ).decode() + self.email = email # email is None for remote actor + self.password = ( + bcrypt.generate_password_hash( + password, current_app.config.get('BCRYPT_LOG_ROUNDS') + ).decode() + if email + else None + ) # no password for remote actor self.created_at = created_at @staticmethod @@ -241,7 +252,9 @@ def create_actor(self) -> None: app_domain = Domain.query.filter_by( name=current_app.config['AP_DOMAIN'] ).first() - actor = Actor(username=self.username, domain_id=app_domain.id) + actor = Actor( + preferred_username=self.username, domain_id=app_domain.id + ) db.session.add(actor) db.session.flush() self.actor_id = actor.id From 209c8fbb29a9fe28aba9ee071973f4b0a408d402 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 13:55:03 +0100 Subject: [PATCH 028/238] API - add digest in HTTP headers --- fittrackee/federation/exceptions.py | 4 +- fittrackee/federation/inbox.py | 16 +- fittrackee/federation/signature.py | 85 +++++-- .../test_federation_remote_inbox.py | 36 ++- .../tests/federation/users/test_signature.py | 230 +++++++++++++++--- .../federation/users/test_users_inbox.py | 7 +- 6 files changed, 304 insertions(+), 74 deletions(-) diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py index c02b415f4..78f8f44e5 100644 --- a/fittrackee/federation/exceptions.py +++ b/fittrackee/federation/exceptions.py @@ -20,10 +20,10 @@ def __init__(self) -> None: class InvalidSignatureException(GenericException): - def __init__(self) -> None: + def __init__(self, message: Optional[str] = None) -> None: super().__init__( status='error', - message='Invalid signature.', + message=f'Invalid signature{f": {message}" if message else ""}.', ) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index 50c2f0649..d0fa75c30 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -19,7 +19,8 @@ from .signature import ( VALID_DATE_FORMAT, SignatureVerification, - signature_header, + get_digest, + get_signature_header, ) from .tasks.activity import handle_activity from .utils import is_invalid_activity_data @@ -57,20 +58,23 @@ def send_to_remote_user_inbox( ) -> None: now_str = datetime.utcnow().strftime(VALID_DATE_FORMAT) parsed_inbox_url = urlparse(recipient_inbox_url) - signed_header = signature_header( + digest = get_digest(activity) + signed_header = get_signature_header( host=parsed_inbox_url.netloc, path=parsed_inbox_url.path, date_str=now_str, actor=sender, + digest=digest, ) response = requests.post( recipient_inbox_url, data=dumps(activity), headers={ - "Host": parsed_inbox_url.netloc, - "Date": now_str, - "Signature": signed_header, - "Content-Type": "application/ld+json", + 'Host': parsed_inbox_url.netloc, + 'Date': now_str, + 'Signature': signed_header, + 'Digest': digest, + 'Content-Type': 'application/ld+json', }, ) if response.status_code >= 400: diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index fc7dc5404..8de363faf 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -1,4 +1,6 @@ import base64 +import hashlib +import json from datetime import datetime from typing import Dict, Optional @@ -15,12 +17,32 @@ VALID_DATE_DELTA = 30 # in seconds VALID_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' -VALID_SIG_KEYS = ['keyId', 'headers', 'signature'] - - -def signature_header(host: str, path: str, date_str: str, actor: Actor) -> str: +VALID_SIG_KEYS = ['keyId', 'algorithm', 'headers', 'signature'] +SUPPORTED_ALGORITHMS = { + 'rsa-sha256': {'algorithm': 'SHA-256', 'hash_function': hashlib.sha256}, + 'rsa-sha512': {'algorithm': 'SHA-512', 'hash_function': hashlib.sha512}, +} +DEFAULT_ALGORITHM = 'rsa-sha256' + + +def get_digest(activity: Dict, algorithm: Optional[str] = None) -> str: + algorithm_dict = SUPPORTED_ALGORITHMS[ + DEFAULT_ALGORITHM if algorithm is None else algorithm + ] + digest = base64.b64encode( + algorithm_dict['hash_function']( # type: ignore + json.dumps(activity).encode() + ).digest() + ).decode() + return f"{algorithm_dict['algorithm']}={digest}" + + +def get_signature_header( + host: str, path: str, date_str: str, actor: Actor, digest: str +) -> str: signed_string = ( - f'(request-target): post {path}\nhost: {host}\ndate: {date_str}' + f'(request-target): post {path}\nhost: {host}\ndate: {date_str}\n' + f'digest: {digest}' ) key = RSA.import_key(actor.private_key) key_signer = pkcs1_15.new(key) @@ -29,7 +51,8 @@ def signature_header(host: str, path: str, date_str: str, actor: Actor) -> str: signature = base64.b64encode(key_signer.sign(h)) return ( f'keyId="{actor.activitypub_id}",' - 'headers="(request-target) host date",' + 'algorithm=rsa-sha256,' + 'headers="(request-target) host date digest",' f'signature="' + signature.decode() + '"' ) @@ -42,6 +65,8 @@ def __init__(self, request: Request, signature_dict: Dict): self.key_id = signature_dict['keyId'] self.headers = signature_dict['headers'] self.signature = base64.b64decode(signature_dict['signature']) + self.algorithm = signature_dict['algorithm'] + self.digest = request.headers.get('Digest') @classmethod def get_signature(cls, request: Request) -> 'SignatureVerification': @@ -56,9 +81,12 @@ def get_signature(cls, request: Request) -> 'SignatureVerification': appLog.error(f'Invalid signature headers: {e} (host: {host}).') raise InvalidSignatureException() - if list(signature_dict.keys()) != VALID_SIG_KEYS: + keys_list = list(signature_dict.keys()) + if keys_list != VALID_SIG_KEYS: appLog.error( - f'Invalid signature headers: invalid keys (host: {host}).' + 'Invalid signature headers: invalid keys, expected: ' + f'{VALID_SIG_KEYS}, got: {keys_list} ' + f'(host: {host}).' ) raise InvalidSignatureException() @@ -87,19 +115,37 @@ def is_date_invalid(self) -> bool: delta = datetime.utcnow() - date return delta.total_seconds() > VALID_DATE_DELTA + def raise_error(self, error: str) -> None: + appLog.error(f'Invalid signature: {error} (host: {self.host}).') + raise InvalidSignatureException(error) + + def verify_digest(self) -> None: + if self.algorithm not in SUPPORTED_ALGORITHMS.keys(): + self.raise_error('unsupported algorithm') + expected_algorithm = SUPPORTED_ALGORITHMS[self.algorithm]['algorithm'] + hash_function = SUPPORTED_ALGORITHMS[self.algorithm]['hash_function'] + + try: + if not self.digest: + raise Exception('No digest') + algorithm, digest = self.digest.split('=', 1) + if algorithm != expected_algorithm: + raise Exception('Algorithm mismatch') + expected = hash_function( # type: ignore + self.request.data + ).digest() + if base64.b64decode(digest) != expected: + raise Exception() + except Exception: + self.raise_error('invalid HTTP digest') + def verify(self) -> None: public_key = self.get_actor_public_key() if not public_key: - appLog.error( - f'Invalid signature: invalid public key (host: {self.host}).' - ) - raise InvalidSignatureException() + self.raise_error('invalid public key') if self.is_date_invalid(): - appLog.error( - f'Invalid signature: invalid date header (host: {self.host}).' - ) - raise InvalidSignatureException() + self.raise_error('invalid date header') comparison = [] for headers_part in self.headers.split(' '): @@ -108,6 +154,8 @@ def verify(self) -> None: '(request-target): post %s' % self.request.path ) else: + if headers_part == 'digest': + self.verify_digest() comparison.append( "%s: %s" % ( @@ -117,11 +165,10 @@ def verify(self) -> None: ) comparison_string: str = '\n'.join(comparison) - signer = pkcs1_15.new(RSA.import_key(public_key)) + signer = pkcs1_15.new(RSA.import_key(public_key)) # type: ignore digest = SHA256.new() digest.update(comparison_string.encode()) try: signer.verify(digest, self.signature) except ValueError: - appLog.error(f'Invalid signature (host: {self.host}).') - raise InvalidSignatureException() + self.raise_error('verification failed') diff --git a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py index dd4dae4ce..78e9ababf 100644 --- a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -15,12 +15,14 @@ class TestSendToRemoteInbox(BaseTestMixin): - @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.get_digest') + @patch('fittrackee.federation.inbox.get_signature_header') @patch('fittrackee.federation.inbox.requests') - def test_it_calls_signature_header( + def test_it_calls_get_signature_header( self, requests_mock: Mock, - signature_header_mock: Mock, + get_signature_header_mock: Mock, + get_digest_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, @@ -28,6 +30,8 @@ def test_it_calls_signature_header( now = datetime.utcnow() parsed_inbox_url = urlparse(remote_actor.inbox_url) requests_mock.post.return_value = generate_response(status_code=200) + digest = random_string() + get_digest_mock.return_value = digest with freeze_time(now): send_to_remote_user_inbox( @@ -36,19 +40,22 @@ def test_it_calls_signature_header( recipient_inbox_url=remote_actor.inbox_url, ) - signature_header_mock.assert_called_with( + get_signature_header_mock.assert_called_with( host=parsed_inbox_url.netloc, path=parsed_inbox_url.path, date_str=get_date_string(now), actor=actor_1, + digest=digest, ) - @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.get_digest') + @patch('fittrackee.federation.inbox.get_signature_header') @patch('fittrackee.federation.inbox.requests') def test_it_calls_requests_post( self, requests_mock: Mock, - signature_header_mock: Mock, + get_signature_header_mock: Mock, + get_digest_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, @@ -58,7 +65,9 @@ def test_it_calls_requests_post( parsed_inbox_url = urlparse(remote_actor.inbox_url) requests_mock.post.return_value = generate_response(status_code=200) signed_header = random_string() - signature_header_mock.return_value = signed_header + get_signature_header_mock.return_value = signed_header + digest = random_string() + get_digest_mock.return_value = digest with freeze_time(now): send_to_remote_user_inbox( @@ -71,19 +80,20 @@ def test_it_calls_requests_post( remote_actor.inbox_url, data=dumps(activity), headers={ - "Host": parsed_inbox_url.netloc, - "Date": get_date_string(now), - "Signature": signed_header, - "Content-Type": "application/ld+json", + 'Host': parsed_inbox_url.netloc, + 'Date': get_date_string(now), + 'Digest': digest, + 'Signature': signed_header, + 'Content-Type': 'application/ld+json', }, ) - @patch('fittrackee.federation.inbox.signature_header') + @patch('fittrackee.federation.inbox.get_signature_header') @patch('fittrackee.federation.inbox.requests') def test_it_logs_error_if_remote_inbox_returns_error( self, requests_mock: Mock, - signature_header_mock: Mock, + get_signature_header_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, diff --git a/fittrackee/tests/federation/users/test_signature.py b/fittrackee/tests/federation/users/test_signature.py index 7abd05b36..f4e15f33b 100644 --- a/fittrackee/tests/federation/users/test_signature.py +++ b/fittrackee/tests/federation/users/test_signature.py @@ -1,25 +1,42 @@ import base64 +import json from datetime import datetime, timedelta from typing import Dict, Optional from unittest.mock import MagicMock, patch import pytest import requests -from Crypto.Hash import SHA256 -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 from flask import Flask +from fittrackee.federation.constants import AP_CTX from fittrackee.federation.exceptions import InvalidSignatureException from fittrackee.federation.models import Actor from fittrackee.federation.signature import ( VALID_DATE_DELTA, VALID_DATE_FORMAT, SignatureVerification, + get_digest, + get_signature_header, ) from ...utils import generate_response, get_date_string, random_string +TEST_ACTIVITY = { + '@context': AP_CTX, + 'id': random_string(), + 'type': random_string(), + 'actor': random_string(), + 'object': random_string(), +} + + +class TestGetDigest: + def test_it_returns_digest_with_default_algorithm(self) -> None: + assert get_digest(TEST_ACTIVITY).startswith('SHA-256=') + + def test_it_returns_digest_with_given_algorithm(self) -> None: + assert get_digest(TEST_ACTIVITY, 'rsa-sha512').startswith('SHA-512=') + class SignatureVerificationTestCase: @staticmethod @@ -33,49 +50,58 @@ def generate_headers( date_str: Optional[str] = None, # overrides date date: Optional[datetime] = None, host: Optional[str] = None, + algorithm: Optional[str] = None, + digest: Optional[str] = None, ) -> Dict: key_id = key_id if key_id else random_string() signature = signature if signature else self.random_signature() + algorithm = algorithm if algorithm else random_string() signature_headers = ( - f'keyId="{key_id}",headers="(request-target) host date",' - f'signature="' + signature + '"' + f'keyId="{key_id}",algorithm={algorithm},headers="(request-target)' + f' host date digest",signature="' + signature + '"' ) + digest = digest if digest else random_string() if date_str is None: date_str = get_date_string(date) return { 'Host': host if host else random_string(), 'Date': date_str, + 'Digest': digest, 'Signature': signature_headers, 'Content-Type': 'application/ld+json', } def generate_valid_headers( - self, host: str, actor: Actor, date_str: Optional[str] = None + self, + host: str, + actor: Actor, + activity: Dict, + date_str: Optional[str] = None, ) -> Dict: if date_str is None: now = datetime.utcnow() date_str = now.strftime(VALID_DATE_FORMAT) - signed_string = ( - f'(request-target): post /inbox\nhost: {host}\n' - f'date: {date_str}' + digest = get_digest(activity) + signed_header = get_signature_header( + host, '/inbox', date_str, actor, digest ) - key = RSA.import_key(actor.private_key) - key_signer = pkcs1_15.new(key) - encoded_string = signed_string.encode('utf-8') - h = SHA256.new(encoded_string) - signature = base64.b64encode(key_signer.sign(h)) return self.generate_headers( key_id=actor.activitypub_id, - signature=signature.decode(), + signature=signed_header, date_str=date_str, host=host, + algorithm='rsa-sha256', + digest=digest, ) @staticmethod - def get_request_mock(headers: Optional[Dict] = None) -> MagicMock: + def get_request_mock( + headers: Optional[Dict] = None, data: Optional[Dict] = None + ) -> MagicMock: request_mock = MagicMock() request_mock.headers = headers if headers else {} request_mock.path = '/inbox' + request_mock.data = json.dumps(data if data else {}).encode() return request_mock @@ -91,7 +117,7 @@ def test_it_raises_error_if_headers_are_empty(self) -> None: [ ( 'missing keyId', - 'headers="(request-target) host date",' + 'headers="(request-target) host date digest",' f'signature="{random_string()}"', ), ( @@ -101,7 +127,7 @@ def test_it_raises_error_if_headers_are_empty(self) -> None: ( 'missing signature', f'keyId="{random_string()}",' - 'headers="(request-target) host date"', + 'headers="(request-target) host date digest"', ), ], ) @@ -122,15 +148,20 @@ def test_it_raises_error_if_a_signature_key_is_missing( def test_it_instantiates_signature_verification(self) -> None: key_id = random_string() signature = self.random_signature() + algorithm = 'rsa-sha256' signature_headers = ( - f'keyId="{key_id}",headers="(request-target) host date",' + f'keyId="{key_id}",algorithm={algorithm},' + 'headers="(request-target) host date digest",' f'signature="' + signature + '"' ) date_str = get_date_string() + activity = {'foo': 'bar'} + digest = get_digest(activity) valid_request_mock = self.get_request_mock( headers={ 'Host': random_string(), 'Date': date_str, + 'Digest': digest, 'Signature': signature_headers, } ) @@ -142,8 +173,10 @@ def test_it_instantiates_signature_verification(self) -> None: assert sig_verification.request == valid_request_mock assert sig_verification.date_str == date_str assert sig_verification.key_id == key_id - assert sig_verification.headers == '(request-target) host date' + assert sig_verification.headers == '(request-target) host date digest' assert sig_verification.signature == base64.b64decode(signature) + assert sig_verification.algorithm == algorithm + assert sig_verification.digest == digest class TestSignatureDateVerification(SignatureVerificationTestCase): @@ -246,8 +279,76 @@ def test_it_returns_public_key(self) -> None: assert key == public_key +class TestSignatureDigestVerification(SignatureVerificationTestCase): + @pytest.mark.parametrize( + 'input_description,input_digest', + [ + ( + 'invalid digest', + random_string(), + ), + ( + 'mismatched digest (different data)', + get_digest({"foo": "bar"}), + ), + ( + 'mismatched digest (different algo)', + get_digest(TEST_ACTIVITY, algorithm='rsa-sha512'), + ), + ], + ) + def test_verify_raises_error_if_http_digest_is_invalid( + self, + input_description: str, + input_digest: str, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config['AP_DOMAIN'], + date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), + algorithm='rsa-sha256', + digest=input_digest, + ), + data=TEST_ACTIVITY, + ) + ) + + with pytest.raises( + InvalidSignatureException, match='invalid HTTP digest' + ): + sig_verification.verify_digest() + + @pytest.mark.parametrize( + 'input_description,input_algorithm', + [('SHA256', 'rsa-sha256'), ('SHA512', 'rsa-sha512')], + ) + def test_verify_do_not_raise_error_if_http_digest_is_valid( + self, + input_description: str, + input_algorithm: str, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config['AP_DOMAIN'], + date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), + algorithm=input_algorithm, + digest=get_digest(TEST_ACTIVITY, input_algorithm), + ), + data=TEST_ACTIVITY, + ) + ) + + sig_verification.verify_digest() + + class TestSignatureVerify(SignatureVerificationTestCase): - def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( + def test_verify_raises_error_if_header_date_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() @@ -256,21 +357,23 @@ def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, + date_str='', + activity=TEST_ACTIVITY, ) ) ) - # update actor keys - actor_1.generate_keys() with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( status_code=200, content=actor_1.serialize(), ) - with pytest.raises(InvalidSignatureException): + with pytest.raises( + InvalidSignatureException, match='invalid date header' + ): sig_verification.verify() - def test_verify_raises_error_if_header_date_is_invalid( + def test_verify_raises_error_if_public_key_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() @@ -279,20 +382,72 @@ def test_verify_raises_error_if_header_date_is_invalid( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - date_str='', + activity=TEST_ACTIVITY, ) ) ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response(status_code=404) + + with pytest.raises( + InvalidSignatureException, match='invalid public key' + ): + sig_verification.verify() + + def test_verify_raises_error_if_algorithm_is_not_supported( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + algorithm = random_string() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config['AP_DOMAIN'], + date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), + algorithm=algorithm, + digest=get_digest(TEST_ACTIVITY), + ), + data=TEST_ACTIVITY, + ) + ) with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( status_code=200, content=actor_1.serialize(), ) - with pytest.raises(InvalidSignatureException): + with pytest.raises( + InvalidSignatureException, match='unsupported algorithm' + ): sig_verification.verify() - def test_verify_raises_error_if_public_key_is_invalid( + def test_verify_raises_error_if_http_digest_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config['AP_DOMAIN'], + date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), + algorithm='rsa-sha256', + digest=random_string(), + ), + data=TEST_ACTIVITY, + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + with pytest.raises( + InvalidSignatureException, match='invalid HTTP digest' + ): + sig_verification.verify() + + def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() @@ -301,13 +456,22 @@ def test_verify_raises_error_if_public_key_is_invalid( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - ) + activity=TEST_ACTIVITY, + ), + data=TEST_ACTIVITY, ) ) + # update actor keys + actor_1.generate_keys() with patch.object(requests, 'get') as requests_mock: - requests_mock.return_value = generate_response(status_code=404) + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) - with pytest.raises(InvalidSignatureException): + with pytest.raises( + InvalidSignatureException, match='verification failed' + ): sig_verification.verify() def test_verify_does_not_raise_error_if_signature_is_valid( @@ -319,7 +483,9 @@ def test_verify_does_not_raise_error_if_signature_is_valid( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - ) + activity=TEST_ACTIVITY, + ), + data=TEST_ACTIVITY, ) ) with patch.object(requests, 'get') as requests_mock: diff --git a/fittrackee/tests/federation/users/test_users_inbox.py b/fittrackee/tests/federation/users/test_users_inbox.py index 578fb5515..113848039 100644 --- a/fittrackee/tests/federation/users/test_users_inbox.py +++ b/fittrackee/tests/federation/users/test_users_inbox.py @@ -10,7 +10,7 @@ from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType from fittrackee.federation.models import Actor -from fittrackee.federation.signature import signature_header +from fittrackee.federation.signature import get_digest, get_signature_header from ...test_case_mixins import ApiTestCaseMixin from ...utils import ( @@ -41,6 +41,7 @@ def post_to_user_inbox( 'actor': actor_2.activitypub_id, 'object': actor_1.activitypub_id, } + digest = get_digest(follow_activity) with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( @@ -54,11 +55,13 @@ def post_to_user_inbox( headers={ 'Host': host, 'Date': date_str, - 'Signature': signature_header( + 'Digest': digest, + 'Signature': get_signature_header( host=host, path=inbox_path, date_str=date_str, actor=actor_2, + digest=digest, ), 'Content-Type': 'application/ld+json', }, From d1c0b64960ea7a324d37a04d44415acdb76bfa53 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 7 Jul 2021 17:48:19 +0200 Subject: [PATCH 029/238] API - refactor and fix user inbox --- fittrackee/federation/federation.py | 11 +++++++++++ .../federation/test_federation_tasks_user_inbox.py | 9 +++++++-- .../{users => federation}/test_users_inbox.py | 10 +++++----- fittrackee/tests/federation/users/test_users_model.py | 4 ++-- fittrackee/users/models.py | 4 ++-- fittrackee/users/users.py | 10 ---------- 6 files changed, 27 insertions(+), 21 deletions(-) rename fittrackee/tests/federation/{users => federation}/test_users_inbox.py (94%) diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index bc29e6326..4bd227694 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -15,6 +15,7 @@ from fittrackee.users.models import User from .decorators import federation_required +from .inbox import inbox from .models import Actor, Domain ap_federation_blueprint = Blueprint('ap_federation', __name__) @@ -111,3 +112,13 @@ def remote_actor( response=actor.serialize(), content_type='application/jrd+json; charset=utf-8', ) + + +@ap_federation_blueprint.route( + '/user//inbox', methods=['POST'] +) +@federation_required +def user_inbox( + app_domain: Domain, preferred_username: str +) -> Union[Dict, HttpResponse]: + return inbox(request, app_domain, preferred_username) diff --git a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py index 4738029c3..96ea6db91 100644 --- a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py @@ -15,13 +15,18 @@ class TestSendToUsersInbox: def test_it_raises_error_if_sender_does_not_exist( self, app_with_federation: Flask, - follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_1_to_user_2_with_federation: FollowRequest, remote_actor: Actor, ) -> None: with pytest.raises(SenderNotFoundException): send_to_users_inbox( sender_id=0, - activity=follow_request_from_user_1_to_user_2.get_activity(), + activity=( + # fmt: off + follow_request_from_user_1_to_user_2_with_federation. + get_activity() + # fmt: on + ), recipients=[remote_actor.inbox_url], ) diff --git a/fittrackee/tests/federation/users/test_users_inbox.py b/fittrackee/tests/federation/federation/test_users_inbox.py similarity index 94% rename from fittrackee/tests/federation/users/test_users_inbox.py rename to fittrackee/tests/federation/federation/test_users_inbox.py index 113848039..875191b9a 100644 --- a/fittrackee/tests/federation/users/test_users_inbox.py +++ b/fittrackee/tests/federation/federation/test_users_inbox.py @@ -33,7 +33,7 @@ def post_to_user_inbox( host = random_domain() date_str = get_date_string() client = app_with_federation.test_client() - inbox_path = f'/api/users/{actor_1.preferred_username}/inbox' + inbox_path = f'/federation/user/{actor_1.preferred_username}/inbox' follow_activity: Dict = { '@context': AP_CTX, 'id': random_string(), @@ -76,7 +76,7 @@ def test_it_returns_404_if_user_does_not_exist( client = app_with_federation.test_client() response = client.post( - f'/api/users/{random_string()}/inbox', + f'/federation/user/{random_string()}/inbox', content_type='application/json', data=json.dumps({}), ) @@ -111,7 +111,7 @@ def test_it_returns_400_if_activity_is_invalid( client = app_with_federation.test_client() response = client.post( - f'/api/users/{actor_1.preferred_username}/inbox', + f'/federation/user/{actor_1.preferred_username}/inbox', content_type='application/json', data=json.dumps(input_activity), ) @@ -134,7 +134,7 @@ def test_it_returns_401_if_headers_are_missing( } response = client.post( - f'/api/users/{actor_1.preferred_username}/inbox', + f'/federation/user/{actor_1.preferred_username}/inbox', content_type='application/json', data=json.dumps(follow_activity), ) @@ -157,7 +157,7 @@ def test_it_returns_401_if_signature_is_invalid( } response = client.post( - f'/api/users/{actor_1.preferred_username}/inbox', + f'/federation/user/{actor_1.preferred_username}/inbox', content_type='application/json', headers={ 'Host': random_string(), diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index 8ab584f91..94c072226 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -36,7 +36,7 @@ def test_it_returns_activity_object_when_federation_is_enabled( expected_object_subset = { '@context': AP_CTX, 'type': 'Follow', - 'actor': actor_1.user.get_user_url(), - 'object': f'https://{actor_2.activitypub_id}', + 'actor': actor_1.activitypub_id, + 'object': actor_2.activitypub_id, } assert {**activity_object, **expected_object_subset} == activity_object diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 777f9e43a..3ee2744b7 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -74,8 +74,8 @@ def get_activity(self) -> Dict: '@context': AP_CTX, 'id': generate_activity_id(), 'type': ActivityType.FOLLOW.value, - 'actor': self.from_user.get_user_url(), - 'object': f'https://{self.to_user.actor.activitypub_id}', + 'actor': self.from_user.actor.activitypub_id, + 'object': self.to_user.actor.activitypub_id, } diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 57a5328bc..73ede8882 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -7,8 +7,6 @@ from sqlalchemy import exc from fittrackee import db -from fittrackee.federation.decorators import federation_required -from fittrackee.federation.inbox import inbox from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import get_username_and_domain from fittrackee.files import get_absolute_file_path @@ -641,11 +639,3 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: return successful_response_dict return UserNotFoundErrorResponse() - - -@users_blueprint.route('/users//inbox', methods=['POST']) -@federation_required -def user_inbox( - app_domain: Domain, user_name: str -) -> Union[Dict, HttpResponse]: - return inbox(request, app_domain, user_name) From c50ff9c3703e252290ec41bd03a13e0b84c84bf4 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 10 Jul 2021 17:51:21 +0200 Subject: [PATCH 030/238] API - minor test refactor --- .../test_signature.py => federation/test_federation_signature.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename fittrackee/tests/federation/{users/test_signature.py => federation/test_federation_signature.py} (100%) diff --git a/fittrackee/tests/federation/users/test_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py similarity index 100% rename from fittrackee/tests/federation/users/test_signature.py rename to fittrackee/tests/federation/federation/test_federation_signature.py From 177da4dada9226d8ca6086c86ffbe749f61926c7 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 10 Jul 2021 18:01:48 +0200 Subject: [PATCH 031/238] API - check if header actor is the same as activity actor --- fittrackee/federation/signature.py | 7 ++ .../federation/test_federation_signature.py | 73 +++++++++++++++---- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index 8de363faf..0b649b549 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -139,7 +139,14 @@ def verify_digest(self) -> None: except Exception: self.raise_error('invalid HTTP digest') + def header_actor_is_payload_actor(self) -> bool: + activity = json.loads(self.request.data.decode()) + return self.key_id == activity.get('actor') + def verify(self) -> None: + if not self.header_actor_is_payload_actor(): + self.raise_error('invalid actor') + public_key = self.get_actor_public_key() if not public_key: self.raise_error('invalid public key') diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index f4e15f33b..1d8048e08 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -104,6 +104,18 @@ def get_request_mock( request_mock.data = json.dumps(data if data else {}).encode() return request_mock + @staticmethod + def get_activity(actor: Optional[Actor] = None) -> Dict: + return { + '@context': AP_CTX, + 'id': random_string(), + 'type': random_string(), + 'actor': ( + random_string() if actor is None else actor.activitypub_id + ), + 'object': random_string(), + } + class TestSignatureVerificationInstantiation(SignatureVerificationTestCase): def test_it_raises_error_if_headers_are_empty(self) -> None: @@ -332,15 +344,16 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( app_with_federation: Flask, actor_1: Actor, ) -> None: + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_headers( host=app_with_federation.config['AP_DOMAIN'], date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), algorithm=input_algorithm, - digest=get_digest(TEST_ACTIVITY, input_algorithm), + digest=get_digest(activity, input_algorithm), ), - data=TEST_ACTIVITY, + data=activity, ) ) @@ -348,18 +361,45 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( class TestSignatureVerify(SignatureVerificationTestCase): + def test_it_raises_error_if_header_actor_is_different_from_activity_actor( + self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> None: + actor_1.generate_keys() + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config['AP_DOMAIN'], + actor=actor_1, + date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), + activity=self.get_activity(actor=actor_2), + ) + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + with pytest.raises( + InvalidSignatureException, match='invalid actor' + ): + sig_verification.verify() + def test_verify_raises_error_if_header_date_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, date_str='', - activity=TEST_ACTIVITY, - ) + activity=self.get_activity(actor=actor_1), + ), + data=activity, ) ) with patch.object(requests, 'get') as requests_mock: @@ -377,13 +417,15 @@ def test_verify_raises_error_if_public_key_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - activity=TEST_ACTIVITY, - ) + activity=activity, + ), + data=activity, ) ) with patch.object(requests, 'get') as requests_mock: @@ -399,15 +441,17 @@ def test_verify_raises_error_if_algorithm_is_not_supported( ) -> None: actor_1.generate_keys() algorithm = random_string() + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_headers( host=app_with_federation.config['AP_DOMAIN'], + key_id=actor_1.activitypub_id, date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), algorithm=algorithm, - digest=get_digest(TEST_ACTIVITY), + digest=get_digest(activity), ), - data=TEST_ACTIVITY, + data=activity, ) ) with patch.object(requests, 'get') as requests_mock: @@ -429,11 +473,12 @@ def test_verify_raises_error_if_http_digest_is_invalid( self.get_request_mock( self.generate_headers( host=app_with_federation.config['AP_DOMAIN'], + key_id=actor_1.activitypub_id, date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), algorithm='rsa-sha256', digest=random_string(), ), - data=TEST_ACTIVITY, + data=self.get_activity(actor=actor_1), ) ) with patch.object(requests, 'get') as requests_mock: @@ -451,14 +496,15 @@ def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - activity=TEST_ACTIVITY, + activity=activity, ), - data=TEST_ACTIVITY, + data=activity, ) ) # update actor keys @@ -478,14 +524,15 @@ def test_verify_does_not_raise_error_if_signature_is_valid( self, app_with_federation: Flask, actor_1: Actor ) -> None: actor_1.generate_keys() + activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], actor=actor_1, - activity=TEST_ACTIVITY, + activity=activity, ), - data=TEST_ACTIVITY, + data=activity, ) ) with patch.object(requests, 'get') as requests_mock: From 17b1ad64c42cfcdad5e406fdc8161ac8d04981ad Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:04:21 +0100 Subject: [PATCH 032/238] API - update federation endpoints documentation --- fittrackee/federation/federation.py | 140 ++++++++++++++++++++++++++++ fittrackee/federation/nodeinfo.py | 69 ++++++++++++++ fittrackee/federation/webfinger.py | 39 ++++++++ 3 files changed, 248 insertions(+) diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 4bd227694..d1cfed513 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -26,6 +26,54 @@ ) @federation_required def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: + """ + Get a local actor + + **Example request**: + + .. sourcecode:: http + + GET /federation/user/admin HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://example.com/federation/user/Sam", + "type": "Person", + "preferredUsername": "Sam", + "name": "Sam", + "inbox": "https://example.com/federation/user/Sam/inbox", + "outbox": "https://example.com/federation/user/Sam/outbox", + "followers": "https://example.com/federation/user/Sam/followers", + "following": "https://example.com/federation/user/Sam/following", + "manuallyApprovesFollowers": true, + "publicKey": { + "id": "https://example.com/federation/user/Sam#main-key", + "owner": "https://example.com/federation/user/Sam", + "publicKeyPem": "-----BEGIN PUBLIC KEY---(...)---END PUBLIC KEY-----" + }, + "endpoints": { + "sharedInbox": "https://example.com/federation/inbox" + } + } + + :param string preferred_username: actor preferred username + + :statuscode 200: success + :statuscode 403: error, federation is disabled for this instance + :statuscode 404: user does not exist + + """ actor = Actor.query.filter_by( preferred_username=preferred_username, domain_id=app_domain.id, @@ -45,6 +93,67 @@ def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: def remote_actor( app_domain: Domain, auth_user: User ) -> Union[Dict, HttpResponse]: + """ + Add a remote actor to local instance if it does not exist. + Otherwise it updates it. + + **Example request**: + + .. sourcecode:: http + + POST /federation/remote_user HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://remote-instance.social/user/Sam", + "type": "Person", + "preferredUsername": "Sam", + "name": "Sam", + "inbox": "https://remote-instance.social/user/Sam/inbox", + "outbox": "https://remote-instance.social/user/Sam/outbox", + "followers": "https://remote-instance.social/user/Sam/followers", + "following": "https://remote-instance.social/user/Sam/following", + "manuallyApprovesFollowers": true, + "publicKey": { + "id": "https://remote-instance.social/user/Sam#main-key", + "owner": "https://remote-instance.social/user/Sam", + "publicKeyPem": "-----BEGIN PUBLIC KEY---(...)---END PUBLIC KEY-----" + }, + "endpoints": { + "sharedInbox": "https://remote-instance.social/inbox" + } + } + + :param integer auth_user_id: authenticate user id (from JSON Web Token) + + : Union[Dict, HttpResponse]: + """ + Post an activity to user inbox + + **Example request**: + + .. sourcecode:: http + + POST /federation/user/Sam/inbox HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + :param string preferred_username: actor preferred username + + : HttpResponse: + """ + Get node info links + + **Example request**: + + .. sourcecode:: http + + GET /.well-known/nodeinfo HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "https://example.com/nodeinfo/2.0" + } + ] + } + + :statuscode 200: success + :statuscode 403: error, federation is disabled for this instance + + """ nodeinfo_url = f'https://{app_domain.name}/nodeinfo/2.0' response = { 'links': [ @@ -30,6 +60,45 @@ def get_nodeinfo_url(app_domain: Domain) -> HttpResponse: @ap_nodeinfo_blueprint.route('/nodeinfo/2.0', methods=['GET']) @federation_required def get_nodeinfo(app_domain: Domain) -> HttpResponse: + """ + Get node infos + + **Example request**: + + .. sourcecode:: http + + GET /nodeinfo/2.0 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "version": "2.0", + "software": { + "name": "fittrackee", + "version": "0.4.8" + }, + "protocols": [ + "activitypub" + ], + "usage": { + "users": { + "total": 10 + }, + "localWorkouts": 35 + }, + "openRegistrations": true + } + + :statuscode 200: success + :statuscode 403: error, federation is disabled for this instance + + """ # TODO : add 'activeHalfyear' and 'activeMonth' for users workouts_count = Workout.query.filter().count() users_count = User.query.filter().count() diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index 99789ae3e..ace9b7016 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -15,6 +15,45 @@ @ap_webfinger_blueprint.route('/webfinger', methods=['GET']) @federation_required def webfinger(app_domain: Domain) -> HttpResponse: + """ + Get account links + + **Example request**: + + .. sourcecode:: http + + GET /.well-known/webfinger?resource=acct:Sam@example.com HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "subject": "acct:Sam@example.com", + "links": [ + { + "href": "https://example.com/federation/user/Sam", + "rel": "self", + "type": "application/activity+json" + } + ] + } + + :query string acct: user account + + + :statuscode 200: success + :statuscode 400: + - Missing resource in request args. + - Invalid resource. + :statuscode 403: error, federation is disabled for this instance + :statuscode 404: user does not exist + + """ resource = request.args.get('resource') if not resource or not resource.startswith('acct:'): return InvalidPayloadErrorResponse('Missing resource in request args.') From 0c6bde3c4b12017f9ca15ab93f2a08b9983bcb02 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 11 Jul 2021 13:10:32 +0200 Subject: [PATCH 033/238] API - generate keys on actor creation --- fittrackee/federation/models.py | 1 + .../federation/federation/test_federation_signature.py | 7 ------- fittrackee/tests/federation/federation/test_users_inbox.py | 1 - fittrackee/users/models.py | 1 - 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 50090e82f..1026de46b 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -103,6 +103,7 @@ def __init__( self.shared_inbox_url = get_ap_url( preferred_username, 'shared_inbox' ) + self.generate_keys() def generate_keys(self) -> None: self.public_key, self.private_key = generate_keys() diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 1d8048e08..3f06f1037 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -364,7 +364,6 @@ class TestSignatureVerify(SignatureVerificationTestCase): def test_it_raises_error_if_header_actor_is_different_from_activity_actor( self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor ) -> None: - actor_1.generate_keys() sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( @@ -389,7 +388,6 @@ def test_it_raises_error_if_header_actor_is_different_from_activity_actor( def test_verify_raises_error_if_header_date_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -416,7 +414,6 @@ def test_verify_raises_error_if_header_date_is_invalid( def test_verify_raises_error_if_public_key_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -439,7 +436,6 @@ def test_verify_raises_error_if_public_key_is_invalid( def test_verify_raises_error_if_algorithm_is_not_supported( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() algorithm = random_string() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( @@ -468,7 +464,6 @@ def test_verify_raises_error_if_algorithm_is_not_supported( def test_verify_raises_error_if_http_digest_is_invalid( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_headers( @@ -495,7 +490,6 @@ def test_verify_raises_error_if_http_digest_is_invalid( def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -523,7 +517,6 @@ def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( def test_verify_does_not_raise_error_if_signature_is_valid( self, app_with_federation: Flask, actor_1: Actor ) -> None: - actor_1.generate_keys() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( diff --git a/fittrackee/tests/federation/federation/test_users_inbox.py b/fittrackee/tests/federation/federation/test_users_inbox.py index 875191b9a..cddef61b0 100644 --- a/fittrackee/tests/federation/federation/test_users_inbox.py +++ b/fittrackee/tests/federation/federation/test_users_inbox.py @@ -29,7 +29,6 @@ class TestUserInbox(ApiTestCaseMixin): def post_to_user_inbox( app_with_federation: Flask, actor_1: Actor, actor_2: Actor ) -> Tuple[Dict, TestResponse]: - actor_2.generate_keys() host = random_domain() date_str = get_date_string() client = app_with_federation.test_client() diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 3ee2744b7..e484cdeaf 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -258,7 +258,6 @@ def create_actor(self) -> None: db.session.add(actor) db.session.flush() self.actor_id = actor.id - actor.generate_keys() db.session.commit() def serialize(self) -> Dict: From a2f45887107ed10c5e17bd9e990e1aa2031cdb42 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:11:38 +0100 Subject: [PATCH 034/238] API - update nodeinfo endpoint --- fittrackee/federation/nodeinfo.py | 6 +-- .../federation/test_federation_nodeinfo.py | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/fittrackee/federation/nodeinfo.py b/fittrackee/federation/nodeinfo.py index b9c32af21..ab9b2184c 100644 --- a/fittrackee/federation/nodeinfo.py +++ b/fittrackee/federation/nodeinfo.py @@ -1,7 +1,7 @@ from flask import Blueprint, current_app +from fittrackee.federation.models import Actor from fittrackee.responses import HttpResponse -from fittrackee.users.models import User from fittrackee.workouts.models import Workout from .decorators import federation_required @@ -101,7 +101,7 @@ def get_nodeinfo(app_domain: Domain) -> HttpResponse: """ # TODO : add 'activeHalfyear' and 'activeMonth' for users workouts_count = Workout.query.filter().count() - users_count = User.query.filter().count() + actor_count = Actor.query.filter_by(domain_id=app_domain.id).count() response = { 'version': '2.0', 'software': { @@ -110,7 +110,7 @@ def get_nodeinfo(app_domain: Domain) -> HttpResponse: }, 'protocols': ['activitypub'], 'usage': { - 'users': {'total': users_count}, + 'users': {'total': actor_count}, 'localWorkouts': workouts_count, }, 'openRegistrations': current_app.config['is_registration_enabled'], diff --git a/fittrackee/tests/federation/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py index aeb48f845..bb6c850d9 100644 --- a/fittrackee/tests/federation/federation/test_federation_nodeinfo.py +++ b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py @@ -4,6 +4,7 @@ from fittrackee import VERSION from fittrackee.federation.models import Actor +from fittrackee.workouts.models import Sport, Workout class TestWellKnowNodeInfo: @@ -102,3 +103,55 @@ def test_it_returns_instance_nodeinfo_if_federation_is_enabled( 'usage': {'users': {'total': 1}, 'localWorkouts': 0}, 'openRegistrations': True, } + + def test_it_displays_workouts_count( + self, + app_with_federation: Flask, + actor_1: Actor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app_with_federation.test_client() + response = client.get( + '/nodeinfo/2.0', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['usage']['localWorkouts'] == 1 + + def test_only_local_actors_are_counted( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + client = app_with_federation.test_client() + response = client.get( + '/nodeinfo/2.0', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert data['usage']['users']['total'] == 1 + + def test_it_displays_if_registration_is_disabled( + self, + app_with_federation: Flask, + actor_1: Actor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + app_with_federation.config['is_registration_enabled'] = False + client = app_with_federation.test_client() + response = client.get( + '/nodeinfo/2.0', + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['openRegistrations'] is False From b0de9370613e0866db57d81dcaf23a41bb1c8469 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:12:22 +0100 Subject: [PATCH 035/238] API - minor test fix after rebase --- fittrackee/tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index ae34cb95d..a36ba6d9f 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -2,8 +2,6 @@ os.environ['FLASK_ENV'] = 'testing' os.environ['APP_SETTINGS'] = 'fittrackee.config.TestingConfig' -os.environ['UI_URL'] = 'https://0.0.0.0:5000' -os.environ['SENDER_EMAIL'] = 'fittrackee@example.com' # to avoid resetting dev database during tests os.environ['DATABASE_URL'] = os.environ['DATABASE_TEST_URL'] From 217a6ff67c66f3703ae12dee14846673d8616a41 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 17 Jul 2021 17:30:51 +0200 Subject: [PATCH 036/238] API - signature refacto --- fittrackee/federation/inbox.py | 8 ++--- fittrackee/federation/signature.py | 24 ++++++++------- .../test_federation_remote_inbox.py | 30 +++++++++---------- .../federation/test_federation_signature.py | 26 ++++++++-------- .../federation/federation/test_users_inbox.py | 9 ++++-- 5 files changed, 53 insertions(+), 44 deletions(-) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index d0fa75c30..1e8c53d1e 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -19,8 +19,8 @@ from .signature import ( VALID_DATE_FORMAT, SignatureVerification, - get_digest, - get_signature_header, + generate_digest, + generate_signature_header, ) from .tasks.activity import handle_activity from .utils import is_invalid_activity_data @@ -58,8 +58,8 @@ def send_to_remote_user_inbox( ) -> None: now_str = datetime.utcnow().strftime(VALID_DATE_FORMAT) parsed_inbox_url = urlparse(recipient_inbox_url) - digest = get_digest(activity) - signed_header = get_signature_header( + digest = generate_digest(activity) + signed_header = generate_signature_header( host=parsed_inbox_url.netloc, path=parsed_inbox_url.path, date_str=now_str, diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index 0b649b549..f643fc825 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -1,3 +1,7 @@ +""" +inspired by bookwyrm signatures.py +https://github.com/bookwyrm-social/bookwyrm +""" import base64 import hashlib import json @@ -25,7 +29,7 @@ DEFAULT_ALGORITHM = 'rsa-sha256' -def get_digest(activity: Dict, algorithm: Optional[str] = None) -> str: +def generate_digest(activity: Dict, algorithm: Optional[str] = None) -> str: algorithm_dict = SUPPORTED_ALGORITHMS[ DEFAULT_ALGORITHM if algorithm is None else algorithm ] @@ -37,7 +41,7 @@ def get_digest(activity: Dict, algorithm: Optional[str] = None) -> str: return f"{algorithm_dict['algorithm']}={digest}" -def get_signature_header( +def generate_signature_header( host: str, path: str, date_str: str, actor: Actor, digest: str ) -> str: signed_string = ( @@ -51,7 +55,7 @@ def get_signature_header( signature = base64.b64encode(key_signer.sign(h)) return ( f'keyId="{actor.activitypub_id}",' - 'algorithm=rsa-sha256,' + f'algorithm={DEFAULT_ALGORITHM},' 'headers="(request-target) host date digest",' f'signature="' + signature.decode() + '"' ) @@ -115,13 +119,13 @@ def is_date_invalid(self) -> bool: delta = datetime.utcnow() - date return delta.total_seconds() > VALID_DATE_DELTA - def raise_error(self, error: str) -> None: + def log_and_raise_error(self, error: str) -> None: appLog.error(f'Invalid signature: {error} (host: {self.host}).') raise InvalidSignatureException(error) def verify_digest(self) -> None: if self.algorithm not in SUPPORTED_ALGORITHMS.keys(): - self.raise_error('unsupported algorithm') + self.log_and_raise_error('unsupported algorithm') expected_algorithm = SUPPORTED_ALGORITHMS[self.algorithm]['algorithm'] hash_function = SUPPORTED_ALGORITHMS[self.algorithm]['hash_function'] @@ -137,7 +141,7 @@ def verify_digest(self) -> None: if base64.b64decode(digest) != expected: raise Exception() except Exception: - self.raise_error('invalid HTTP digest') + self.log_and_raise_error('invalid HTTP digest') def header_actor_is_payload_actor(self) -> bool: activity = json.loads(self.request.data.decode()) @@ -145,14 +149,14 @@ def header_actor_is_payload_actor(self) -> bool: def verify(self) -> None: if not self.header_actor_is_payload_actor(): - self.raise_error('invalid actor') + self.log_and_raise_error('invalid actor') public_key = self.get_actor_public_key() if not public_key: - self.raise_error('invalid public key') + self.log_and_raise_error('invalid public key') if self.is_date_invalid(): - self.raise_error('invalid date header') + self.log_and_raise_error('invalid date header') comparison = [] for headers_part in self.headers.split(' '): @@ -178,4 +182,4 @@ def verify(self) -> None: try: signer.verify(digest, self.signature) except ValueError: - self.raise_error('verification failed') + self.log_and_raise_error('verification failed') diff --git a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py index 78e9ababf..358817bff 100644 --- a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -15,14 +15,14 @@ class TestSendToRemoteInbox(BaseTestMixin): - @patch('fittrackee.federation.inbox.get_digest') - @patch('fittrackee.federation.inbox.get_signature_header') + @patch('fittrackee.federation.inbox.generate_digest') + @patch('fittrackee.federation.inbox.generate_signature_header') @patch('fittrackee.federation.inbox.requests') - def test_it_calls_get_signature_header( + def test_it_calls_generate_signature_header( self, requests_mock: Mock, - get_signature_header_mock: Mock, - get_digest_mock: Mock, + generate_signature_header_mock: Mock, + generate_digest_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, @@ -31,7 +31,7 @@ def test_it_calls_get_signature_header( parsed_inbox_url = urlparse(remote_actor.inbox_url) requests_mock.post.return_value = generate_response(status_code=200) digest = random_string() - get_digest_mock.return_value = digest + generate_digest_mock.return_value = digest with freeze_time(now): send_to_remote_user_inbox( @@ -40,7 +40,7 @@ def test_it_calls_get_signature_header( recipient_inbox_url=remote_actor.inbox_url, ) - get_signature_header_mock.assert_called_with( + generate_signature_header_mock.assert_called_with( host=parsed_inbox_url.netloc, path=parsed_inbox_url.path, date_str=get_date_string(now), @@ -48,14 +48,14 @@ def test_it_calls_get_signature_header( digest=digest, ) - @patch('fittrackee.federation.inbox.get_digest') - @patch('fittrackee.federation.inbox.get_signature_header') + @patch('fittrackee.federation.inbox.generate_digest') + @patch('fittrackee.federation.inbox.generate_signature_header') @patch('fittrackee.federation.inbox.requests') def test_it_calls_requests_post( self, requests_mock: Mock, - get_signature_header_mock: Mock, - get_digest_mock: Mock, + generate_signature_header_mock: Mock, + generate_digest_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, @@ -65,9 +65,9 @@ def test_it_calls_requests_post( parsed_inbox_url = urlparse(remote_actor.inbox_url) requests_mock.post.return_value = generate_response(status_code=200) signed_header = random_string() - get_signature_header_mock.return_value = signed_header + generate_signature_header_mock.return_value = signed_header digest = random_string() - get_digest_mock.return_value = digest + generate_digest_mock.return_value = digest with freeze_time(now): send_to_remote_user_inbox( @@ -88,12 +88,12 @@ def test_it_calls_requests_post( }, ) - @patch('fittrackee.federation.inbox.get_signature_header') + @patch('fittrackee.federation.inbox.generate_signature_header') @patch('fittrackee.federation.inbox.requests') def test_it_logs_error_if_remote_inbox_returns_error( self, requests_mock: Mock, - get_signature_header_mock: Mock, + generate_signature_header_mock: Mock, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 3f06f1037..652e749e7 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -15,8 +15,8 @@ VALID_DATE_DELTA, VALID_DATE_FORMAT, SignatureVerification, - get_digest, - get_signature_header, + generate_digest, + generate_signature_header, ) from ...utils import generate_response, get_date_string, random_string @@ -30,12 +30,14 @@ } -class TestGetDigest: +class TestGenerateDigest: def test_it_returns_digest_with_default_algorithm(self) -> None: - assert get_digest(TEST_ACTIVITY).startswith('SHA-256=') + assert generate_digest(TEST_ACTIVITY).startswith('SHA-256=') def test_it_returns_digest_with_given_algorithm(self) -> None: - assert get_digest(TEST_ACTIVITY, 'rsa-sha512').startswith('SHA-512=') + assert generate_digest(TEST_ACTIVITY, 'rsa-sha512').startswith( + 'SHA-512=' + ) class SignatureVerificationTestCase: @@ -81,8 +83,8 @@ def generate_valid_headers( if date_str is None: now = datetime.utcnow() date_str = now.strftime(VALID_DATE_FORMAT) - digest = get_digest(activity) - signed_header = get_signature_header( + digest = generate_digest(activity) + signed_header = generate_signature_header( host, '/inbox', date_str, actor, digest ) return self.generate_headers( @@ -168,7 +170,7 @@ def test_it_instantiates_signature_verification(self) -> None: ) date_str = get_date_string() activity = {'foo': 'bar'} - digest = get_digest(activity) + digest = generate_digest(activity) valid_request_mock = self.get_request_mock( headers={ 'Host': random_string(), @@ -301,11 +303,11 @@ class TestSignatureDigestVerification(SignatureVerificationTestCase): ), ( 'mismatched digest (different data)', - get_digest({"foo": "bar"}), + generate_digest({"foo": "bar"}), ), ( 'mismatched digest (different algo)', - get_digest(TEST_ACTIVITY, algorithm='rsa-sha512'), + generate_digest(TEST_ACTIVITY, algorithm='rsa-sha512'), ), ], ) @@ -351,7 +353,7 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( host=app_with_federation.config['AP_DOMAIN'], date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), algorithm=input_algorithm, - digest=get_digest(activity, input_algorithm), + digest=generate_digest(activity, input_algorithm), ), data=activity, ) @@ -445,7 +447,7 @@ def test_verify_raises_error_if_algorithm_is_not_supported( key_id=actor_1.activitypub_id, date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), algorithm=algorithm, - digest=get_digest(activity), + digest=generate_digest(activity), ), data=activity, ) diff --git a/fittrackee/tests/federation/federation/test_users_inbox.py b/fittrackee/tests/federation/federation/test_users_inbox.py index cddef61b0..5d5ecbaf5 100644 --- a/fittrackee/tests/federation/federation/test_users_inbox.py +++ b/fittrackee/tests/federation/federation/test_users_inbox.py @@ -10,7 +10,10 @@ from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType from fittrackee.federation.models import Actor -from fittrackee.federation.signature import get_digest, get_signature_header +from fittrackee.federation.signature import ( + generate_digest, + generate_signature_header, +) from ...test_case_mixins import ApiTestCaseMixin from ...utils import ( @@ -40,7 +43,7 @@ def post_to_user_inbox( 'actor': actor_2.activitypub_id, 'object': actor_1.activitypub_id, } - digest = get_digest(follow_activity) + digest = generate_digest(follow_activity) with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( @@ -55,7 +58,7 @@ def post_to_user_inbox( 'Host': host, 'Date': date_str, 'Digest': digest, - 'Signature': get_signature_header( + 'Signature': generate_signature_header( host=host, path=inbox_path, date_str=date_str, From 42ec118364881fb6b5a61f63ca6003ca8c64b3f5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:14:12 +0100 Subject: [PATCH 037/238] API - verify signature without digest --- fittrackee/federation/signature.py | 22 +++--- .../federation/test_federation_signature.py | 71 +++++++++++++++++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index f643fc825..696932bb9 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -21,7 +21,7 @@ VALID_DATE_DELTA = 30 # in seconds VALID_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' -VALID_SIG_KEYS = ['keyId', 'algorithm', 'headers', 'signature'] +VALID_SIG_KEYS = ['keyId', 'headers', 'signature'] SUPPORTED_ALGORITHMS = { 'rsa-sha256': {'algorithm': 'SHA-256', 'hash_function': hashlib.sha256}, 'rsa-sha512': {'algorithm': 'SHA-512', 'hash_function': hashlib.sha512}, @@ -41,6 +41,14 @@ def generate_digest(activity: Dict, algorithm: Optional[str] = None) -> str: return f"{algorithm_dict['algorithm']}={digest}" +def generate_signature(private_key: str, signed_string: str) -> bytes: + key = RSA.import_key(private_key) + key_signer = pkcs1_15.new(key) + encoded_string = signed_string.encode('utf-8') + h = SHA256.new(encoded_string) + return base64.b64encode(key_signer.sign(h)) + + def generate_signature_header( host: str, path: str, date_str: str, actor: Actor, digest: str ) -> str: @@ -48,11 +56,7 @@ def generate_signature_header( f'(request-target): post {path}\nhost: {host}\ndate: {date_str}\n' f'digest: {digest}' ) - key = RSA.import_key(actor.private_key) - key_signer = pkcs1_15.new(key) - encoded_string = signed_string.encode('utf-8') - h = SHA256.new(encoded_string) - signature = base64.b64encode(key_signer.sign(h)) + signature = generate_signature(actor.private_key, signed_string) return ( f'keyId="{actor.activitypub_id}",' f'algorithm={DEFAULT_ALGORITHM},' @@ -69,7 +73,7 @@ def __init__(self, request: Request, signature_dict: Dict): self.key_id = signature_dict['keyId'] self.headers = signature_dict['headers'] self.signature = base64.b64decode(signature_dict['signature']) - self.algorithm = signature_dict['algorithm'] + self.algorithm = signature_dict.get('algorithm', DEFAULT_ALGORITHM) self.digest = request.headers.get('Digest') @classmethod @@ -86,9 +90,9 @@ def get_signature(cls, request: Request) -> 'SignatureVerification': raise InvalidSignatureException() keys_list = list(signature_dict.keys()) - if keys_list != VALID_SIG_KEYS: + if not all(key in keys_list for key in VALID_SIG_KEYS): appLog.error( - 'Invalid signature headers: invalid keys, expected: ' + 'Invalid signature headers: missing keys, expected: ' f'{VALID_SIG_KEYS}, got: {keys_list} ' f'(host: {host}).' ) diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 652e749e7..34c09b22a 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -16,6 +16,7 @@ VALID_DATE_FORMAT, SignatureVerification, generate_digest, + generate_signature, generate_signature_header, ) @@ -57,21 +58,30 @@ def generate_headers( ) -> Dict: key_id = key_id if key_id else random_string() signature = signature if signature else self.random_signature() - algorithm = algorithm if algorithm else random_string() + signature_headers = f'keyId="{key_id}",' + if algorithm is not None: + signature_headers += f'algorithm={algorithm},' + digest = digest if digest else random_string() + signature_headers += ( + 'headers="(request-target) host date digest",signature="' + + signature + + '"' + ) signature_headers = ( f'keyId="{key_id}",algorithm={algorithm},headers="(request-target)' f' host date digest",signature="' + signature + '"' ) - digest = digest if digest else random_string() if date_str is None: date_str = get_date_string(date) - return { + headers = { 'Host': host if host else random_string(), 'Date': date_str, - 'Digest': digest, 'Signature': signature_headers, 'Content-Type': 'application/ld+json', } + if digest: + headers['Digest'] = digest + return headers def generate_valid_headers( self, @@ -96,6 +106,39 @@ def generate_valid_headers( digest=digest, ) + @staticmethod + def _generate_signature_header_without_digest( + host: str, path: str, date_str: str, actor: Actor + ) -> str: + signed_string = ( + f'(request-target): post {path}\nhost: {host}\ndate: {date_str}' + ) + signature = generate_signature(actor.private_key, signed_string) + return ( + f'keyId="{actor.activitypub_id}",' + 'headers="(request-target) host date",' + f'signature="' + signature.decode() + '"' + ) + + def generate_valid_headers_without_digest( + self, + host: str, + actor: Actor, + date_str: Optional[str] = None, + ) -> Dict: + if date_str is None: + now = datetime.utcnow() + date_str = now.strftime(VALID_DATE_FORMAT) + signed_header = self._generate_signature_header_without_digest( + host, '/inbox', date_str, actor + ) + return self.generate_headers( + key_id=actor.activitypub_id, + signature=signed_header, + date_str=date_str, + host=host, + ) + @staticmethod def get_request_mock( headers: Optional[Dict] = None, data: Optional[Dict] = None @@ -537,3 +580,23 @@ def test_verify_does_not_raise_error_if_signature_is_valid( ) sig_verification.verify() + + def test_verify_does_not_raise_error_if_signature_without_digest_is_valid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + activity = self.get_activity(actor=actor_1) + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers_without_digest( + host=app_with_federation.config['AP_DOMAIN'], actor=actor_1 + ), + data=activity, + ) + ) + with patch.object(requests, 'get') as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor_1.serialize(), + ) + + sig_verification.verify() From d67d3961006096ba31873fdf5dce124f077c6d48 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 18 Jul 2021 11:21:45 +0200 Subject: [PATCH 038/238] API - fix signature actor verification --- fittrackee/federation/signature.py | 4 ++-- .../tests/federation/federation/test_federation_signature.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fittrackee/federation/signature.py b/fittrackee/federation/signature.py index 696932bb9..bcddb79f4 100644 --- a/fittrackee/federation/signature.py +++ b/fittrackee/federation/signature.py @@ -58,7 +58,7 @@ def generate_signature_header( ) signature = generate_signature(actor.private_key, signed_string) return ( - f'keyId="{actor.activitypub_id}",' + f'keyId="{actor.activitypub_id}#main-key",' f'algorithm={DEFAULT_ALGORITHM},' 'headers="(request-target) host date digest",' f'signature="' + signature.decode() + '"' @@ -149,7 +149,7 @@ def verify_digest(self) -> None: def header_actor_is_payload_actor(self) -> bool: activity = json.loads(self.request.data.decode()) - return self.key_id == activity.get('actor') + return self.key_id.replace('#main-key', '') == activity.get('actor') def verify(self) -> None: if not self.header_actor_is_payload_actor(): diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 34c09b22a..5f146e3f0 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -115,7 +115,7 @@ def _generate_signature_header_without_digest( ) signature = generate_signature(actor.private_key, signed_string) return ( - f'keyId="{actor.activitypub_id}",' + f'keyId="{actor.activitypub_id}#main-key",' 'headers="(request-target) host date",' f'signature="' + signature.decode() + '"' ) From edae36bd1011d8ce30346ba17dd70c2fb8fea64c Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 18 Jul 2021 12:12:32 +0200 Subject: [PATCH 039/238] API - minor refacto --- fittrackee/federation/models.py | 6 ++++++ fittrackee/federation/webfinger.py | 2 +- .../federation/test_federation_models.py | 16 ++++++++++++++++ .../federation/test_federation_webfinger.py | 17 +++++------------ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 1026de46b..d89e8bd09 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -118,6 +118,12 @@ def name(self) -> Optional[str]: return self.user.username return None + @property + def fullname(self) -> Optional[str]: + if self.type == ActorType.PERSON: + return f'{self.preferred_username}@{self.domain.name}' + return None + def update_remote_data(self, remote_user_data: Dict) -> None: self.activitypub_id = remote_user_data['id'] self.type = ActorType(remote_user_data['type']) diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index ace9b7016..5802d87af 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -73,7 +73,7 @@ def webfinger(app_domain: Domain) -> HttpResponse: return UserNotFoundErrorResponse() response = { - 'subject': f'acct:{actor.preferred_username}@{actor.domain.name}', + 'subject': f'acct:{actor.fullname}', 'links': [ { 'href': actor.activitypub_id, diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index f5e079fbf..27265b9c5 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -70,6 +70,14 @@ def test_actor_is_local( ) -> None: assert not actor_1.is_remote + def test_it_returns_fullname( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + assert ( + actor_1.fullname + == f'{actor_1.preferred_username}@{actor_1.domain.name}' + ) + def test_it_returns_serialized_object( self, app_with_federation: Flask, actor_1: Actor ) -> None: @@ -127,6 +135,14 @@ def test_actor_is_remote( ) -> None: assert remote_actor.is_remote + def test_it_returns_fullname( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + assert ( + actor_1.fullname + == f'{actor_1.preferred_username}@{actor_1.domain.name}' + ) + def test_it_returns_serialized_object( self, app_with_federation: Flask, diff --git a/fittrackee/tests/federation/federation/test_federation_webfinger.py b/fittrackee/tests/federation/federation/test_federation_webfinger.py index b897ad7b1..34a1cbf03 100644 --- a/fittrackee/tests/federation/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/federation/test_federation_webfinger.py @@ -84,8 +84,7 @@ def test_it_returns_json_resource_descriptor_as_content_type( ) -> None: client = app_with_federation.test_client() response = client.get( - '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain.name}', + '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', content_type='application/json', ) @@ -97,25 +96,20 @@ def test_it_returns_subject_with_user_data( ) -> None: client = app_with_federation.test_client() response = client.get( - '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain.name}', + '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', content_type='application/json', ) assert response.status_code == 200 data = json.loads(response.data.decode()) - assert ( - f'acct:{actor_1.preferred_username}@{actor_1.domain.name}' - in data['subject'] - ) + assert f'acct:{actor_1.fullname}' in data['subject'] def test_it_returns_user_links( self, app_with_federation: Flask, actor_1: Actor ) -> None: client = app_with_federation.test_client() response = client.get( - '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{actor_1.domain.name}', + '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', content_type='application/json', ) @@ -132,8 +126,7 @@ def test_it_returns_error_if_federation_is_disabled( ) -> None: client = app.test_client() response = client.get( - '/.well-known/webfinger?resource=acct:' - f'{app_actor.preferred_username}@{app_actor.domain.name}', + '/.well-known/webfinger?resource=acct:' f'{app_actor.fullname}', content_type='application/json', ) From 1c38570f4de4de66169ee89d576713a2e6fa5c77 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:16:00 +0100 Subject: [PATCH 040/238] API - test refacto --- fittrackee/federation/utils.py | 5 - .../federation/test_federation_activities.py | 74 ++++----- .../federation/test_federation_federation.py | 154 ++++++------------ .../federation/federation/test_remote_user.py | 18 +- .../federation/users/test_users_follow_api.py | 45 +++-- .../federation/users/test_users_model.py | 5 +- .../tests/fixtures/fixtures_federation.py | 20 ++- fittrackee/tests/utils.py | 94 ++++++----- fittrackee/users/models.py | 6 +- 9 files changed, 185 insertions(+), 236 deletions(-) diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 1ef8636f1..6b56df2ab 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,7 +1,6 @@ import re from importlib import import_module from typing import Callable, Dict, Optional, Tuple -from uuid import uuid4 from Crypto.PublicKey import RSA from flask import current_app @@ -47,10 +46,6 @@ def remove_url_scheme(url: str) -> str: return re.sub(r'https?://', '', url) -def generate_activity_id() -> str: - return f"{current_app.config['UI_URL']}/{uuid4()}" - - def get_username_and_domain(full_name: str) -> Optional[re.Match]: full_name_pattern = r'([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})' return re.match(full_name_pattern, full_name) diff --git a/fittrackee/tests/federation/federation/test_federation_activities.py b/fittrackee/tests/federation/federation/test_federation_activities.py index 91211b771..9a0685146 100644 --- a/fittrackee/tests/federation/federation/test_federation_activities.py +++ b/fittrackee/tests/federation/federation/test_federation_activities.py @@ -1,3 +1,5 @@ +from typing import Dict, Optional, Union + import pytest from flask import Flask @@ -8,14 +10,11 @@ UnsupportedActivityException, ) from fittrackee.federation.models import Actor -from fittrackee.federation.utils import ( - generate_activity_id, - get_activity_instance, -) +from fittrackee.federation.utils import get_activity_instance from fittrackee.users.exceptions import FollowRequestAlreadyRejectedError from fittrackee.users.models import FollowRequest -from ...utils import random_domain_with_scheme, random_string +from ...utils import RandomActor, random_string SUPPORTED_ACTIVITIES = [(f'{a.value} activity', a.value) for a in ActivityType] @@ -37,17 +36,28 @@ def test_it_raises_exception_if_activity_type_is_invalid(self) -> None: class TestFollowActivity: - def test_it_raises_error_if_target_actor_does_not_exists( - self, app_with_federation: Flask, remote_actor: Actor - ) -> None: - follow_activity = { + @staticmethod + def generate_follow_activity( + actor_id: Optional[str] = None, + object_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + if object_actor is None: + object_actor = RandomActor() + return { '@context': AP_CTX, - 'id': generate_activity_id(), + 'id': f'{actor_id}#follow/{object_actor.fullname}', 'type': ActivityType.FOLLOW.value, - 'actor': remote_actor.activitypub_id, - 'object': f'{random_domain_with_scheme}/users/{random_string()}', + 'actor': actor_id if actor_id else RandomActor().activitypub_id, + 'object': object_actor.activitypub_id, } + def test_it_raises_error_if_target_actor_does_not_exists( + self, app_with_federation: Flask, remote_actor: Actor + ) -> None: + follow_activity = self.generate_follow_activity( + actor_id=remote_actor.activitypub_id + ) + activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) @@ -61,13 +71,7 @@ def test_it_raises_error_if_target_actor_does_not_exists( def test_it_raises_error_if_remote_actor_does_not_exists( self, app_with_federation: Flask, actor_1: Actor ) -> None: - follow_activity = { - '@context': AP_CTX, - 'id': generate_activity_id(), - 'type': ActivityType.FOLLOW.value, - 'actor': f'{random_domain_with_scheme}/users/{random_string()}', - 'object': actor_1.activitypub_id, - } + follow_activity = self.generate_follow_activity(object_actor=actor_1) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -87,13 +91,9 @@ def test_it_raises_error_if_follow_request_already_rejected( follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: actor_1.user.refuses_follow_request_from(remote_actor.user) - follow_activity = { - '@context': AP_CTX, - 'id': generate_activity_id(), - 'type': ActivityType.FOLLOW.value, - 'actor': remote_actor.activitypub_id, - 'object': actor_1.activitypub_id, - } + follow_activity = self.generate_follow_activity( + actor_id=remote_actor.activitypub_id, object_actor=actor_1 + ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -105,14 +105,9 @@ def test_it_raises_error_if_follow_request_already_rejected( def test_it_creates_follow_request( self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor ) -> None: - follow_activity = { - '@context': AP_CTX, - 'id': generate_activity_id(), - 'type': ActivityType.FOLLOW.value, - 'actor': remote_actor.activitypub_id, - 'object': actor_1.activitypub_id, - } - + follow_activity = self.generate_follow_activity( + actor_id=remote_actor.activitypub_id, object_actor=actor_1 + ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) @@ -131,14 +126,9 @@ def test_it_does_raise_error_if_pending_follow_request_already_exist( remote_actor: Actor, follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - follow_activity = { - '@context': AP_CTX, - 'id': generate_activity_id(), - 'type': ActivityType.FOLLOW.value, - 'actor': remote_actor.activitypub_id, - 'object': actor_1.activitypub_id, - } - + follow_activity = self.generate_follow_activity( + actor_id=remote_actor.activitypub_id, object_actor=actor_1 + ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index f8eb3d1fa..cf9a74881 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -9,12 +9,7 @@ from fittrackee.users.models import User from ...test_case_mixins import ApiTestCaseMixin -from ...utils import ( - get_remote_user_object, - random_actor_url, - random_domain_with_scheme, - random_string, -) +from ...utils import RandomActor, random_string class TestFederationUser: @@ -72,14 +67,14 @@ def test_it_returns_error_if_federation_is_disabled( class TestRemoteUser(ApiTestCaseMixin): def test_it_returns_error_if_federation_is_disabled( - self, app: Flask, user_1: User + self, app: Flask, user_1: User, random_actor: RandomActor ) -> None: client, auth_token = self.get_test_client_and_auth_token(app) response = client.post( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor_url()}), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 403 @@ -91,13 +86,13 @@ def test_it_returns_error_if_federation_is_disabled( ) def test_it_returns_error_if_user_is_not_logged( - self, app_with_federation: Flask + self, app_with_federation: Flask, random_actor: RandomActor ) -> None: client = app_with_federation.test_client() response = client.post( '/federation/remote-user', content_type='application/json', - data=json.dumps({'actor_url': random_actor_url()}), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 401 @@ -123,7 +118,10 @@ def test_it_returns_400_if_remote_user_url_is_missing( assert 'invalid payload' in data['message'] def test_it_returns_error_if_remote_instance_returns_error( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app_with_federation @@ -136,7 +134,7 @@ def test_it_returns_error_if_remote_instance_returns_error( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor_url()}), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 400 @@ -145,7 +143,10 @@ def test_it_returns_error_if_remote_instance_returns_error( assert 'Can not fetch remote actor.' in data['message'] def test_it_returns_error_if_remote_actor_object_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app_with_federation @@ -158,7 +159,7 @@ def test_it_returns_error_if_remote_actor_object_is_invalid( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor_url()}), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 400 @@ -167,9 +168,12 @@ def test_it_returns_error_if_remote_actor_object_is_invalid( assert 'Invalid remote actor object.' in data['message'] def test_it_returns_error_if_keys_are_missing_in_remote_actor_object( - self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + self, + app_with_federation: Flask, + actor_1: Actor, + remote_domain: Domain, + random_actor: RandomActor, ) -> None: - remote_username = random_string() client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) @@ -177,20 +181,13 @@ def test_it_returns_error_if_keys_are_missing_in_remote_actor_object( 'fittrackee.federation.federation.get_remote_user' ) as get_remote_user_mock: get_remote_user_mock.return_value = { - 'preferredUsername': remote_username, + 'preferredUsername': random_actor.preferred_username, } response = client.post( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_username, - domain_with_scheme=f'https://{remote_domain.name}', - ) - } - ), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 400 @@ -202,11 +199,10 @@ def test_it_returns_error_if_remote_domain_is_local_domain( self, app_with_federation: Flask, actor_1: Actor, + random_actor: RandomActor, ) -> None: - remote_username = random_string() - domain = f"https://{ app_with_federation.config['AP_DOMAIN']}" - remote_user_object = get_remote_user_object( - username=remote_username, domain_with_scheme=domain + random_actor.domain = ( + f"https://{ app_with_federation.config['AP_DOMAIN']}" ) client, auth_token = self.get_test_client_and_auth_token( app_with_federation @@ -214,18 +210,14 @@ def test_it_returns_error_if_remote_domain_is_local_domain( with patch( 'fittrackee.federation.federation.get_remote_user' ) as get_remote_user_mock: - get_remote_user_mock.return_value = remote_user_object + get_remote_user_mock.return_value = ( + random_actor.get_remote_user_object() + ) response = client.post( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_username, domain_with_scheme=domain - ) - } - ), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 400 @@ -237,19 +229,17 @@ def test_it_returns_error_if_remote_domain_is_local_domain( ) def test_it_creates_remote_actor_if_actor_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + self, + app_with_federation: Flask, + actor_1: Actor, + remote_domain: Domain, + random_actor: RandomActor, ) -> None: - remote_username = random_string() - remote_preferred_username = random_string() - domain = f'https://{remote_domain.name}' - remote_user_object = get_remote_user_object( - username=remote_username, - preferred_username=remote_preferred_username, - domain_with_scheme=domain, - ) + random_actor.domain = f'https://{remote_domain.name}' client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) + remote_user_object = random_actor.get_remote_user_object() with patch( 'fittrackee.federation.federation.get_remote_user' ) as get_remote_user_mock: @@ -258,14 +248,7 @@ def test_it_creates_remote_actor_if_actor_does_not_exist( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_preferred_username, - domain_with_scheme=domain, - ) - } - ), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 200 @@ -273,16 +256,12 @@ def test_it_creates_remote_actor_if_actor_does_not_exist( assert data == remote_user_object def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, ) -> None: - remote_username = random_string() - remote_preferred_username = random_string() - domain = random_domain_with_scheme() - remote_user_object = get_remote_user_object( - username=remote_username, - preferred_username=remote_preferred_username, - domain_with_scheme=domain, - ) + remote_user_object = random_actor.get_remote_user_object() client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) @@ -294,21 +273,14 @@ def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_preferred_username, - domain_with_scheme=domain, - ) - } - ), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert data == remote_user_object - def test_it_returns_updated_remote_actor_if_remote_domain_exists( + def test_it_returns_updated_remote_actor_if_remote_actor_exists( self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor ) -> None: remote_user_object = remote_actor.serialize() @@ -336,7 +308,11 @@ def test_it_returns_updated_remote_actor_if_remote_domain_exists( assert remote_actor.last_fetch_date != last_fetched def test_it_creates_several_remote_actors( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, + random_actor_2: RandomActor, ) -> None: """ check constrains on User model (especially empty password and email) @@ -347,50 +323,26 @@ def test_it_creates_several_remote_actors( with patch( 'fittrackee.federation.federation.get_remote_user' ) as get_remote_user_mock: - remote_preferred_username = random_string() - domain = random_domain_with_scheme() - remote_user_object = get_remote_user_object( - preferred_username=remote_preferred_username, - domain_with_scheme=domain, - ) + remote_user_object = random_actor.get_remote_user_object() get_remote_user_mock.return_value = remote_user_object response = client.post( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_preferred_username, - domain_with_scheme=domain, - ) - } - ), + data=json.dumps({'actor_url': random_actor.activitypub_id}), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert data == remote_user_object - remote_preferred_username = random_string() - domain = random_domain_with_scheme() - remote_user_object = get_remote_user_object( - preferred_username=remote_preferred_username, - domain_with_scheme=domain, - ) + remote_user_object = random_actor_2.get_remote_user_object() get_remote_user_mock.return_value = remote_user_object response = client.post( '/federation/remote-user', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps( - { - 'actor_url': random_actor_url( - username=remote_preferred_username, - domain_with_scheme=domain, - ) - } - ), + data=json.dumps({'actor_url': random_actor_2.activitypub_id}), ) assert response.status_code == 200 diff --git a/fittrackee/tests/federation/federation/test_remote_user.py b/fittrackee/tests/federation/federation/test_remote_user.py index 12462896e..997c199bd 100644 --- a/fittrackee/tests/federation/federation/test_remote_user.py +++ b/fittrackee/tests/federation/federation/test_remote_user.py @@ -6,13 +6,7 @@ from fittrackee.federation.exceptions import ActorNotFoundException from fittrackee.federation.remote_user import get_remote_user -from ...utils import ( - generate_response, - get_remote_user_object, - random_actor_url, - random_domain_with_scheme, - random_string, -) +from ...utils import RandomActor, generate_response, random_actor_url class TestGetRemoteUser: @@ -24,18 +18,14 @@ def test_it_returns_error_if_remote_instance_returns_error(self) -> None: get_remote_user(random_actor_url()) def test_it_returns_user_object_if_remote_response_is_successful( - self, + self, random_actor: RandomActor ) -> None: - username = random_string() - remote_domain = random_domain_with_scheme() - remote_user = get_remote_user_object(username, remote_domain) + remote_user = random_actor.get_remote_user_object() with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( status_code=200, content=remote_user ) - expected_user = get_remote_user( - random_actor_url(username, remote_domain) - ) + expected_user = get_remote_user(random_actor.activitypub_id) assert remote_user == expected_user diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index 278b279b3..e38b2b21c 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -8,14 +8,16 @@ from fittrackee.users.models import FollowRequest from ...test_case_mixins import ApiTestCaseMixin, BaseTestMixin -from ...utils import random_domain, random_string +from ...utils import RandomActor, random_string class TestFollowWithFederation(ApiTestCaseMixin): """Follow user belonging to the same instance""" def test_it_raises_error_if_target_user_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app_with_federation @@ -129,15 +131,17 @@ class TestRemoteFollowWithFederation(BaseTestMixin, ApiTestCaseMixin): """Follow user from another instance""" def test_it_raise_error_if_remote_actor_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) - remote_account = f'{random_string()}@{random_domain()}' response = client.post( - f'/api/users/{remote_account}/follow', + f'/api/users/{random_actor.fullname}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -148,15 +152,18 @@ def test_it_raise_error_if_remote_actor_does_not_exist( assert data['message'] == 'user does not exist' def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domain( # noqa - self, app_with_federation: Flask, actor_1: Actor, remote_domain: Domain + self, + app_with_federation: Flask, + actor_1: Actor, + remote_domain: Domain, + random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) - remote_account = f'{random_string()}@{remote_domain.name}' response = client.post( - f'/api/users/{remote_account}/follow', + f'/api/users/{random_actor.fullname}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -177,12 +184,9 @@ def test_it_creates_follow_request( client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) - remote_account = ( - f'{remote_actor.preferred_username}@{remote_actor.domain.name}' - ) response = client.post( - f'/api/users/{remote_account}/follow', + f'/api/users/{remote_actor.fullname}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -192,7 +196,7 @@ def test_it_creates_follow_request( assert data['status'] == 'success' assert ( data['message'] - == f"Follow request to user '{remote_account}' is sent." + == f"Follow request to user '{remote_actor.fullname}' is sent." ) def test_it_returns_success_if_follow_request_already_exists( @@ -205,12 +209,9 @@ def test_it_returns_success_if_follow_request_already_exists( client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) - remote_account = ( - f'{remote_actor.preferred_username}@{remote_actor.domain.name}' - ) response = client.post( - f'/api/users/{remote_account}/follow', + f'/api/users/{remote_actor.fullname}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -220,7 +221,7 @@ def test_it_returns_success_if_follow_request_already_exists( assert data['status'] == 'success' assert ( data['message'] - == f"Follow request to user '{remote_account}' is sent." + == f"Follow request to user '{remote_actor.fullname}' is sent." ) @patch('fittrackee.users.models.send_to_users_inbox') @@ -234,12 +235,9 @@ def test_it_calls_send_to_inbox( client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) - remote_account = ( - f'{remote_actor.preferred_username}@{remote_actor.domain.name}' - ) client.post( - f'/api/users/{remote_account}/follow', + f'/api/users/{remote_actor.fullname}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -257,5 +255,4 @@ def test_it_calls_send_to_inbox( followed_user_id=remote_actor.user.id, ).first() activity = follow_request.get_activity() - del activity['id'] - self.assert_dict_contains_subset(call_args['activity'], activity) + assert call_args['activity'] == activity diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index 94c072226..ca3c466f3 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -32,11 +32,10 @@ def test_it_returns_activity_object_when_federation_is_enabled( ) -> None: activity_object = follow_request_from_user_1_to_user_2.get_activity() - assert app_with_federation.config['UI_URL'] in activity_object['id'] - expected_object_subset = { + assert activity_object == { '@context': AP_CTX, + 'id': f'{actor_1.activitypub_id}#follow/{actor_2.fullname}', 'type': 'Follow', 'actor': actor_1.activitypub_id, 'object': actor_2.activitypub_id, } - assert {**activity_object, **expected_object_subset} == activity_object diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 6c7e5682d..4709429ec 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -5,7 +5,7 @@ from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import User -from ..utils import get_remote_user_object, random_domain +from ..utils import RandomActor, get_remote_user_object, random_domain @pytest.fixture() @@ -66,11 +66,15 @@ def remote_domain(app_with_federation: Flask) -> Domain: @pytest.fixture() def remote_actor( - user_2: User, app_with_federation: Flask, remote_domain: Domain + user_2: User, + app_with_federation: Flask, + remote_domain: Domain, ) -> Actor: domain = f'https://{remote_domain.name}' remote_user_object = get_remote_user_object( - username=user_2.username, domain_with_scheme=domain + username=user_2.username, + preferred_username=user_2.username, + domain=domain, ) actor = Actor( preferred_username=user_2.username, @@ -83,3 +87,13 @@ def remote_actor( user_2.actor_id = actor.id db.session.commit() return actor + + +@pytest.fixture() +def random_actor() -> RandomActor: + return RandomActor() + + +@pytest.fixture() +def random_actor_2() -> RandomActor: + return RandomActor() diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 8e3ecd948..97260ff9a 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -1,5 +1,6 @@ import random import string +from dataclasses import dataclass from datetime import datetime from json import dumps from typing import Dict, Optional, Union @@ -40,6 +41,57 @@ def get_date_string(date: Optional[datetime] = None) -> str: return date.strftime(VALID_DATE_FORMAT) +def get_remote_user_object( + username: str, + preferred_username: str, + domain: str, +) -> Dict: + user_url = f'{domain}/users/{username}' + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id': user_url, + 'type': 'Person', + 'following': f'{user_url}/following', + 'followers': f'{user_url}/followers', + 'inbox': f'{user_url}/inbox', + 'outbox': f'{user_url}/outbox', + 'name': username, + 'preferredUsername': preferred_username, + 'manuallyApprovesFollowers': True, + 'publicKey': { + 'id': f'{user_url}#main-key', + 'owner': user_url, + 'publicKeyPem': random_string(), + }, + 'endpoints': {'sharedInbox': f'{domain}/inbox'}, + } + + +@dataclass +class RandomActor: + name: str = random_string() + preferred_username: str = random_string() + domain: str = random_domain_with_scheme() + + @property + def fullname(self) -> str: + return ( + f'{self.preferred_username}@{self.domain.replace("https://", "")}' + ) + + @property + def activitypub_id(self) -> str: + return f'{self.domain}/users/{self.preferred_username}' + + def get_remote_user_object(self) -> Dict: + return get_remote_user_object( + self.name, self.preferred_username, self.domain + ) + + def generate_response( content: Optional[Union[str, Dict]] = None, status_code: Optional[int] = None, @@ -55,10 +107,6 @@ def generate_response( return response -def random_full_username() -> str: - return f'{random_string()}@{random_domain()}' - - def random_actor_url( username: Optional[str] = None, domain_with_scheme: Optional[str] = None ) -> str: @@ -69,41 +117,3 @@ def random_actor_url( else random_domain_with_scheme() ) return f'{remote_domain}/users/{username}' - - -def get_remote_user_object( - username: Optional[str] = None, - preferred_username: Optional[str] = None, - domain_with_scheme: Optional[str] = None, -) -> Dict: - username = username if username else random_string() - preferred_username = ( - preferred_username if preferred_username else random_string() - ) - remote_domain = ( - domain_with_scheme - if domain_with_scheme - else random_domain_with_scheme() - ) - user_url = random_actor_url(username, remote_domain) - return { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - ], - 'id': user_url, - 'type': 'Person', - 'following': f'{user_url}/following', - 'followers': f'{user_url}/followers', - 'inbox': f'{user_url}/inbox', - 'outbox': f'{user_url}/outbox', - 'name': username, - 'preferredUsername': preferred_username, - 'manuallyApprovesFollowers': True, - 'publicKey': { - 'id': f'{user_url}#main-key', - 'owner': user_url, - 'publicKeyPem': random_string(), - }, - 'endpoints': {'sharedInbox': f'{remote_domain}/inbox'}, - } diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index e484cdeaf..79a07ec6e 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -13,7 +13,6 @@ from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.federation.models import Actor, Domain from fittrackee.federation.tasks.user_inbox import send_to_users_inbox -from fittrackee.federation.utils import generate_activity_id from fittrackee.workouts.models import Workout from .exceptions import ( @@ -72,7 +71,10 @@ def get_activity(self) -> Dict: raise FederationDisabledException() return { '@context': AP_CTX, - 'id': generate_activity_id(), + 'id': ( + f'{self.from_user.actor.activitypub_id}#follow/' + f'{self.to_user.actor.fullname}' + ), 'type': ActivityType.FOLLOW.value, 'actor': self.from_user.actor.activitypub_id, 'object': self.to_user.actor.activitypub_id, From 821589de9c22775fb0058a1cfc019a5a4f879afe Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:29:30 +0100 Subject: [PATCH 041/238] API - add Accept and Reject activities in response to Follow activity --- fittrackee/federation/activities.py | 107 +++++-- fittrackee/federation/enums.py | 2 + fittrackee/federation/exceptions.py | 8 + fittrackee/federation/tasks/activity.py | 3 +- fittrackee/federation/utils.py | 21 +- fittrackee/federation/utils_user.py | 33 ++ .../federation/test_federation_activities.py | 289 ++++++++++++++++-- .../federation/users/test_users_follow_api.py | 24 +- .../users/test_users_follow_request_api.py | 272 ++++++++++++++++- .../federation/users/test_users_model.py | 58 +++- .../fixtures/fixtures_federation_users.py | 39 +++ fittrackee/tests/test_case_mixins.py | 45 +++ fittrackee/tests/users/test_auth_api.py | 4 +- .../tests/users/test_users_follow_api.py | 2 +- .../users/test_users_follow_request_api.py | 160 ++++++++++ fittrackee/tests/users/test_users_model.py | 4 +- fittrackee/tests/workouts/test_stats_api.py | 2 +- .../workouts/test_workouts_api_1_post.py | 2 +- .../workouts/test_workouts_api_2_patch.py | 4 +- fittrackee/users/follow_requests.py | 74 ++++- fittrackee/users/models.py | 42 ++- fittrackee/users/users.py | 50 ++- fittrackee/users/utils/follow.py | 15 - 23 files changed, 1121 insertions(+), 139 deletions(-) create mode 100644 fittrackee/federation/utils_user.py delete mode 100644 fittrackee/users/utils/follow.py diff --git a/fittrackee/federation/activities.py b/fittrackee/federation/activities.py index 953ee250a..8ab14c2a9 100644 --- a/fittrackee/federation/activities.py +++ b/fittrackee/federation/activities.py @@ -1,45 +1,118 @@ from abc import ABC, abstractmethod -from typing import Dict +from importlib import import_module +from typing import Callable, Dict, Tuple from fittrackee import appLog from fittrackee.federation.exceptions import ActorNotFoundException from fittrackee.federation.models import Actor -from fittrackee.users.exceptions import FollowRequestAlreadyRejectedError -from fittrackee.users.utils.follow import create_follow_request +from fittrackee.users.exceptions import ( + FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, + NotExistingFollowRequestError, +) + +from .exceptions import UnsupportedActivityException + + +def get_activity_instance(activity_dict: Dict) -> Callable: + activity_type = activity_dict['type'] + try: + Activity = getattr( + import_module('fittrackee.federation.activities'), + f'{activity_type}Activity', + ) + except AttributeError: + raise UnsupportedActivityException(activity_type) + return Activity class AbstractActivity(ABC): def __init__(self, activity_dict: Dict) -> None: self.activity = activity_dict + def activity_name(self) -> str: + return self.__class__.__name__ + @abstractmethod def process_activity(self) -> None: pass -class FollowActivity(AbstractActivity): - def process_activity(self) -> None: - followed_actor = Actor.query.filter_by( - activitypub_id=self.activity['object'] +class FollowBaseActivity(AbstractActivity): + def get_actors(self) -> Tuple[Actor, Actor]: + """ + return actors from activity 'actor' and 'object' + """ + actor = Actor.query.filter_by( + activitypub_id=self.activity['actor'] ).first() - if not followed_actor: + if not actor: raise ActorNotFoundException( - message='followed actor not found for Follow Activity' + message=f'actor not found for {self.activity_name()}' ) - follower_actor = Actor.query.filter_by( - activitypub_id=self.activity['actor'] + object_actor_activitypub_id = ( + self.activity['object'] + if isinstance(self.activity['object'], str) + else self.activity['object']['actor'] + ) + object_actor = Actor.query.filter_by( + activitypub_id=object_actor_activitypub_id ).first() - if not follower_actor: + if not object_actor: raise ActorNotFoundException( - message='followed actor not found for Follow Activity' + message=f'object actor not found for {self.activity_name()}' ) + return actor, object_actor + + @abstractmethod + def process_activity(self) -> None: + pass + +class FollowActivity(FollowBaseActivity): + def process_activity(self) -> None: + follower_actor, followed_actor = self.get_actors() try: - create_follow_request( - follower_user_id=follower_actor.user.id, - followed_user=followed_actor.user, - ) + follower_actor.user.send_follow_request_to(followed_actor.user) except FollowRequestAlreadyRejectedError as e: appLog.error('Follow activity: follow request already rejected.') raise e + + +class AcceptActivity(FollowBaseActivity): + def process_activity(self) -> None: + followed_actor, follower_actor = self.get_actors() + try: + followed_actor.user.approves_follow_request_from( + follower_actor.user + ) + except NotExistingFollowRequestError as e: + appLog.error( + f'{self.activity_name()}: follow request does not exist.' + ) + raise e + except FollowRequestAlreadyProcessedError as e: + appLog.error( + f'{self.activity_name()}: follow request already processed.' + ) + raise e + + +class RejectActivity(FollowBaseActivity): + def process_activity(self) -> None: + followed_actor, follower_actor = self.get_actors() + try: + followed_actor.user.rejects_follow_request_from( + follower_actor.user + ) + except NotExistingFollowRequestError as e: + appLog.error( + f'{self.activity_name()}: follow request does not exist.' + ) + raise e + except FollowRequestAlreadyProcessedError as e: + appLog.error( + f'{self.activity_name()}: follow request already processed.' + ) + raise e diff --git a/fittrackee/federation/enums.py b/fittrackee/federation/enums.py index bf950af56..831188339 100644 --- a/fittrackee/federation/enums.py +++ b/fittrackee/federation/enums.py @@ -2,7 +2,9 @@ class ActivityType(Enum): + ACCEPT = 'Accept' FOLLOW = 'Follow' + REJECT = 'Reject' class ActorType(Enum): diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py index 78f8f44e5..85bcb9da5 100644 --- a/fittrackee/federation/exceptions.py +++ b/fittrackee/federation/exceptions.py @@ -11,6 +11,14 @@ def __init__(self, message: Optional[str] = None) -> None: ) +class DomainNotFoundException(GenericException): + def __init__(self, domain: str) -> None: + super().__init__( + status='error', + message=f"Domain '{domain}' not found.", + ) + + class FederationDisabledException(GenericException): def __init__(self) -> None: super().__init__( diff --git a/fittrackee/federation/tasks/activity.py b/fittrackee/federation/tasks/activity.py index 2d081ff04..e09f878b7 100644 --- a/fittrackee/federation/tasks/activity.py +++ b/fittrackee/federation/tasks/activity.py @@ -1,7 +1,8 @@ from typing import Dict from fittrackee import dramatiq -from fittrackee.federation.utils import get_activity_instance + +from ..activities import get_activity_instance @dramatiq.actor(queue_name='fittrackee_activities') diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 6b56df2ab..02b2a7624 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -1,12 +1,10 @@ import re -from importlib import import_module -from typing import Callable, Dict, Optional, Tuple +from typing import Dict, Tuple from Crypto.PublicKey import RSA from flask import current_app from .enums import ActivityType -from .exceptions import UnsupportedActivityException def generate_keys() -> Tuple[str, str]: @@ -46,26 +44,9 @@ def remove_url_scheme(url: str) -> str: return re.sub(r'https?://', '', url) -def get_username_and_domain(full_name: str) -> Optional[re.Match]: - full_name_pattern = r'([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})' - return re.match(full_name_pattern, full_name) - - def is_invalid_activity_data(activity_data: Dict) -> bool: return ( 'type' not in activity_data or 'object' not in activity_data or activity_data['type'] not in [a.value for a in ActivityType] ) - - -def get_activity_instance(activity_dict: Dict) -> Callable: - activity_type = activity_dict["type"] - try: - Activity = getattr( - import_module('fittrackee.federation.activities'), - f'{activity_type}Activity', - ) - except AttributeError: - raise UnsupportedActivityException(activity_type) - return Activity diff --git a/fittrackee/federation/utils_user.py b/fittrackee/federation/utils_user.py new file mode 100644 index 000000000..7c0fe7180 --- /dev/null +++ b/fittrackee/federation/utils_user.py @@ -0,0 +1,33 @@ +import re +from typing import Optional + +from fittrackee.federation.models import Actor, Domain +from fittrackee.users.exceptions import UserNotFoundException +from fittrackee.users.models import User + +from .exceptions import ActorNotFoundException, DomainNotFoundException + + +def get_username_and_domain(full_name: str) -> Optional[re.Match]: + full_name_pattern = r'([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})' + return re.match(full_name_pattern, full_name) + + +def get_user_from_username(user_name: str) -> User: + user_name_and_domain = get_username_and_domain(user_name) + if user_name_and_domain is None: # local actor + user = User.query.filter_by(username=user_name).first() + else: # remote actor + name, domain_name = user_name_and_domain.groups() + domain = Domain.query.filter_by(name=domain_name).first() + if not domain: + raise DomainNotFoundException(domain_name) + actor = Actor.query.filter_by( + preferred_username=name, domain_id=domain.id + ).first() + if not actor: + raise ActorNotFoundException() + user = actor.user + if not user: + raise UserNotFoundException() + return user diff --git a/fittrackee/tests/federation/federation/test_federation_activities.py b/fittrackee/tests/federation/federation/test_federation_activities.py index 9a0685146..8890bf1ca 100644 --- a/fittrackee/tests/federation/federation/test_federation_activities.py +++ b/fittrackee/tests/federation/federation/test_federation_activities.py @@ -3,6 +3,7 @@ import pytest from flask import Flask +from fittrackee.federation.activities import get_activity_instance from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType from fittrackee.federation.exceptions import ( @@ -10,8 +11,11 @@ UnsupportedActivityException, ) from fittrackee.federation.models import Actor -from fittrackee.federation.utils import get_activity_instance -from fittrackee.users.exceptions import FollowRequestAlreadyRejectedError +from fittrackee.users.exceptions import ( + FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, + NotExistingFollowRequestError, +) from fittrackee.users.models import FollowRequest from ...utils import RandomActor, random_string @@ -35,43 +39,94 @@ def test_it_raises_exception_if_activity_type_is_invalid(self) -> None: get_activity_instance({'type': random_string()}) -class TestFollowActivity: +class FollowRequestActivitiesTestCase: @staticmethod def generate_follow_activity( - actor_id: Optional[str] = None, - object_actor: Optional[Union[Actor, RandomActor]] = None, + follower_actor_id: Optional[str] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, ) -> Dict: - if object_actor is None: - object_actor = RandomActor() + if follower_actor_id is None: + follower_actor_id = RandomActor().activitypub_id + if followed_actor is None: + followed_actor = RandomActor() return { '@context': AP_CTX, - 'id': f'{actor_id}#follow/{object_actor.fullname}', + 'id': f'{follower_actor_id}#follows/{followed_actor.fullname}', 'type': ActivityType.FOLLOW.value, - 'actor': actor_id if actor_id else RandomActor().activitypub_id, - 'object': object_actor.activitypub_id, + 'actor': follower_actor_id, + 'object': followed_actor.activitypub_id, } - def test_it_raises_error_if_target_actor_does_not_exists( + @staticmethod + def generate_accept_or_reject_activity( + activity_type: ActivityType, + follower_actor: Optional[Union[Actor, RandomActor]] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + if follower_actor is None: + follower_actor = RandomActor() + if followed_actor is None: + followed_actor = RandomActor() + return { + '@context': AP_CTX, + 'id': ( + f'{followed_actor.activitypub_id}#' + f'{activity_type.value.lower()}s/follow/' + f'{follower_actor.fullname}' + ), + 'type': activity_type.value, + 'actor': followed_actor.activitypub_id, + 'object': { + 'id': ( + f'{follower_actor.activitypub_id}#follows/' + f'{followed_actor.fullname}' + ), + 'type': ActivityType.FOLLOW.value, + 'actor': follower_actor.activitypub_id, + 'object': followed_actor.activitypub_id, + }, + } + + def generate_accept_activity( + self, + follower_actor: Optional[Union[Actor, RandomActor]] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + return self.generate_accept_or_reject_activity( + ActivityType.ACCEPT, follower_actor, followed_actor + ) + + def generate_reject_activity( + self, + follower_actor: Optional[Union[Actor, RandomActor]] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + return self.generate_accept_or_reject_activity( + ActivityType.REJECT, follower_actor, followed_actor + ) + + +class TestFollowActivity(FollowRequestActivitiesTestCase): + def test_it_raises_error_if_followed_actor_does_not_exist( self, app_with_federation: Flask, remote_actor: Actor ) -> None: follow_activity = self.generate_follow_activity( - actor_id=remote_actor.activitypub_id + follower_actor_id=remote_actor.activitypub_id ) - activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) with pytest.raises( ActorNotFoundException, - match='followed actor not found for Follow Activity', + match='object actor not found for FollowActivity', ): activity.process_activity() - def test_it_raises_error_if_remote_actor_does_not_exists( + def test_it_raises_error_if_remote_actor_does_not_exist( self, app_with_federation: Flask, actor_1: Actor ) -> None: - follow_activity = self.generate_follow_activity(object_actor=actor_1) + follow_activity = self.generate_follow_activity(followed_actor=actor_1) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -79,7 +134,7 @@ def test_it_raises_error_if_remote_actor_does_not_exists( with pytest.raises( ActorNotFoundException, - match='followed actor not found for Follow Activity', + match='actor not found for FollowActivity', ): activity.process_activity() @@ -88,13 +143,13 @@ def test_it_raises_error_if_follow_request_already_rejected( app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, - follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: - actor_1.user.refuses_follow_request_from(remote_actor.user) + actor_1.user.rejects_follow_request_from(remote_actor.user) follow_activity = self.generate_follow_activity( - actor_id=remote_actor.activitypub_id, object_actor=actor_1 + follower_actor_id=remote_actor.activitypub_id, + followed_actor=actor_1, ) - activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) @@ -106,11 +161,13 @@ def test_it_creates_follow_request( self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor ) -> None: follow_activity = self.generate_follow_activity( - actor_id=remote_actor.activitypub_id, object_actor=actor_1 + follower_actor_id=remote_actor.activitypub_id, + followed_actor=actor_1, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) + activity.process_activity() follow_request = FollowRequest.query.filter_by( @@ -119,19 +176,21 @@ def test_it_creates_follow_request( ).first() assert follow_request is not None - def test_it_does_raise_error_if_pending_follow_request_already_exist( + def test_it_does_not_raise_error_if_pending_follow_request_already_exist( self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor, - follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: follow_activity = self.generate_follow_activity( - actor_id=remote_actor.activitypub_id, object_actor=actor_1 + follower_actor_id=remote_actor.activitypub_id, + followed_actor=actor_1, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) + activity.process_activity() follow_request = FollowRequest.query.filter_by( @@ -139,3 +198,183 @@ def test_it_does_raise_error_if_pending_follow_request_already_exist( followed_user_id=actor_1.user.id, ).first() assert follow_request.updated_at is None + + +class TestAcceptActivity(FollowRequestActivitiesTestCase): + def test_it_raises_error_if_follower_actor_does_not_exist( + self, app_with_federation: Flask, remote_actor: Actor + ) -> None: + accept_activity = self.generate_accept_activity( + followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='object actor not found for AcceptActivity', + ): + activity.process_activity() + + def test_it_raises_error_if_followed_actor_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + accept_activity = self.generate_accept_activity(follower_actor=actor_1) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='actor not found for AcceptActivity', + ): + activity.process_activity() + + def test_it_raises_error_if_follow_request_does_not_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + accept_activity = self.generate_accept_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises(NotExistingFollowRequestError): + activity.process_activity() + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_1_to_remote_actor: FollowRequest, + ) -> None: + remote_actor.user.rejects_follow_request_from(actor_1.user) + accept_activity = self.generate_accept_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises(FollowRequestAlreadyProcessedError): + activity.process_activity() + + def test_it_accepts_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_1_to_remote_actor: FollowRequest, + ) -> None: + accept_activity = self.generate_accept_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=actor_1.user.id, + followed_user_id=remote_actor.user.id, + ).first() + + assert follow_request.is_approved + assert follow_request.updated_at is not None + + +class TestRejectActivity(FollowRequestActivitiesTestCase): + def test_it_raises_error_if_follower_actor_does_not_exist( + self, app_with_federation: Flask, remote_actor: Actor + ) -> None: + accept_activity = self.generate_reject_activity( + followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='object actor not found for RejectActivity', + ): + activity.process_activity() + + def test_it_raises_error_if_followed_actor_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + accept_activity = self.generate_reject_activity(follower_actor=actor_1) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises( + ActorNotFoundException, + match='actor not found for RejectActivity', + ): + activity.process_activity() + + def test_it_raises_error_if_follow_request_does_not_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + accept_activity = self.generate_reject_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises(NotExistingFollowRequestError): + activity.process_activity() + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_1_to_remote_actor: FollowRequest, + ) -> None: + remote_actor.user.rejects_follow_request_from(actor_1.user) + accept_activity = self.generate_reject_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + with pytest.raises(FollowRequestAlreadyProcessedError): + activity.process_activity() + + def test_it_rejects_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_user_1_to_remote_actor: FollowRequest, + ) -> None: + accept_activity = self.generate_reject_activity( + follower_actor=actor_1, followed_actor=remote_actor + ) + activity = get_activity_instance({'type': accept_activity['type']})( + activity_dict=accept_activity + ) + + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=actor_1.user.id, + followed_user_id=remote_actor.user.id, + ).first() + + assert follow_request.is_approved is False + assert follow_request.updated_at is not None diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index e38b2b21c..957cb7d0e 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -7,7 +7,7 @@ from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import FollowRequest -from ...test_case_mixins import ApiTestCaseMixin, BaseTestMixin +from ...test_case_mixins import ApiTestCaseMixin, UserInboxTestMixin from ...utils import RandomActor, random_string @@ -107,7 +107,7 @@ def test_it_returns_success_if_follow_request_already_exists( ) @patch('fittrackee.users.models.send_to_users_inbox') - def test_it_does_not_call_send_to_inbox( + def test_it_does_not_call_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, @@ -127,7 +127,7 @@ def test_it_does_not_call_send_to_inbox( send_to_users_inbox_mock.send.assert_not_called() -class TestRemoteFollowWithFederation(BaseTestMixin, ApiTestCaseMixin): +class TestRemoteFollowWithFederation(ApiTestCaseMixin, UserInboxTestMixin): """Follow user from another instance""" def test_it_raise_error_if_remote_actor_does_not_exist( @@ -225,7 +225,7 @@ def test_it_returns_success_if_follow_request_already_exists( ) @patch('fittrackee.users.models.send_to_users_inbox') - def test_it_calls_send_to_inbox( + def test_it_calls_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, @@ -242,17 +242,13 @@ def test_it_calls_send_to_inbox( headers=dict(Authorization=f'Bearer {auth_token}'), ) - send_to_users_inbox_mock.send.assert_called_once() - self.assert_call_args_keys_equal( - send_to_users_inbox_mock.send, - ['sender_id', 'activity', 'recipients'], - ) - call_args = self.get_call_kwargs(send_to_users_inbox_mock.send) - assert call_args['sender_id'] == actor_1.id - assert call_args['recipients'] == [remote_actor.inbox_url] follow_request = FollowRequest.query.filter_by( follower_user_id=actor_1.user.id, followed_user_id=remote_actor.user.id, ).first() - activity = follow_request.get_activity() - assert call_args['activity'] == activity + self.assert_send_to_users_inbox_called_once( + send_to_users_inbox_mock, + local_actor=actor_1, + remote_actor=remote_actor, + base_object=follow_request, + ) diff --git a/fittrackee/tests/federation/users/test_users_follow_request_api.py b/fittrackee/tests/federation/users/test_users_follow_request_api.py index 4d976585d..b0fba7bc4 100644 --- a/fittrackee/tests/federation/users/test_users_follow_request_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -1,12 +1,15 @@ import json from datetime import datetime +from unittest.mock import Mock, patch from flask import Flask from fittrackee.federation.models import Actor from fittrackee.users.models import FollowRequest -from ...test_case_mixins import ApiTestCaseMixin +from ...test_case_mixins import ApiTestCaseMixin, UserInboxTestMixin +from ...users.test_users_follow_request_api import FollowRequestTestCase +from ...utils import random_string class TestGetFollowRequestWithFederation(ApiTestCaseMixin): @@ -55,3 +58,270 @@ def test_it_returns_current_user_follow_requests_with_actors( assert len(data['data']['follow_requests']) == 1 assert data['data']['follow_requests'][0]['name'] == 'toto' assert '@context' in data['data']['follow_requests'][0] + + +class TestAcceptLocalFollowRequestWithFederation(FollowRequestTestCase): + def test_it_raises_error_if_target_user_does_not_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_return_user_not_found( + f'/api/follow_requests/{random_string()}/accept', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, actor_2.user.username, 'accept' + ) + + def test_it_raises_error_if_follow_request_already_accepted( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1_with_federation.is_approved = True + follow_request_from_user_2_to_user_1_with_federation.updated_at = ( + datetime.utcnow() + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, actor_2.user.username, 'accept' + ) + + def test_it_accepts_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, actor_2.user.username, 'accept' + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + client.post( + f'/api/follow_requests/{actor_2.user.username}/accept', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + send_to_users_inbox_mock.send.assert_not_called() + + +class TestAcceptRemoteFollowRequestWithFederation( + FollowRequestTestCase, UserInboxTestMixin +): + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_accepts_follow_request( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, remote_actor.fullname, 'accept' # type: ignore + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_calls_send_to_user_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + client.post( + f'/api/follow_requests/{remote_actor.fullname}/accept', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + follow_request = FollowRequest.query.filter_by( + follower_user_id=remote_actor.user.id, + followed_user_id=actor_1.user.id, + ).first() + self.assert_send_to_users_inbox_called_once( + send_to_users_inbox_mock, + local_actor=actor_1, + remote_actor=remote_actor, + base_object=follow_request, + ) + + +class TestRejectLocalFollowRequestWithFederation(FollowRequestTestCase): + def test_it_raises_error_if_target_user_does_not_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_return_user_not_found( + f'/api/follow_requests/{random_string()}/reject', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, actor_2.user.username, 'reject' + ) + + def test_it_raises_error_if_follow_request_already_accepted( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1_with_federation.updated_at = ( + datetime.utcnow() + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, actor_2.user.username, 'reject' + ) + + def test_it_rejects_follow_request( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, actor_2.user.username, 'reject' + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + client.post( + f'/api/follow_requests/{actor_2.user.username}/reject', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + send_to_users_inbox_mock.send.assert_not_called() + + +class TestRejectRemoteFollowRequestWithFederation( + FollowRequestTestCase, UserInboxTestMixin +): + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_accepts_follow_request( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, remote_actor.fullname, 'reject' # type: ignore + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_calls_send_to_user_inbox( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation + ) + + client.post( + f'/api/follow_requests/{remote_actor.fullname}/reject', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + follow_request = FollowRequest.query.filter_by( + follower_user_id=remote_actor.user.id, + followed_user_id=actor_1.user.id, + ).first() + self.assert_send_to_users_inbox_called_once( + send_to_users_inbox_mock, + local_actor=actor_1, + remote_actor=remote_actor, + base_object=follow_request, + ) diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index ca3c466f3..220088882 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -1,3 +1,5 @@ +from datetime import datetime + from flask import Flask from fittrackee.federation.constants import AP_CTX @@ -23,7 +25,7 @@ def test_follow_request_model( assert serialized_follow_request['from_user'] == actor_1.serialize() assert serialized_follow_request['to_user'] == actor_2.serialize() - def test_it_returns_activity_object_when_federation_is_enabled( + def test_it_returns_follow_activity_object( self, app_with_federation: Flask, actor_1: Actor, @@ -34,8 +36,60 @@ def test_it_returns_activity_object_when_federation_is_enabled( assert activity_object == { '@context': AP_CTX, - 'id': f'{actor_1.activitypub_id}#follow/{actor_2.fullname}', + 'id': f'{actor_1.activitypub_id}#follows/{actor_2.fullname}', 'type': 'Follow', 'actor': actor_1.activitypub_id, 'object': actor_2.activitypub_id, } + + def test_it_returns_accept_activity_object_when_follow_request_is_accepted( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + activity_object = follow_request_from_user_1_to_user_2.get_activity() + + assert activity_object == { + '@context': AP_CTX, + 'id': ( + f'{actor_2.activitypub_id}#accepts/follow/{actor_1.fullname}' + ), + 'type': 'Accept', + 'actor': actor_2.activitypub_id, + 'object': { + 'id': f'{actor_1.activitypub_id}#follows/{actor_2.fullname}', + 'type': 'Follow', + 'actor': actor_1.activitypub_id, + 'object': actor_2.activitypub_id, + }, + } + + def test_it_returns_reject_activity_object_when_follow_request_is_rejected( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = False + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + activity_object = follow_request_from_user_1_to_user_2.get_activity() + + assert activity_object == { + '@context': AP_CTX, + 'id': ( + f'{actor_2.activitypub_id}#rejects/follow/{actor_1.fullname}' + ), + 'type': 'Reject', + 'actor': actor_2.activitypub_id, + 'object': { + 'id': f'{actor_1.activitypub_id}#follows/{actor_2.fullname}', + 'type': 'Follow', + 'actor': actor_1.activitypub_id, + 'object': actor_2.activitypub_id, + }, + } diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py index 39c8eff99..47c866829 100644 --- a/fittrackee/tests/fixtures/fixtures_federation_users.py +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -16,3 +16,42 @@ def follow_request_from_user_1_to_user_2_with_federation( db.session.add(follow_request) db.session.commit() return follow_request + + +@pytest.fixture() +def follow_request_from_user_2_to_user_1_with_federation( + actor_1: Actor, + actor_2: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=actor_2.user.id, followed_user_id=actor_1.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + +@pytest.fixture() +def follow_request_from_remote_user_to_user_1( + actor_1: Actor, + remote_actor: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=remote_actor.user.id, followed_user_id=actor_1.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + +@pytest.fixture() +def follow_request_from_user_1_to_remote_actor( + actor_1: Actor, + remote_actor: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=actor_1.user.id, followed_user_id=remote_actor.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request diff --git a/fittrackee/tests/test_case_mixins.py b/fittrackee/tests/test_case_mixins.py index d485bfd66..50e809206 100644 --- a/fittrackee/tests/test_case_mixins.py +++ b/fittrackee/tests/test_case_mixins.py @@ -6,6 +6,8 @@ from flask import Flask from flask.testing import FlaskClient +from fittrackee.federation.models import Actor + class BaseTestMixin: @staticmethod @@ -46,6 +48,49 @@ def get_test_client_and_auth_token( auth_token = json.loads(resp_login.data.decode())['auth_token'] return client, auth_token + @staticmethod + def assert_return_not_found( + url: str, client: FlaskClient, auth_token: str, message: str + ) -> None: + response = client.post( + url, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == message + + def assert_return_user_not_found( + self, url: str, client: FlaskClient, auth_token: str + ) -> None: + self.assert_return_not_found( + url, client, auth_token, 'user does not exist' + ) + + +class UserInboxTestMixin(BaseTestMixin): + def assert_send_to_users_inbox_called_once( + self, + send_to_users_inbox_mock: Mock, + local_actor: Actor, + remote_actor: Actor, + base_object: Any, + ) -> None: + send_to_users_inbox_mock.send.assert_called_once() + self.assert_call_args_keys_equal( + send_to_users_inbox_mock.send, + ['sender_id', 'activity', 'recipients'], + ) + call_args = self.get_call_kwargs(send_to_users_inbox_mock.send) + assert call_args['sender_id'] == local_actor.id + assert call_args['recipients'] == [remote_actor.inbox_url] + activity = base_object.get_activity() + del activity['id'] + self.assert_dict_contains_subset(call_args['activity'], activity) + class CallArgsMixin: @staticmethod diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index e71660650..9307635b6 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -372,9 +372,7 @@ def test_user_can_login_when_user_email_is_uppercase( assert data['message'] == 'successfully logged in' assert data['auth_token'] - def test_it_returns_error_if_user_does_not_exists( - self, app: Flask - ) -> None: + def test_it_returns_error_if_user_does_not_exist(self, app: Flask) -> None: client = app.test_client() response = client.post( diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index 5163ea6d2..a05fe4203 100644 --- a/fittrackee/tests/users/test_users_follow_api.py +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -92,7 +92,7 @@ def test_it_returns_success_if_follow_request_already_exists( ) @patch('fittrackee.users.models.send_to_users_inbox') - def test_it_does_not_call_send_to_inbox( + def test_it_does_not_call_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app: Flask, diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py index c0b929809..92b9de979 100644 --- a/fittrackee/tests/users/test_users_follow_request_api.py +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -3,10 +3,12 @@ from unittest.mock import patch from flask import Flask +from flask.testing import FlaskClient from fittrackee.users.models import FollowRequest, User from ..test_case_mixins import ApiTestCaseMixin +from ..utils import random_string class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): @@ -214,3 +216,161 @@ def test_it_returns_second_page_with_one_request_per_page_with_descending_order( 'pages': 2, 'total': 2, } + + +class FollowRequestTestCase(ApiTestCaseMixin): + def assert_it_returns_follow_request_not_found( + self, + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow_requests/{user_name}/{action}' + self.assert_return_not_found( + url, client, auth_token, 'Follow request does not exist.' + ) + + @staticmethod + def assert_it_returns_follow_request_already_processed( + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow_requests/{user_name}/{action}' + response = client.post( + url, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == ( + f"Follow request from user '{user_name}' already {action}ed." + ) + + @staticmethod + def assert_it_returns_follow_request_processed( + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow_requests/{user_name}/{action}' + response = client.post( + url, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == ( + f"Follow request from user '{user_name}' is {action}ed." + ) + + +class TestAcceptFollowRequestWithoutFederation(FollowRequestTestCase): + def test_it_raises_error_if_target_user_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_return_user_not_found( + f'/api/follow_requests/{random_string()}/accept', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, user_2.username, 'accept' + ) + + def test_it_raises_error_if_follow_request_already_accepted( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, 'accept' + ) + + def test_it_accepts_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, 'accept' + ) + + +class TestRejectFollowRequestWithoutFederation(FollowRequestTestCase): + def test_it_raises_error_if_target_user_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_return_user_not_found( + f'/api/follow_requests/{random_string()}/reject', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, user_2.username, 'reject' + ) + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, 'reject' + ) + + def test_it_rejects_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token(app) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, 'reject' + ) diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 08a2e36b2..e3f7d6b06 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -179,12 +179,12 @@ def test_user_refuses_follow_request( user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - follow_request = user_1.refuses_follow_request_from(user_2) + follow_request = user_1.rejects_follow_request_from(user_2) assert not follow_request.is_approved assert user_1.pending_follow_requests == [] - def test_it_raises_error_if_follow_request_does_not_exists( + def test_it_raises_error_if_follow_request_does_not_exist( self, app: Flask, user_1: User, diff --git a/fittrackee/tests/workouts/test_stats_api.py b/fittrackee/tests/workouts/test_stats_api.py index 7d0bf79d1..1e22131d5 100644 --- a/fittrackee/tests/workouts/test_stats_api.py +++ b/fittrackee/tests/workouts/test_stats_api.py @@ -24,7 +24,7 @@ def test_it_gets_no_stats_when_user_has_no_workouts( assert 'success' in data['status'] assert data['data']['statistics'] == {} - def test_it_returns_error_when_user_does_not_exists( + def test_it_returns_error_when_user_does_not_exist( self, app: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token(app) diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index 38f015e49..18f30fd5c 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -491,7 +491,7 @@ def test_it_returns_400_if_sport_id_is_not_provided( assert data['status'] == 'error' assert data['message'] == 'invalid payload' - def test_it_returns_500_if_sport_id_does_not_exists( + def test_it_returns_500_if_sport_id_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: client, auth_token = self.get_test_client_and_auth_token(app) diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index 34ecc38fb..09c192d43 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -218,7 +218,7 @@ def test_it_returns_400_if_payload_is_empty( assert 'error' in data['status'] assert 'invalid payload' in data['message'] - def test_it_raises_500_if_sport_does_not_exists( + def test_it_raises_500_if_sport_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: token, workout_short_id = post_an_workout(app, gpx_file) @@ -596,7 +596,7 @@ def test_it_returns_500_if_date_format_is_invalid( in data['message'] ) - def test_it_returns_404_if_edited_workout_does_not_exists( + def test_it_returns_404_if_edited_workout_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: client, auth_token = self.get_test_client_and_auth_token(app) diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py index a54ea943c..ea41d8ce5 100644 --- a/fittrackee/users/follow_requests.py +++ b/fittrackee/users/follow_requests.py @@ -1,8 +1,26 @@ -from typing import Dict +from typing import Dict, Union from flask import Blueprint, request +from fittrackee import appLog +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + DomainNotFoundException, +) +from fittrackee.federation.utils_user import get_user_from_username +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + NotFoundErrorResponse, + UserNotFoundErrorResponse, +) + from .decorators import authenticate +from .exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, + UserNotFoundException, +) from .models import FollowRequest, User follow_requests_blueprint = Blueprint('follow_requests', __name__) @@ -48,3 +66,57 @@ def get_follow_requests(auth_user: User) -> Dict: 'total': follow_requests_pagination.total, }, } + + +def process_follow_request( + auth_user: User, user_name: str, action: str +) -> Union[Dict, HttpResponse]: + try: + from_user = get_user_from_username(user_name) + except ( + ActorNotFoundException, + DomainNotFoundException, + UserNotFoundException, + ) as e: + appLog.error(f'Error when accepting follow request: {e}') + return UserNotFoundErrorResponse() + + current_user = User.query.filter_by(id=auth_user.id).first() + try: + if action == 'accept': + current_user.approves_follow_request_from(from_user) + else: # action == 'reject' + current_user.rejects_follow_request_from(from_user) + except NotExistingFollowRequestError: + return NotFoundErrorResponse(message='Follow request does not exist.') + except FollowRequestAlreadyProcessedError: + return InvalidPayloadErrorResponse( + message=( + f"Follow request from user '{user_name}' already {action}ed." + ) + ) + + return { + 'status': 'success', + 'message': f"Follow request from user '{user_name}' is {action}ed.", + } + + +@follow_requests_blueprint.route( + '/follow_requests//accept', methods=['POST'] +) +@authenticate +def accept_follow_request( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + return process_follow_request(auth_user, user_name, 'accept') + + +@follow_requests_blueprint.route( + '/follow_requests//reject', methods=['POST'] +) +@authenticate +def reject_follow_request( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + return process_follow_request(auth_user, user_name, 'reject') diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 79a07ec6e..c10fd7323 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -17,6 +17,7 @@ from .exceptions import ( FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, NotExistingFollowRequestError, ) from .utils.token import decode_user_token, get_user_token @@ -69,16 +70,34 @@ def serialize(self) -> Dict: def get_activity(self) -> Dict: if not current_app.config['federation_enabled']: raise FederationDisabledException() - return { - '@context': AP_CTX, + follow_activity = { 'id': ( - f'{self.from_user.actor.activitypub_id}#follow/' + f'{self.from_user.actor.activitypub_id}#follows/' f'{self.to_user.actor.fullname}' ), 'type': ActivityType.FOLLOW.value, 'actor': self.from_user.actor.activitypub_id, 'object': self.to_user.actor.activitypub_id, } + if self.updated_at is None: + activity = follow_activity.copy() + else: + activity = { + 'id': ( + f'{self.to_user.actor.activitypub_id}#' + f'{"accept" if self.is_approved else "reject"}s/' + f'follow/{self.from_user.actor.fullname}' + ), + 'type': ( + ActivityType.ACCEPT.value + if self.is_approved + else ActivityType.REJECT.value + ), + 'actor': self.to_user.actor.activitypub_id, + 'object': follow_activity, + } + activity['@context'] = AP_CTX + return activity class User(BaseModel): @@ -204,6 +223,14 @@ def pending_follow_requests(self) -> FollowRequest: return self.received_follow_requests.filter_by(updated_at=None).all() def send_follow_request_to(self, target: 'User') -> FollowRequest: + existing_follow_request = FollowRequest.query.filter_by( + follower_user_id=self.id, followed_user_id=target.id + ).first() + if existing_follow_request: + if existing_follow_request.is_rejected(): + raise FollowRequestAlreadyRejectedError() + return existing_follow_request + follow_request = FollowRequest( follower_user_id=self.id, followed_user_id=target.id ) @@ -232,6 +259,13 @@ def _processes_follow_request_from( follow_request.is_approved = approved follow_request.updated_at = datetime.now() db.session.commit() + + if current_app.config['federation_enabled'] and user.actor.is_remote: + send_to_users_inbox.send( + sender_id=self.actor.id, + activity=follow_request.get_activity(), + recipients=[user.actor.inbox_url], + ) return follow_request def approves_follow_request_from(self, user: 'User') -> FollowRequest: @@ -240,7 +274,7 @@ def approves_follow_request_from(self, user: 'User') -> FollowRequest: ) return follow_request - def refuses_follow_request_from(self, user: 'User') -> FollowRequest: + def rejects_follow_request_from(self, user: 'User') -> FollowRequest: follow_request = self._processes_follow_request_from( user=user, approved=False ) diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 73ede8882..3a30216ce 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -6,9 +6,12 @@ from flask import Blueprint, request, send_file from sqlalchemy import exc -from fittrackee import db -from fittrackee.federation.models import Actor, Domain -from fittrackee.federation.utils import get_username_and_domain +from fittrackee import appLog, db +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + DomainNotFoundException, +) +from fittrackee.federation.utils_user import get_user_from_username from fittrackee.files import get_absolute_file_path from fittrackee.responses import ( ForbiddenErrorResponse, @@ -27,7 +30,6 @@ ) from .models import User, UserSportPreference from .utils.admin import set_admin_rights -from .utils.follow import create_follow_request users_blueprint = Blueprint('users', __name__) @@ -613,29 +615,19 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: 'message': f"Follow request to user '{user_name}' is sent.", } - user_name_and_domain = get_username_and_domain(user_name) - if user_name_and_domain is None: # local actor - target_user = User.query.filter_by(username=user_name).first() - else: # remote actor - name, domain_name = user_name_and_domain.groups() - domain = Domain.query.filter_by(name=domain_name).first() - if not domain: - return UserNotFoundErrorResponse() - actor = Actor.query.filter_by( - preferred_username=name, domain_id=domain.id - ).first() - if not actor: - return UserNotFoundErrorResponse() - target_user = actor.user - - if target_user: - try: - create_follow_request( - follower_user_id=auth_user.id, - followed_user=target_user, - ) - except FollowRequestAlreadyRejectedError: - return ForbiddenErrorResponse() - return successful_response_dict + try: + target_user = get_user_from_username(user_name) + except ( + ActorNotFoundException, + DomainNotFoundException, + UserNotFoundException, + ) as e: + appLog.error(f'Error when following a user: {e}') + return UserNotFoundErrorResponse() - return UserNotFoundErrorResponse() + follower_user = User.query.filter_by(id=auth_user.id).first() + try: + follower_user.send_follow_request_to(target_user) + except FollowRequestAlreadyRejectedError: + return ForbiddenErrorResponse() + return successful_response_dict diff --git a/fittrackee/users/utils/follow.py b/fittrackee/users/utils/follow.py deleted file mode 100644 index ef4193e86..000000000 --- a/fittrackee/users/utils/follow.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..exceptions import FollowRequestAlreadyRejectedError -from ..models import FollowRequest, User - - -def create_follow_request(follower_user_id: int, followed_user: User) -> None: - existing_follow_request = FollowRequest.query.filter_by( - follower_user_id=follower_user_id, followed_user_id=followed_user.id - ).first() - if existing_follow_request: - if existing_follow_request.is_rejected(): - raise FollowRequestAlreadyRejectedError() - return - - auth_user = User.query.filter_by(id=follower_user_id).first() - auth_user.send_follow_request_to(followed_user) From f7b5e1c92dffd591828d7e4d62aa0a440b412283 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Feb 2022 11:26:51 +0100 Subject: [PATCH 042/238] API - add profile url to Actor --- fittrackee/federation/federation.py | 2 ++ fittrackee/federation/models.py | 4 +++ fittrackee/federation/utils.py | 2 ++ fittrackee/federation/webfinger.py | 12 ++++++++- .../23_8842c351a2d8_init_federation.py | 6 +++-- .../federation/test_federation_models.py | 18 ++++++++++--- .../federation/test_federation_webfinger.py | 20 ++++++++++---- .../tests/fixtures/fixtures_federation.py | 26 +++++++++++++++++++ fittrackee/tests/utils.py | 12 +++++++-- 9 files changed, 89 insertions(+), 13 deletions(-) diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index d1cfed513..0e09d137f 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -52,6 +52,7 @@ def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: "type": "Person", "preferredUsername": "Sam", "name": "Sam", + "url": "https://example.com/users/Sam", "inbox": "https://example.com/federation/user/Sam/inbox", "outbox": "https://example.com/federation/user/Sam/outbox", "followers": "https://example.com/federation/user/Sam/followers", @@ -120,6 +121,7 @@ def remote_actor( "type": "Person", "preferredUsername": "Sam", "name": "Sam", + "url": "https://remote-instance.social/@Sam", "inbox": "https://remote-instance.social/user/Sam/inbox", "outbox": "https://remote-instance.social/user/Sam/outbox", "followers": "https://remote-instance.social/user/Sam/followers", diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index d89e8bd09..7623be1b5 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -65,6 +65,7 @@ class Actor(BaseModel): preferred_username = db.Column(db.String(255), nullable=False) public_key = db.Column(db.String(5000), nullable=True) private_key = db.Column(db.String(5000), nullable=True) + profile_url = db.Column(db.String(255), nullable=False) inbox_url = db.Column(db.String(255), nullable=False) outbox_url = db.Column(db.String(255), nullable=False) followers_url = db.Column(db.String(255), nullable=False) @@ -98,6 +99,7 @@ def __init__( self.activitypub_id = get_ap_url(preferred_username, 'user_url') self.followers_url = get_ap_url(preferred_username, 'followers') self.following_url = get_ap_url(preferred_username, 'following') + self.profile_url = get_ap_url(preferred_username, 'profile_url') self.inbox_url = get_ap_url(preferred_username, 'inbox') self.outbox_url = get_ap_url(preferred_username, 'outbox') self.shared_inbox_url = get_ap_url( @@ -132,6 +134,7 @@ def update_remote_data(self, remote_user_data: Dict) -> None: ] self.followers_url = remote_user_data['followers'] self.following_url = remote_user_data['following'] + self.profile_url = remote_user_data.get('url', remote_user_data['id']) self.inbox_url = remote_user_data['inbox'] self.outbox_url = remote_user_data['outbox'] self.shared_inbox_url = remote_user_data.get('endpoints', {}).get( @@ -147,6 +150,7 @@ def serialize(self) -> Dict: 'type': self.type.value, 'preferredUsername': self.preferred_username, 'name': self.name, + 'url': self.profile_url, 'inbox': self.inbox_url, 'outbox': self.outbox_url, 'followers': self.followers_url, diff --git a/fittrackee/federation/utils.py b/fittrackee/federation/utils.py index 02b2a7624..458d8de5b 100644 --- a/fittrackee/federation/utils.py +++ b/fittrackee/federation/utils.py @@ -37,6 +37,8 @@ def get_ap_url(username: str, url_type: str) -> str: return f'{ap_url_user}/{url_type}' if url_type == 'shared_inbox': return f'{ap_url}inbox' + if url_type == 'profile_url': + return f"https://{current_app.config['AP_DOMAIN']}/users/{username}" raise Exception('Invalid \'url_type\'.') diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py index 5802d87af..1ac8108fa 100644 --- a/fittrackee/federation/webfinger.py +++ b/fittrackee/federation/webfinger.py @@ -35,6 +35,11 @@ def webfinger(app_domain: Domain) -> HttpResponse: { "subject": "acct:Sam@example.com", "links": [ + { + "href": "https://example.com/user/Sam", + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html" + }, { "href": "https://example.com/federation/user/Sam", "rel": "self", @@ -75,11 +80,16 @@ def webfinger(app_domain: Domain) -> HttpResponse: response = { 'subject': f'acct:{actor.fullname}', 'links': [ + { + 'href': f'{actor.profile_url}', + 'rel': 'http://webfinger.net/rel/profile-page', + 'type': 'text/html', + }, { 'href': actor.activitypub_id, 'rel': 'self', 'type': 'application/activity+json', - } + }, ], } return HttpResponse( diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 532d51bf9..2461506e6 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -63,6 +63,7 @@ def upgrade(): sa.Column('preferred_username', sa.String(length=255), nullable=False), sa.Column('public_key', sa.String(length=5000), nullable=True), sa.Column('private_key', sa.String(length=5000), nullable=True), + sa.Column('profile_url', sa.String(length=255), nullable=False), sa.Column('inbox_url', sa.String(length=255), nullable=False), sa.Column('outbox_url', sa.String(length=255), nullable=False), sa.Column('followers_url', sa.String(length=255), nullable=False), @@ -115,8 +116,8 @@ def upgrade(): op.execute( "INSERT INTO actors (" "activitypub_id, domain_id, preferred_username, public_key, " - "private_key, followers_url, following_url, inbox_url, " - "outbox_url, shared_inbox_url, created_at, " + "private_key, followers_url, following_url, profile_url, " + "inbox_url, outbox_url, shared_inbox_url, created_at, " "manually_approves_followers) " "VALUES (" f"'{get_ap_url(user.username, 'user_url')}', " @@ -124,6 +125,7 @@ def upgrade(): f"'{public_key}', '{private_key}', " f"'{get_ap_url(user.username, 'followers')}', " f"'{get_ap_url(user.username, 'following')}', " + f"'{get_ap_url(user.username, 'profile_url')}', " f"'{get_ap_url(user.username, 'inbox')}', " f"'{get_ap_url(user.username, 'outbox')}', " f"'{get_ap_url(user.username, 'shared_inbox')}', " diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index 27265b9c5..5aec26c1a 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -90,6 +90,7 @@ def test_it_returns_serialized_object( serialized_actor['preferredUsername'] == actor_1.preferred_username ) assert serialized_actor['name'] == actor_1.user.username + assert serialized_actor['url'] == actor_1.profile_url assert serialized_actor['inbox'] == ( f'https://{ap_url}/federation/user/' f'{actor_1.preferred_username}/inbox' @@ -136,11 +137,21 @@ def test_actor_is_remote( assert remote_actor.is_remote def test_it_returns_fullname( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, remote_actor: Actor ) -> None: assert ( - actor_1.fullname - == f'{actor_1.preferred_username}@{actor_1.domain.name}' + remote_actor.fullname + == f'{remote_actor.preferred_username}@{remote_actor.domain.name}' + ) + + def test_it_returns_ap_id_if_no_profile_url_provided( + self, + app_with_federation: Flask, + remote_actor_without_profile_page: Actor, + ) -> None: + assert ( + remote_actor_without_profile_page.profile_url + == remote_actor_without_profile_page.activitypub_id ) def test_it_returns_serialized_object( @@ -162,6 +173,7 @@ def test_it_returns_serialized_object( == remote_actor.preferred_username ) assert serialized_actor['name'] == remote_actor.user.username + assert serialized_actor['url'] == remote_actor.profile_url assert serialized_actor['inbox'] == f'{user_url}/inbox' assert serialized_actor['outbox'] == f'{user_url}/outbox' assert serialized_actor['followers'] == f'{user_url}/followers' diff --git a/fittrackee/tests/federation/federation/test_federation_webfinger.py b/fittrackee/tests/federation/federation/test_federation_webfinger.py index 34a1cbf03..2dd9cedd0 100644 --- a/fittrackee/tests/federation/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/federation/test_federation_webfinger.py @@ -115,11 +115,21 @@ def test_it_returns_user_links( assert response.status_code == 200 data = json.loads(response.data.decode()) - assert data['links'][0] == { - 'href': actor_1.activitypub_id, - 'rel': 'self', - 'type': 'application/activity+json', - } + assert data['links'] == [ + { + 'href': ( + f'https://{actor_1.domain.name}/users/' + f'{actor_1.user.username}' + ), + 'rel': 'http://webfinger.net/rel/profile-page', + 'type': 'text/html', + }, + { + 'href': actor_1.activitypub_id, + 'rel': 'self', + 'type': 'application/activity+json', + }, + ] def test_it_returns_error_if_federation_is_disabled( self, app: Flask, app_actor: Actor diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 4709429ec..23e594765 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -69,6 +69,32 @@ def remote_actor( user_2: User, app_with_federation: Flask, remote_domain: Domain, +) -> Actor: + domain = f'https://{remote_domain.name}' + remote_user_object = get_remote_user_object( + username=user_2.username, + preferred_username=user_2.username, + domain=domain, + profile_url=f'{domain}/{user_2.username}', + ) + actor = Actor( + preferred_username=user_2.username, + domain_id=remote_domain.id, + remote_user_data=remote_user_object, + ) + db.session.add(actor) + db.session.flush() + user_2.name = user_2.username.capitalize() + user_2.actor_id = actor.id + db.session.commit() + return actor + + +@pytest.fixture() +def remote_actor_without_profile_page( + user_2: User, + app_with_federation: Flask, + remote_domain: Domain, ) -> Actor: domain = f'https://{remote_domain.name}' remote_user_object = get_remote_user_object( diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 97260ff9a..01949c535 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -45,9 +45,10 @@ def get_remote_user_object( username: str, preferred_username: str, domain: str, + profile_url: Optional[str] = None, ) -> Dict: user_url = f'{domain}/users/{username}' - return { + user_object = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', @@ -68,6 +69,9 @@ def get_remote_user_object( }, 'endpoints': {'sharedInbox': f'{domain}/inbox'}, } + if profile_url: + user_object['url'] = profile_url + return user_object @dataclass @@ -86,9 +90,13 @@ def fullname(self) -> str: def activitypub_id(self) -> str: return f'{self.domain}/users/{self.preferred_username}' + @property + def profile_url(self) -> str: + return f'{self.domain}/{self.preferred_username}' + def get_remote_user_object(self) -> Dict: return get_remote_user_object( - self.name, self.preferred_username, self.domain + self.name, self.preferred_username, self.domain, self.profile_url ) From 40a244844b8fb3373c63deac74a11ec7f4c6beb3 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 21 Jul 2021 13:53:09 +0200 Subject: [PATCH 043/238] API - update rollback migration --- .../migrations/versions/23_8842c351a2d8_init_federation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 2461506e6..8a2aa5852 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -165,6 +165,10 @@ def upgrade(): def downgrade(): op.drop_table('follow_requests') + # remove remote users (for which password is NULL) + op.execute( + "DELETE FROM users WHERE password IS NULL;" + ) op.drop_constraint('username_actor_id_unique', 'users', type_='unique') op.alter_column( 'users', 'username', existing_type=sa.String(length=50), From efa2e805405087c15eec98071f4366482d598c18 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 21 Jul 2021 14:51:26 +0200 Subject: [PATCH 044/238] API - fix testing config --- fittrackee/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fittrackee/config.py b/fittrackee/config.py index ccc9d9137..4cbf0adb3 100644 --- a/fittrackee/config.py +++ b/fittrackee/config.py @@ -77,6 +77,7 @@ class TestingConfig(BaseConfig): UPLOAD_FOLDER = '/tmp/fitTrackee/uploads' UI_URL = 'http://0.0.0.0:5000' SENDER_EMAIL = 'fittrackee@example.com' + AP_DOMAIN = '0.0.0.0:5000' class ProductionConfig(BaseConfig): From 93d179332cc7f6da358742f3c1792c7c6b071ec4 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:37:07 +0100 Subject: [PATCH 045/238] API - create remote user on Follow Activity if not existing + refacto --- fittrackee/federation/activities.py | 35 ++-- fittrackee/federation/exceptions.py | 10 + fittrackee/federation/federation.py | 66 +----- .../{remote_user.py => remote_actor.py} | 2 +- fittrackee/federation/tasks/activity.py | 17 +- fittrackee/federation/utils_user.py | 60 ++++++ .../federation/test_federation_activities.py | 32 ++- .../federation/test_federation_federation.py | 196 +----------------- .../federation/test_federation_utils_user.py | 176 ++++++++++++++++ ...st_remote_user.py => test_remote_actor.py} | 8 +- fittrackee/tests/utils.py | 2 +- 11 files changed, 318 insertions(+), 286 deletions(-) rename fittrackee/federation/{remote_user.py => remote_actor.py} (87%) create mode 100644 fittrackee/tests/federation/federation/test_federation_utils_user.py rename fittrackee/tests/federation/federation/{test_remote_user.py => test_remote_actor.py} (80%) diff --git a/fittrackee/federation/activities.py b/fittrackee/federation/activities.py index 8ab14c2a9..f60f53f2a 100644 --- a/fittrackee/federation/activities.py +++ b/fittrackee/federation/activities.py @@ -1,30 +1,16 @@ from abc import ABC, abstractmethod -from importlib import import_module -from typing import Callable, Dict, Tuple +from typing import Dict, Tuple from fittrackee import appLog from fittrackee.federation.exceptions import ActorNotFoundException from fittrackee.federation.models import Actor +from fittrackee.federation.utils_user import create_remote_user from fittrackee.users.exceptions import ( FollowRequestAlreadyProcessedError, FollowRequestAlreadyRejectedError, NotExistingFollowRequestError, ) -from .exceptions import UnsupportedActivityException - - -def get_activity_instance(activity_dict: Dict) -> Callable: - activity_type = activity_dict['type'] - try: - Activity = getattr( - import_module('fittrackee.federation.activities'), - f'{activity_type}Activity', - ) - except AttributeError: - raise UnsupportedActivityException(activity_type) - return Activity - class AbstractActivity(ABC): def __init__(self, activity_dict: Dict) -> None: @@ -39,7 +25,9 @@ def process_activity(self) -> None: class FollowBaseActivity(AbstractActivity): - def get_actors(self) -> Tuple[Actor, Actor]: + def get_actors( + self, create_remote_actor: bool = False + ) -> Tuple[Actor, Actor]: """ return actors from activity 'actor' and 'object' """ @@ -47,9 +35,12 @@ def get_actors(self) -> Tuple[Actor, Actor]: activitypub_id=self.activity['actor'] ).first() if not actor: - raise ActorNotFoundException( - message=f'actor not found for {self.activity_name()}' - ) + if create_remote_actor: + actor = create_remote_user(self.activity['actor']) + else: + raise ActorNotFoundException( + f'actor not found for {self.activity_name()}' + ) object_actor_activitypub_id = ( self.activity['object'] @@ -72,7 +63,9 @@ def process_activity(self) -> None: class FollowActivity(FollowBaseActivity): def process_activity(self) -> None: - follower_actor, followed_actor = self.get_actors() + follower_actor, followed_actor = self.get_actors( + create_remote_actor=True + ) try: follower_actor.user.send_follow_request_to(followed_actor.user) except FollowRequestAlreadyRejectedError as e: diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py index 85bcb9da5..dba521aa2 100644 --- a/fittrackee/federation/exceptions.py +++ b/fittrackee/federation/exceptions.py @@ -43,6 +43,16 @@ def __init__(self) -> None: ) +class RemoteActorException(GenericException): + def __init__(self, message: Optional[str] = None) -> None: + super().__init__( + status='error', + message=( + f'Invalid remote actor{ f": {message}" if message else ""}.' + ), + ) + + class UnsupportedActivityException(GenericException): def __init__(self, activity_type: str) -> None: super().__init__( diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 0e09d137f..810d19450 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -1,11 +1,9 @@ from typing import Dict, Union -from urllib.parse import urlparse from flask import Blueprint, request -from fittrackee import db -from fittrackee.federation.exceptions import ActorNotFoundException -from fittrackee.federation.remote_user import get_remote_user +from fittrackee.federation.exceptions import RemoteActorException +from fittrackee.federation.utils_user import create_remote_user from fittrackee.responses import ( HttpResponse, InvalidPayloadErrorResponse, @@ -161,64 +159,10 @@ def remote_actor( if request.get_json(silent=True) is not None else None ) - if not remote_actor_url: - return InvalidPayloadErrorResponse() - - # check if domain already exists - remote_domain_name = urlparse(remote_actor_url).netloc - remote_domain = Domain.query.filter_by(name=remote_domain_name).first() - if not remote_domain: - remote_domain = Domain(name=remote_domain_name) - db.session.add(remote_domain) - db.session.flush() - - if not remote_domain.is_remote: - return InvalidPayloadErrorResponse( - message='The provided account is not a remote account.' - ) - - try: - remote_actor_object = get_remote_user(remote_actor_url) - except ActorNotFoundException: - return InvalidPayloadErrorResponse( - message='Can not fetch remote actor.' - ) - - # check if actor already exists try: - actor = Actor.query.filter_by( - preferred_username=remote_actor_object['preferredUsername'], - domain_id=remote_domain.id, - ).first() - except KeyError: - return InvalidPayloadErrorResponse( - message='Invalid remote actor object.' - ) - if not actor: - try: - actor = Actor( - preferred_username=remote_actor_object['preferredUsername'], - domain_id=remote_domain.id, - remote_user_data=remote_actor_object, - ) - except KeyError: - return InvalidPayloadErrorResponse( - message='Invalid remote actor object.' - ) - db.session.add(actor) - db.session.flush() - user = User( - username=remote_actor_object['name'], - email=None, - password=None, - ) - db.session.add(user) - user.actor_id = actor.id - else: - actor.update_remote_data(remote_actor_object) - actor.user.username = remote_actor_object['name'] - db.session.commit() - + actor = create_remote_user(remote_actor_url) + except RemoteActorException as e: + return InvalidPayloadErrorResponse(e.message) return HttpResponse( response=actor.serialize(), content_type='application/jrd+json; charset=utf-8', diff --git a/fittrackee/federation/remote_user.py b/fittrackee/federation/remote_actor.py similarity index 87% rename from fittrackee/federation/remote_user.py rename to fittrackee/federation/remote_actor.py index 675b0f377..573eef8dc 100644 --- a/fittrackee/federation/remote_user.py +++ b/fittrackee/federation/remote_actor.py @@ -5,7 +5,7 @@ from fittrackee.federation.exceptions import ActorNotFoundException -def get_remote_user(actor_url: str) -> Dict: +def get_remote_actor(actor_url: str) -> Dict: response = requests.get( actor_url, headers={'Accept': 'application/activity+json'}, diff --git a/fittrackee/federation/tasks/activity.py b/fittrackee/federation/tasks/activity.py index e09f878b7..fab21b071 100644 --- a/fittrackee/federation/tasks/activity.py +++ b/fittrackee/federation/tasks/activity.py @@ -1,8 +1,21 @@ -from typing import Dict +from importlib import import_module +from typing import Callable, Dict from fittrackee import dramatiq -from ..activities import get_activity_instance +from ..exceptions import UnsupportedActivityException + + +def get_activity_instance(activity_dict: Dict) -> Callable: + activity_type = activity_dict['type'] + try: + Activity = getattr( + import_module('fittrackee.federation.activities'), + f'{activity_type}Activity', + ) + except AttributeError: + raise UnsupportedActivityException(activity_type) + return Activity @dramatiq.actor(queue_name='fittrackee_activities') diff --git a/fittrackee/federation/utils_user.py b/fittrackee/federation/utils_user.py index 7c0fe7180..ec68ade23 100644 --- a/fittrackee/federation/utils_user.py +++ b/fittrackee/federation/utils_user.py @@ -1,7 +1,11 @@ import re from typing import Optional +from urllib.parse import urlparse +from fittrackee import db +from fittrackee.federation.exceptions import RemoteActorException from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.remote_actor import get_remote_actor from fittrackee.users.exceptions import UserNotFoundException from fittrackee.users.models import User @@ -31,3 +35,59 @@ def get_user_from_username(user_name: str) -> User: if not user: raise UserNotFoundException() return user + + +def create_remote_user(remote_actor_url: Optional[str]) -> Actor: + if not remote_actor_url: + raise RemoteActorException('invalid remote actor url') + + # check if domain already exists + remote_domain_name = urlparse(remote_actor_url).netloc + remote_domain = Domain.query.filter_by(name=remote_domain_name).first() + if not remote_domain: + remote_domain = Domain(name=remote_domain_name) + db.session.add(remote_domain) + db.session.flush() + + if not remote_domain.is_remote: + raise RemoteActorException( + 'the provided account is not a remote account' + ) + + try: + remote_actor_object = get_remote_actor(remote_actor_url) + except ActorNotFoundException: + raise RemoteActorException('can not fetch remote actor') + + # check if actor already exists + try: + actor = Actor.query.filter_by( + preferred_username=remote_actor_object['preferredUsername'], + domain_id=remote_domain.id, + ).first() + except KeyError: + raise RemoteActorException('invalid remote actor object') + + if not actor: + try: + actor = Actor( + preferred_username=remote_actor_object['preferredUsername'], + domain_id=remote_domain.id, + remote_user_data=remote_actor_object, + ) + except KeyError: + raise RemoteActorException('invalid remote actor object') + db.session.add(actor) + db.session.flush() + user = User( + username=remote_actor_object['name'], + email=None, + password=None, + ) + db.session.add(user) + user.actor_id = actor.id + else: + actor.update_remote_data(remote_actor_object) + actor.user.username = remote_actor_object['name'] + db.session.commit() + return actor diff --git a/fittrackee/tests/federation/federation/test_federation_activities.py b/fittrackee/tests/federation/federation/test_federation_activities.py index 8890bf1ca..d9bf19a49 100644 --- a/fittrackee/tests/federation/federation/test_federation_activities.py +++ b/fittrackee/tests/federation/federation/test_federation_activities.py @@ -1,9 +1,9 @@ from typing import Dict, Optional, Union +from unittest.mock import patch import pytest from flask import Flask -from fittrackee.federation.activities import get_activity_instance from fittrackee.federation.constants import AP_CTX from fittrackee.federation.enums import ActivityType from fittrackee.federation.exceptions import ( @@ -11,6 +11,7 @@ UnsupportedActivityException, ) from fittrackee.federation.models import Actor +from fittrackee.federation.tasks.activity import get_activity_instance from fittrackee.users.exceptions import ( FollowRequestAlreadyProcessedError, FollowRequestAlreadyRejectedError, @@ -123,21 +124,32 @@ def test_it_raises_error_if_followed_actor_does_not_exist( ): activity.process_activity() - def test_it_raises_error_if_remote_actor_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + def test_it_creates_actor_if_remote_actor_does_not_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, ) -> None: - follow_activity = self.generate_follow_activity(followed_actor=actor_1) - + follow_activity = self.generate_follow_activity( + follower_actor_id=random_actor.activitypub_id, + followed_actor=actor_1, + ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity ) - - with pytest.raises( - ActorNotFoundException, - match='actor not found for FollowActivity', - ): + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = ( + random_actor.get_remote_user_object() + ) activity.process_activity() + remote_actor = Actor.query.filter_by( + activitypub_id=follow_activity['actor'] + ) + assert remote_actor is not None + def test_it_raises_error_if_follow_request_already_rejected( self, app_with_federation: Flask, diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index cf9a74881..7b6ad74db 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -5,11 +5,11 @@ from flask import Flask from fittrackee.federation.exceptions import ActorNotFoundException -from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.models import Actor from fittrackee.users.models import User from ...test_case_mixins import ApiTestCaseMixin -from ...utils import RandomActor, random_string +from ...utils import RandomActor class TestFederationUser: @@ -115,104 +115,24 @@ def test_it_returns_400_if_remote_user_url_is_missing( assert response.status_code == 400 data = json.loads(response.data.decode()) assert 'error' in data['status'] - assert 'invalid payload' in data['message'] - - def test_it_returns_error_if_remote_instance_returns_error( - self, - app_with_federation: Flask, - actor_1: Actor, - random_actor: RandomActor, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - get_remote_user_mock.side_effect = ActorNotFoundException() - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor.activitypub_id}), - ) - - assert response.status_code == 400 - data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert 'Can not fetch remote actor.' in data['message'] - - def test_it_returns_error_if_remote_actor_object_is_invalid( - self, - app_with_federation: Flask, - actor_1: Actor, - random_actor: RandomActor, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - get_remote_user_mock.return_value = {} - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor.activitypub_id}), - ) - - assert response.status_code == 400 - data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert 'Invalid remote actor object.' in data['message'] - - def test_it_returns_error_if_keys_are_missing_in_remote_actor_object( - self, - app_with_federation: Flask, - actor_1: Actor, - remote_domain: Domain, - random_actor: RandomActor, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + assert ( + 'Invalid remote actor: invalid remote actor url.' + in data['message'] ) - with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - get_remote_user_mock.return_value = { - 'preferredUsername': random_actor.preferred_username, - } - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor.activitypub_id}), - ) - assert response.status_code == 400 - data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert 'Invalid remote actor object.' in data['message'] - - def test_it_returns_error_if_remote_domain_is_local_domain( + def test_it_returns_error_if_create_remote_user_returns_error( self, app_with_federation: Flask, actor_1: Actor, random_actor: RandomActor, ) -> None: - random_actor.domain = ( - f"https://{ app_with_federation.config['AP_DOMAIN']}" - ) client, auth_token = self.get_test_client_and_auth_token( app_with_federation ) with patch( - 'fittrackee.federation.federation.get_remote_user' + 'fittrackee.federation.utils_user.get_remote_actor' ) as get_remote_user_mock: - get_remote_user_mock.return_value = ( - random_actor.get_remote_user_object() - ) + get_remote_user_mock.side_effect = ActorNotFoundException() response = client.post( '/federation/remote-user', content_type='application/json', @@ -224,37 +144,10 @@ def test_it_returns_error_if_remote_domain_is_local_domain( data = json.loads(response.data.decode()) assert 'error' in data['status'] assert ( - 'The provided account is not a remote account.' + 'Invalid remote actor: can not fetch remote actor.' in data['message'] ) - def test_it_creates_remote_actor_if_actor_does_not_exist( - self, - app_with_federation: Flask, - actor_1: Actor, - remote_domain: Domain, - random_actor: RandomActor, - ) -> None: - random_actor.domain = f'https://{remote_domain.name}' - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - remote_user_object = random_actor.get_remote_user_object() - with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - get_remote_user_mock.return_value = remote_user_object - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor.activitypub_id}), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data == remote_user_object - def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( self, app_with_federation: Flask, @@ -266,64 +159,8 @@ def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( app_with_federation ) with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - get_remote_user_mock.return_value = remote_user_object - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor.activitypub_id}), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data == remote_user_object - - def test_it_returns_updated_remote_actor_if_remote_actor_exists( - self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor - ) -> None: - remote_user_object = remote_actor.serialize() - updated_name = random_string() - remote_user_object['name'] = updated_name - last_fetched = remote_actor.last_fetch_date - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - with patch( - 'fittrackee.federation.federation.get_remote_user' + 'fittrackee.federation.utils_user.get_remote_actor' ) as get_remote_user_mock: - get_remote_user_mock.return_value = remote_user_object - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': remote_actor.activitypub_id}), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data == remote_user_object - assert remote_actor.name == updated_name - assert remote_actor.last_fetch_date != last_fetched - - def test_it_creates_several_remote_actors( - self, - app_with_federation: Flask, - actor_1: Actor, - random_actor: RandomActor, - random_actor_2: RandomActor, - ) -> None: - """ - check constrains on User model (especially empty password and email) - """ - client, auth_token = self.get_test_client_and_auth_token( - app_with_federation - ) - with patch( - 'fittrackee.federation.federation.get_remote_user' - ) as get_remote_user_mock: - remote_user_object = random_actor.get_remote_user_object() get_remote_user_mock.return_value = remote_user_object response = client.post( '/federation/remote-user', @@ -335,16 +172,3 @@ def test_it_creates_several_remote_actors( assert response.status_code == 200 data = json.loads(response.data.decode()) assert data == remote_user_object - - remote_user_object = random_actor_2.get_remote_user_object() - get_remote_user_mock.return_value = remote_user_object - response = client.post( - '/federation/remote-user', - content_type='application/json', - headers=dict(Authorization=f'Bearer {auth_token}'), - data=json.dumps({'actor_url': random_actor_2.activitypub_id}), - ) - - assert response.status_code == 200 - data = json.loads(response.data.decode()) - assert data == remote_user_object diff --git a/fittrackee/tests/federation/federation/test_federation_utils_user.py b/fittrackee/tests/federation/federation/test_federation_utils_user.py new file mode 100644 index 000000000..f0e931cd0 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_utils_user.py @@ -0,0 +1,176 @@ +from typing import Union +from unittest.mock import patch + +import pytest +from flask import Flask + +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + RemoteActorException, +) +from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.utils_user import create_remote_user + +from ...utils import RandomActor, random_string + + +class TestCreateRemoteUser: + @pytest.mark.parametrize( + 'input_description, input_actor_url', + [('none', None), ('empty string', '')], + ) + def test_it_returns_error_if_remote_actor_url_is_invalid( + self, input_description: str, input_actor_url: Union[str, None] + ) -> None: + with pytest.raises( + RemoteActorException, + match='Invalid remote actor: invalid remote actor url.', + ): + create_remote_user(input_actor_url) + + def test_it_returns_error_if_remote_actor_domain_is_local( + self, app_with_federation: Flask + ) -> None: + with pytest.raises( + RemoteActorException, + match=( + 'Invalid remote actor: ' + 'the provided account is not a remote account.' + ), + ): + create_remote_user(f'{app_with_federation.config["UI_URL"]}') + + def test_it_returns_error_if_preferred_username_is_missing_in_remote_actor_object( # noqa + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + random_actor.domain = f'https://{remote_domain.name}' + remote_user_object = random_actor.get_remote_user_object() + del remote_user_object['preferredUsername'] + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + with pytest.raises( + RemoteActorException, + match='Invalid remote actor: invalid remote actor object.', + ): + create_remote_user(random_actor.activitypub_id) + + def test_it_returns_error_if_keys_are_missing_in_remote_actor_object( + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = { + 'preferredUsername': random_actor.preferred_username, + } + with pytest.raises( + RemoteActorException, + match='Invalid remote actor: invalid remote actor object.', + ): + create_remote_user(random_actor.activitypub_id) + + def test_it_returns_error_if_fetching_remote_user_returns_error( + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.side_effect = ActorNotFoundException() + with pytest.raises( + RemoteActorException, + match='Invalid remote actor: can not fetch remote actor.', + ): + create_remote_user(random_actor.activitypub_id) + + def test_it_creates_remote_actor_if_actor_does_not_exist( + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + random_actor.domain = f'https://{remote_domain.name}' + remote_user_object = random_actor.get_remote_user_object() + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + + actor = create_remote_user(random_actor.activitypub_id) + + expected_actor = Actor.query.filter_by( + activitypub_id=random_actor.activitypub_id + ).first() + assert actor == expected_actor + + def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, + ) -> None: + remote_user_object = random_actor.get_remote_user_object() + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + + actor = create_remote_user(random_actor.activitypub_id) + + expected_actor = Actor.query.filter_by( + activitypub_id=random_actor.activitypub_id + ).first() + assert actor == expected_actor + + def test_it_returns_updated_remote_actor_if_remote_actor_exists( + self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor + ) -> None: + remote_user_object = remote_actor.serialize() + updated_name = random_string() + remote_user_object['name'] = updated_name + last_fetched = remote_actor.last_fetch_date + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + get_remote_user_mock.return_value = remote_user_object + + create_remote_user(remote_actor.activitypub_id) + + updated_actor = Actor.query.filter_by(id=remote_actor.id).first() + assert updated_actor.name == updated_name + assert updated_actor.last_fetch_date != last_fetched + + def test_it_creates_several_remote_actors( + self, + app_with_federation: Flask, + actor_1: Actor, + random_actor: RandomActor, + random_actor_2: RandomActor, + ) -> None: + """ + check constrains on User model (especially empty password and email) + """ + with patch( + 'fittrackee.federation.utils_user.get_remote_actor' + ) as get_remote_user_mock: + remote_user_object = random_actor.get_remote_user_object() + get_remote_user_mock.return_value = remote_user_object + actor = create_remote_user(random_actor.activitypub_id) + + assert actor.activitypub_id == random_actor.activitypub_id + + remote_user_object = random_actor_2.get_remote_user_object() + get_remote_user_mock.return_value = remote_user_object + actor = create_remote_user(random_actor_2.activitypub_id) + + assert actor.activitypub_id == random_actor_2.activitypub_id diff --git a/fittrackee/tests/federation/federation/test_remote_user.py b/fittrackee/tests/federation/federation/test_remote_actor.py similarity index 80% rename from fittrackee/tests/federation/federation/test_remote_user.py rename to fittrackee/tests/federation/federation/test_remote_actor.py index 997c199bd..e0b464c4c 100644 --- a/fittrackee/tests/federation/federation/test_remote_user.py +++ b/fittrackee/tests/federation/federation/test_remote_actor.py @@ -4,18 +4,18 @@ import requests from fittrackee.federation.exceptions import ActorNotFoundException -from fittrackee.federation.remote_user import get_remote_user +from fittrackee.federation.remote_actor import get_remote_actor from ...utils import RandomActor, generate_response, random_actor_url -class TestGetRemoteUser: +class TestGetRemoteActor: def test_it_returns_error_if_remote_instance_returns_error(self) -> None: with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response(status_code=404) with pytest.raises(ActorNotFoundException): - get_remote_user(random_actor_url()) + get_remote_actor(random_actor_url()) def test_it_returns_user_object_if_remote_response_is_successful( self, random_actor: RandomActor @@ -26,6 +26,6 @@ def test_it_returns_user_object_if_remote_response_is_successful( status_code=200, content=remote_user ) - expected_user = get_remote_user(random_actor.activitypub_id) + expected_user = get_remote_actor(random_actor.activitypub_id) assert remote_user == expected_user diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 01949c535..cf5b39296 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -47,7 +47,7 @@ def get_remote_user_object( domain: str, profile_url: Optional[str] = None, ) -> Dict: - user_url = f'{domain}/users/{username}' + user_url = f'{domain}/users/{preferred_username}' user_object = { '@context': [ 'https://www.w3.org/ns/activitystreams', From cc59b403cd2bcacf7fa85271bd322ac29a432d4e Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 21 Jul 2021 19:36:36 +0200 Subject: [PATCH 046/238] API - handle automatic follow requests approval --- fittrackee/federation/models.py | 12 +++--- fittrackee/federation/utils_user.py | 3 ++ .../23_8842c351a2d8_init_federation.py | 17 ++++++-- .../federation/test_federation_utils_user.py | 8 ++++ .../federation/users/test_users_model.py | 42 +++++++++++++++++++ fittrackee/tests/users/test_users_model.py | 26 ++++++++++++ fittrackee/tests/utils.py | 10 ++++- fittrackee/users/models.py | 30 ++++++++++--- 8 files changed, 130 insertions(+), 18 deletions(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index 7623be1b5..af719b689 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -72,9 +72,6 @@ class Actor(BaseModel): following_url = db.Column(db.String(255), nullable=False) shared_inbox_url = db.Column(db.String(255), nullable=False) created_at = db.Column(db.DateTime, nullable=False) - manually_approves_followers = db.Column( - db.Boolean, default=True, nullable=False - ) last_fetch_date = db.Column(db.DateTime, nullable=True) domain = db.relationship('Domain', back_populates='actors') @@ -126,12 +123,15 @@ def fullname(self) -> Optional[str]: return f'{self.preferred_username}@{self.domain.name}' return None + @property + def manually_approves_followers(self) -> Optional[bool]: + if self.type == ActorType.PERSON and self.user: + return self.user.manually_approves_followers + return None + def update_remote_data(self, remote_user_data: Dict) -> None: self.activitypub_id = remote_user_data['id'] self.type = ActorType(remote_user_data['type']) - self.manually_approves_followers = remote_user_data[ - 'manuallyApprovesFollowers' - ] self.followers_url = remote_user_data['followers'] self.following_url = remote_user_data['following'] self.profile_url = remote_user_data.get('url', remote_user_data['id']) diff --git a/fittrackee/federation/utils_user.py b/fittrackee/federation/utils_user.py index ec68ade23..39c6fdb98 100644 --- a/fittrackee/federation/utils_user.py +++ b/fittrackee/federation/utils_user.py @@ -89,5 +89,8 @@ def create_remote_user(remote_actor_url: Optional[str]) -> Actor: else: actor.update_remote_data(remote_actor_object) actor.user.username = remote_actor_object['name'] + actor.user.manually_approves_followers = remote_actor_object[ + 'manuallyApprovesFollowers' + ] db.session.commit() return actor diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 8a2aa5852..3826ff43f 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -70,7 +70,6 @@ def upgrade(): sa.Column('following_url', sa.String(length=255), nullable=False), sa.Column('shared_inbox_url', sa.String(length=255), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('manually_approves_followers', sa.Boolean(), nullable=False), sa.Column('last_fetch_date', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint( ['domain_id'], @@ -83,6 +82,11 @@ def upgrade(): ), ) + op.add_column( + 'users', + sa.Column('manually_approves_followers', sa.Boolean(), + nullable=True) + ) op.add_column('users', sa.Column('actor_id', sa.Integer(), nullable=True)) op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) op.create_foreign_key( @@ -111,14 +115,17 @@ def upgrade(): connection = op.get_bind() domain = connection.execute(domain_table.select()).fetchone() for user in connection.execute(user_helper.select()): + op.execute( + "UPDATE users SET manually_approves_followers = True " + f"WHERE users.id = {user.id}" + ) created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') public_key, private_key = generate_keys() op.execute( "INSERT INTO actors (" "activitypub_id, domain_id, preferred_username, public_key, " "private_key, followers_url, following_url, profile_url, " - "inbox_url, outbox_url, shared_inbox_url, created_at, " - "manually_approves_followers) " + "inbox_url, outbox_url, shared_inbox_url, created_at) " "VALUES (" f"'{get_ap_url(user.username, 'user_url')}', " f"{domain.id}, '{user.username}', " @@ -129,7 +136,7 @@ def upgrade(): f"'{get_ap_url(user.username, 'inbox')}', " f"'{get_ap_url(user.username, 'outbox')}', " f"'{get_ap_url(user.username, 'shared_inbox')}', " - f"'{created_at}'::timestamp, {True}) RETURNING id" + f"'{created_at}'::timestamp) RETURNING id" ) actor = connection.execute( actors_table.select().where( @@ -139,6 +146,7 @@ def upgrade(): op.execute( f'UPDATE users SET actor_id = {actor.id} WHERE users.id = {user.id}' ) + op.alter_column('users', 'manually_approves_followers', nullable=False) op.create_unique_constraint( 'username_actor_id_unique', 'users', ['username', 'actor_id'] ) @@ -183,6 +191,7 @@ def downgrade(): ) op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') op.drop_constraint('users_actor_id_key', 'users', type_='unique') + op.drop_column('users', 'manually_approves_followers') op.drop_column('users', 'actor_id') op.drop_table('actors') diff --git a/fittrackee/tests/federation/federation/test_federation_utils_user.py b/fittrackee/tests/federation/federation/test_federation_utils_user.py index f0e931cd0..ee6f55126 100644 --- a/fittrackee/tests/federation/federation/test_federation_utils_user.py +++ b/fittrackee/tests/federation/federation/test_federation_utils_user.py @@ -100,6 +100,7 @@ def test_it_creates_remote_actor_if_actor_does_not_exist( random_actor: RandomActor, ) -> None: random_actor.domain = f'https://{remote_domain.name}' + random_actor.manually_approves_followers = False remote_user_object = random_actor.get_remote_user_object() with patch( 'fittrackee.federation.utils_user.get_remote_actor' @@ -112,6 +113,11 @@ def test_it_creates_remote_actor_if_actor_does_not_exist( activitypub_id=random_actor.activitypub_id ).first() assert actor == expected_actor + assert actor.user.username == random_actor.name + assert ( + actor.user.manually_approves_followers + == random_actor.manually_approves_followers + ) def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( self, @@ -138,6 +144,7 @@ def test_it_returns_updated_remote_actor_if_remote_actor_exists( remote_user_object = remote_actor.serialize() updated_name = random_string() remote_user_object['name'] = updated_name + remote_user_object['manuallyApprovesFollowers'] = False last_fetched = remote_actor.last_fetch_date with patch( 'fittrackee.federation.utils_user.get_remote_actor' @@ -149,6 +156,7 @@ def test_it_returns_updated_remote_actor_if_remote_actor_exists( updated_actor = Actor.query.filter_by(id=remote_actor.id).first() assert updated_actor.name == updated_name assert updated_actor.last_fetch_date != last_fetched + assert updated_actor.user.manually_approves_followers is False def test_it_creates_several_remote_actors( self, diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index 220088882..3ab4b90ba 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -1,4 +1,5 @@ from datetime import datetime +from unittest.mock import Mock, patch from flask import Flask @@ -93,3 +94,44 @@ def test_it_returns_reject_activity_object_when_follow_request_is_rejected( 'object': actor_2.activitypub_id, }, } + + +class TestUserFollowingModelWithFederation: + @patch('fittrackee.users.models.send_to_users_inbox') + def test_local_actor_sends_follow_requests_to_remote_actor( + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + follow_request = actor_1.user.send_follow_request_to(remote_actor.user) + + assert follow_request in actor_1.user.sent_follow_requests.all() + assert follow_request.is_approved is False + assert follow_request.updated_at is None + send_to_users_inbox_mock.send.assert_called_with( + sender_id=actor_1.id, + activity=follow_request.get_activity(), + recipients=[remote_actor.inbox_url], + ) + + @patch('fittrackee.users.models.send_to_users_inbox') + def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( # noqa + self, + send_to_users_inbox_mock: Mock, + app_with_federation: Flask, + actor_1: Actor, + remote_actor: Actor, + ) -> None: + actor_1.user.manually_approves_followers = False + follow_request = remote_actor.user.send_follow_request_to(actor_1.user) + + assert follow_request in remote_actor.user.sent_follow_requests.all() + assert follow_request.is_approved is True + assert follow_request.updated_at is not None + send_to_users_inbox_mock.send.assert_called_with( + sender_id=actor_1.id, + activity=follow_request.get_activity(), + recipients=[remote_actor.inbox_url], + ) diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index e3f7d6b06..33c6f8310 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -1,4 +1,5 @@ from datetime import datetime +from unittest.mock import Mock, patch import pytest from flask import Flask @@ -127,6 +128,18 @@ def test_user_2_sends_follow_requests_to_user_1( assert follow_request in user_2.sent_follow_requests.all() + @patch('fittrackee.users.models.send_to_users_inbox') + def test_it_does_not_call_send_to_user_inbox_when_federation_is_disabled( + self, + send_to_users_inbox_mock: Mock, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_2.send_follow_request_to(user_1) + + send_to_users_inbox_mock.send.assert_not_called() + def test_user_1_receives_follow_requests_from_user_2( self, app: Flask, @@ -204,3 +217,16 @@ def test_it_raises_error_if_user_approves_follow_request_already_processed( # n with pytest.raises(FollowRequestAlreadyProcessedError): user_1.approves_follow_request_from(user_2) + + def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.manually_approves_followers = False + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_2.sent_follow_requests.all() + assert follow_request.is_approved is True + assert follow_request.updated_at is not None diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index cf5b39296..c2cf1ce46 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -46,6 +46,7 @@ def get_remote_user_object( preferred_username: str, domain: str, profile_url: Optional[str] = None, + manually_approves_followers: bool = True, ) -> Dict: user_url = f'{domain}/users/{preferred_username}' user_object = { @@ -61,7 +62,7 @@ def get_remote_user_object( 'outbox': f'{user_url}/outbox', 'name': username, 'preferredUsername': preferred_username, - 'manuallyApprovesFollowers': True, + 'manuallyApprovesFollowers': manually_approves_followers, 'publicKey': { 'id': f'{user_url}#main-key', 'owner': user_url, @@ -79,6 +80,7 @@ class RandomActor: name: str = random_string() preferred_username: str = random_string() domain: str = random_domain_with_scheme() + manually_approves_followers: bool = True @property def fullname(self) -> str: @@ -96,7 +98,11 @@ def profile_url(self) -> str: def get_remote_user_object(self) -> Dict: return get_remote_user_object( - self.name, self.preferred_username, self.domain, self.profile_url + self.name, + self.preferred_username, + self.domain, + self.profile_url, + self.manually_approves_followers, ) diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index c10fd7323..c6f66f7c8 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -152,6 +152,9 @@ class User(BaseModel): primaryjoin=id == FollowRequest.follower_user_id, lazy='dynamic', ) + manually_approves_followers = db.Column( + db.Boolean, default=True, nullable=False + ) def __repr__(self) -> str: return f'' @@ -235,14 +238,28 @@ def send_follow_request_to(self, target: 'User') -> FollowRequest: follower_user_id=self.id, followed_user_id=target.id ) db.session.add(follow_request) + if not target.manually_approves_followers: + follow_request.is_approved = True + follow_request.updated_at = datetime.utcnow() db.session.commit() - if current_app.config['federation_enabled'] and target.actor.is_remote: - send_to_users_inbox.send( - sender_id=self.actor.id, - activity=follow_request.get_activity(), - recipients=[target.actor.inbox_url], - ) + if current_app.config['federation_enabled']: + # send Follow activity to remote followed user + if target.actor.is_remote: + send_to_users_inbox.send( + sender_id=self.actor.id, + activity=follow_request.get_activity(), + recipients=[target.actor.inbox_url], + ) + + # send Accept activity to remote follower user if local followed + # user accepts follow requests automatically + if self.actor.is_remote and not target.manually_approves_followers: + send_to_users_inbox.send( + sender_id=target.actor.id, + activity=follow_request.get_activity(), + recipients=[self.actor.inbox_url], + ) return follow_request @@ -266,6 +283,7 @@ def _processes_follow_request_from( activity=follow_request.get_activity(), recipients=[user.actor.inbox_url], ) + return follow_request def approves_follow_request_from(self, user: 'User') -> FollowRequest: From ef1deffacd987b1874907a4cf627ba4a81edd61f Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:49:53 +0100 Subject: [PATCH 047/238] API - update config --- fittrackee/application/models.py | 4 ++-- fittrackee/federation/nodeinfo.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index b97fda882..e77ae8958 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -7,7 +7,7 @@ from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.session import Session -from fittrackee import VERSION, BaseModel, db +from fittrackee import BaseModel, db from fittrackee.users.models import User @@ -48,7 +48,7 @@ def serialize(self) -> Dict: 'max_zip_file_size': self.max_zip_file_size, 'max_users': self.max_users, 'map_attribution': self.map_attribution, - 'version': VERSION, + 'version': current_app.config['VERSION'], } diff --git a/fittrackee/federation/nodeinfo.py b/fittrackee/federation/nodeinfo.py index ab9b2184c..767d0bccf 100644 --- a/fittrackee/federation/nodeinfo.py +++ b/fittrackee/federation/nodeinfo.py @@ -81,7 +81,7 @@ def get_nodeinfo(app_domain: Domain) -> HttpResponse: "version": "2.0", "software": { "name": "fittrackee", - "version": "0.4.8" + "version": "0.5.7" }, "protocols": [ "activitypub" From d405dc3524879a5ff68871f07018133b7be3c47d Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 14:51:24 +0100 Subject: [PATCH 048/238] API - fix endpoints after authentication decorator update (rebase) --- fittrackee/federation/federation.py | 2 -- fittrackee/users/follow_requests.py | 5 ++--- fittrackee/users/users.py | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 810d19450..84d691325 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -135,8 +135,6 @@ def remote_actor( } } - :param integer auth_user_id: authenticate user id (from JSON Web Token) - : Union[Dict, HttpResponse]: appLog.error(f'Error when following a user: {e}') return UserNotFoundErrorResponse() - follower_user = User.query.filter_by(id=auth_user.id).first() try: - follower_user.send_follow_request_to(target_user) + auth_user.send_follow_request_to(target_user) except FollowRequestAlreadyRejectedError: return ForbiddenErrorResponse() return successful_response_dict From 5a9255650b58f19fae30dfab72cb4a0439921823 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 2 Dec 2021 09:42:47 +0100 Subject: [PATCH 049/238] API - init follower/following model --- fittrackee/tests/users/test_users_model.py | 58 ++++++++++++++++++++++ fittrackee/users/models.py | 28 ++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 33c6f8310..5e961c3ce 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -230,3 +230,61 @@ def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( assert follow_request in user_2.sent_follow_requests.all() assert follow_request.is_approved is True assert follow_request.updated_at is not None + + +class TestUserFollowers: + def test_it_returns_empty_list_if_no_followers( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.followers.all() == [] + + def test_it_returns_empty_list_if_follow_request_is_not_approved( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + assert user_1.followers.all() == [] + + def test_it_returns_follower_if_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.now() + + assert user_1.followers.all() == [user_2] + + +class TestUserFollowing: + def test_it_returns_empty_list_if_no_followers( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.following.all() == [] + + def test_it_returns_empty_list_if_follow_request_is_not_approved( + self, + app: Flask, + user_1: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + assert user_1.following.all() == [] + + def test_it_returns_follower_if_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.now() + + assert user_1.following.all() == [user_2] diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index c6f66f7c8..f0a9c676c 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -3,7 +3,7 @@ import jwt from flask import current_app -from sqlalchemy import func +from sqlalchemy import and_, func from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.sql.expression import select @@ -155,6 +155,32 @@ class User(BaseModel): manually_approves_followers = db.Column( db.Boolean, default=True, nullable=False ) + followers = db.relationship( + 'User', + secondary='follow_requests', + primaryjoin=and_( + id == FollowRequest.followed_user_id, + FollowRequest.is_approved == True, # noqa + ), + secondaryjoin=and_( + id == FollowRequest.follower_user_id, + ), + lazy='dynamic', + viewonly=True, + ) + following = db.relationship( + 'User', + secondary='follow_requests', + primaryjoin=and_( + id == FollowRequest.follower_user_id, + FollowRequest.is_approved == True, # noqa + ), + secondaryjoin=and_( + id == FollowRequest.followed_user_id, + ), + lazy='dynamic', + viewonly=True, + ) def __repr__(self) -> str: return f'' From 3ac93ec9d31dd356cd19170ae5c79bdcb903e828 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 16:36:49 +0100 Subject: [PATCH 050/238] API - test refacto --- .../tests/application/test_app_config_api.py | 28 +-- .../federation/test_federation_federation.py | 10 +- .../federation/users/test_users_follow_api.py | 20 +-- .../users/test_users_follow_request_api.py | 32 ++-- fittrackee/tests/test_case_mixins.py | 5 +- fittrackee/tests/users/test_auth_api.py | 116 +++++++++---- fittrackee/tests/users/test_users_api.py | 134 ++++++++++----- .../tests/users/test_users_follow_api.py | 20 ++- .../users/test_users_follow_request_api.py | 64 +++++-- fittrackee/tests/workouts/test_records_api.py | 20 ++- fittrackee/tests/workouts/test_sports_api.py | 50 ++++-- fittrackee/tests/workouts/test_stats_api.py | 96 ++++++++--- .../tests/workouts/test_workouts_api_0_get.py | 160 +++++++++++++----- .../workouts/test_workouts_api_1_post.py | 132 +++++++++++---- .../workouts/test_workouts_api_2_patch.py | 36 +++- .../workouts/test_workouts_api_3_delete.py | 8 +- 16 files changed, 660 insertions(+), 271 deletions(-) diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index c7da3fed8..d5eef2858 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -12,7 +12,9 @@ class TestGetConfig(ApiTestCaseMixin): def test_it_gets_application_config( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/config', @@ -38,7 +40,7 @@ def test_it_returns_error_if_application_has_no_config( self, app_no_config: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_no_config, as_admin=True + app_no_config, user_1_admin.email ) response = client.get( @@ -56,7 +58,7 @@ def test_it_returns_error_if_application_has_several_config( self, app: Flask, app_config: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -76,7 +78,7 @@ def test_it_updates_config_when_user_is_admin( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( '/api/config', @@ -98,7 +100,7 @@ def test_it_updates_all_config( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -129,7 +131,9 @@ def test_it_updates_all_config( def test_it_returns_403_when_user_is_not_an_admin( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( '/api/config', @@ -148,7 +152,7 @@ def test_it_returns_400_if_invalid_is_payload( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -167,7 +171,7 @@ def test_it_returns_error_on_update_if_application_has_no_config( self, app_no_config: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_no_config, as_admin=True + app_no_config, user_1_admin.email ) response = client.patch( @@ -186,7 +190,7 @@ def test_it_raises_error_if_archive_max_size_is_below_files_max_size( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -215,7 +219,7 @@ def test_it_raises_error_if_archive_max_size_equals_0( self, app_with_max_file_size_equals_0: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_file_size_equals_0, as_admin=True + app_with_max_file_size_equals_0, user_1_admin.email ) response = client.patch( @@ -241,7 +245,7 @@ def test_it_raises_error_if_files_max_size_equals_0( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -267,7 +271,7 @@ def test_it_raises_error_if_gpx_limit_import_equals_0( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index 7b6ad74db..df76c3a83 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -69,7 +69,9 @@ class TestRemoteUser(ApiTestCaseMixin): def test_it_returns_error_if_federation_is_disabled( self, app: Flask, user_1: User, random_actor: RandomActor ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/federation/remote-user', content_type='application/json', @@ -104,7 +106,7 @@ def test_it_returns_400_if_remote_user_url_is_missing( self, app_with_federation: Flask, actor_1: Actor ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( '/federation/remote-user', @@ -127,7 +129,7 @@ def test_it_returns_error_if_create_remote_user_returns_error( random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) with patch( 'fittrackee.federation.utils_user.get_remote_actor' @@ -156,7 +158,7 @@ def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( ) -> None: remote_user_object = random_actor.get_remote_user_object() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) with patch( 'fittrackee.federation.utils_user.get_remote_actor' diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index 957cb7d0e..e77ac555b 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -20,7 +20,7 @@ def test_it_raises_error_if_target_user_does_not_exist( actor_1: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -44,7 +44,7 @@ def test_it_raises_error_if_target_user_has_already_rejected_request( follow_request_from_user_1_to_user_2.is_approved = False follow_request_from_user_1_to_user_2.updated_at = datetime.now() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -62,7 +62,7 @@ def test_it_creates_follow_request( self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -88,7 +88,7 @@ def test_it_returns_success_if_follow_request_already_exists( follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -115,7 +115,7 @@ def test_it_does_not_call_send_to_user_inbox( actor_2: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( @@ -137,7 +137,7 @@ def test_it_raise_error_if_remote_actor_does_not_exist( random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -159,7 +159,7 @@ def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domai random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -182,7 +182,7 @@ def test_it_creates_follow_request( remote_actor: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -207,7 +207,7 @@ def test_it_returns_success_if_follow_request_already_exists( follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.post( @@ -233,7 +233,7 @@ def test_it_calls_send_to_user_inbox( remote_actor: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( diff --git a/fittrackee/tests/federation/users/test_users_follow_request_api.py b/fittrackee/tests/federation/users/test_users_follow_request_api.py index b0fba7bc4..55ae0c635 100644 --- a/fittrackee/tests/federation/users/test_users_follow_request_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -17,7 +17,7 @@ def test_it_returns_empty_list_if_no_follow_request( self, app_with_federation: Flask, actor_1: Actor ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.get( @@ -43,7 +43,7 @@ def test_it_returns_current_user_follow_requests_with_actors( ) -> None: follow_request_from_user_3_to_user_1.updated_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) response = client.get( @@ -67,7 +67,7 @@ def test_it_raises_error_if_target_user_does_not_exist( actor_1: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_return_user_not_found( @@ -80,7 +80,7 @@ def test_it_raises_error_if_follow_request_does_not_exist( self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_not_found( @@ -99,7 +99,7 @@ def test_it_raises_error_if_follow_request_already_accepted( datetime.utcnow() ) client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_already_processed( @@ -114,7 +114,7 @@ def test_it_accepts_follow_request( follow_request_from_user_2_to_user_1_with_federation: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_processed( @@ -131,7 +131,7 @@ def test_it_does_not_call_send_to_user_inbox( follow_request_from_user_2_to_user_1_with_federation: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( @@ -156,7 +156,7 @@ def test_it_accepts_follow_request( follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_processed( @@ -173,7 +173,7 @@ def test_it_calls_send_to_user_inbox( follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( @@ -201,7 +201,7 @@ def test_it_raises_error_if_target_user_does_not_exist( actor_1: Actor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_return_user_not_found( @@ -214,7 +214,7 @@ def test_it_raises_error_if_follow_request_does_not_exist( self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_not_found( @@ -232,7 +232,7 @@ def test_it_raises_error_if_follow_request_already_accepted( datetime.utcnow() ) client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_already_processed( @@ -247,7 +247,7 @@ def test_it_rejects_follow_request( follow_request_from_user_2_to_user_1_with_federation: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_processed( @@ -264,7 +264,7 @@ def test_it_does_not_call_send_to_user_inbox( follow_request_from_user_2_to_user_1_with_federation: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( @@ -289,7 +289,7 @@ def test_it_accepts_follow_request( follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) self.assert_it_returns_follow_request_processed( @@ -306,7 +306,7 @@ def test_it_calls_send_to_user_inbox( follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation + app_with_federation, actor_1.user.email ) client.post( diff --git a/fittrackee/tests/test_case_mixins.py b/fittrackee/tests/test_case_mixins.py index 50e809206..5e077e3a3 100644 --- a/fittrackee/tests/test_case_mixins.py +++ b/fittrackee/tests/test_case_mixins.py @@ -32,14 +32,15 @@ def assert_dict_contains_subset(container: Dict, subset: Dict) -> None: class ApiTestCaseMixin: @staticmethod def get_test_client_and_auth_token( - app: Flask, as_admin: bool = False + app: Flask, user_email: str ) -> Tuple[FlaskClient, str]: + """user_email must be user 1 email""" client = app.test_client() resp_login = client.post( '/api/auth/login', data=json.dumps( dict( - email='admin@example.com' if as_admin else 'test@test.com', + email=user_email, password='12345678', ) ), diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 9307635b6..3788d06df 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -423,7 +423,9 @@ def test_it_returns_error_if_password_is_invalid( class TestUserLogout(ApiTestCaseMixin): def test_user_can_logout(self, app: Flask, user_1: User) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/auth/logout', @@ -439,7 +441,9 @@ def test_it_returns_error_with_expired_token( self, app: Flask, user_1: User ) -> None: now = datetime.utcnow() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) with freeze_time(now + timedelta(seconds=4)): response = client.get( @@ -474,7 +478,9 @@ class TestUserProfile(ApiTestCaseMixin): def test_it_returns_user_minimal_profile( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/auth/profile', @@ -503,7 +509,9 @@ def test_it_returns_user_minimal_profile( def test_it_returns_user_full_profile( self, app: Flask, user_1_full: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_full.email + ) response = client.get( '/api/auth/profile', @@ -543,7 +551,9 @@ def test_it_returns_user_profile_with_workouts( workout_cycling_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/auth/profile', @@ -580,7 +590,9 @@ def test_it_returns_error_if_headers_are_invalid(self, app: Flask) -> None: class TestUserProfileUpdate(ApiTestCaseMixin): def test_it_updates_user_profile(self, app: Flask, user_1: User) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -626,7 +638,9 @@ def test_it_updates_user_profile(self, app: Flask, user_1: User) -> None: def test_it_updates_user_profile_without_password( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -670,7 +684,9 @@ def test_it_updates_user_profile_without_password( def test_it_returns_error_if_fields_are_missing( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -687,7 +703,9 @@ def test_it_returns_error_if_fields_are_missing( def test_it_returns_error_if_payload_is_empty( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -704,7 +722,9 @@ def test_it_returns_error_if_payload_is_empty( def test_it_returns_error_if_passwords_mismatch( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -734,7 +754,9 @@ def test_it_returns_error_if_passwords_mismatch( def test_it_returns_error_if_password_confirmation_is_missing( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit', @@ -765,7 +787,9 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin): def test_it_updates_user_preferences( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/preferences', @@ -808,7 +832,9 @@ def test_it_updates_user_preferences( def test_it_returns_error_if_fields_are_missing( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/preferences', @@ -825,7 +851,9 @@ def test_it_returns_error_if_fields_are_missing( def test_it_returns_error_if_payload_is_empty( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/preferences', @@ -844,7 +872,9 @@ class TestUserSportPreferencesUpdate(ApiTestCaseMixin): def test_it_returns_error_if_payload_is_empty( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -861,7 +891,9 @@ def test_it_returns_error_if_payload_is_empty( def test_it_returns_error_if_sport_id_is_missing( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -878,7 +910,9 @@ def test_it_returns_error_if_sport_id_is_missing( def test_it_returns_error_if_sport_not_found( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -895,7 +929,9 @@ def test_it_returns_error_if_sport_not_found( def test_it_returns_error_if_payload_contains_only_sport_id( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -912,7 +948,9 @@ def test_it_returns_error_if_payload_contains_only_sport_id( def test_it_returns_error_if_color_is_invalid( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -942,7 +980,9 @@ def test_it_updates_sport_color_for_auth_user( sport_2_running: Sport, input_color: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -969,7 +1009,9 @@ def test_it_updates_sport_color_for_auth_user( def test_it_disables_sport_for_auth_user( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -996,7 +1038,9 @@ def test_it_disables_sport_for_auth_user( def test_it_updates_stopped_speed_threshold_for_auth_user( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/profile/edit/sports', @@ -1025,7 +1069,9 @@ class TestUserSportPreferencesReset(ApiTestCaseMixin): def test_it_returns_error_if_sport_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( '/api/auth/profile/reset/sports/1', @@ -1044,7 +1090,9 @@ def test_it_resets_sport_preferences( sport_1_cycling: Sport, user_sport_1_preference: UserSportPreference, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( f'/api/auth/profile/reset/sports/{sport_1_cycling.id}', @@ -1063,7 +1111,9 @@ def test_it_resets_sport_preferences( def test_it_does_not_raise_error_if_sport_preferences_do_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( f'/api/auth/profile/reset/sports/{sport_1_cycling.id}', @@ -1075,7 +1125,9 @@ def test_it_does_not_raise_error_if_sport_preferences_do_not_exist( class TestUserPicture(ApiTestCaseMixin): def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/picture', @@ -1111,7 +1163,9 @@ def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None: def test_it_returns_error_if_file_is_missing( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/picture', @@ -1129,7 +1183,9 @@ def test_it_returns_error_if_file_is_missing( def test_it_returns_error_if_file_is_invalid( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/auth/picture', @@ -1153,7 +1209,7 @@ def test_it_returns_error_if_image_size_exceeds_file_limit( gpx_file: str, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_file_size + app_with_max_file_size, user_1.email ) response = client.post( @@ -1184,7 +1240,7 @@ def test_it_returns_error_if_image_size_exceeds_archive_limit( gpx_file: str, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_zip_file_size + app_with_max_zip_file_size, user_1.email ) response = client.post( diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index d75cd5ad8..4f719e643 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -15,7 +15,9 @@ class TestGetUser(ApiTestCaseMixin): def test_it_gets_single_user_without_workouts( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/users/{user_2.username}', @@ -57,7 +59,9 @@ def test_it_gets_single_user_with_workouts( workout_cycling_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/users/{user_1.username}', @@ -93,7 +97,9 @@ def test_it_gets_single_user_with_workouts( def test_it_returns_error_if_user_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users/not_existing', @@ -111,7 +117,9 @@ class TestGetUsers(ApiTestCaseMixin): def test_it_get_users_list( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users', @@ -181,7 +189,9 @@ def test_it_gets_users_list_with_workouts( workout_running_user_1: Workout, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users', @@ -244,7 +254,9 @@ def test_it_gets_first_page_on_users_list( user_2: User, user_3: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?page=1', @@ -271,7 +283,9 @@ def test_it_gets_next_page_on_users_list( user_2: User, user_3: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?page=2', @@ -297,7 +311,9 @@ def test_it_gets_empty_next_page_on_users_list( user_2: User, user_3: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?page=2', @@ -323,7 +339,9 @@ def test_it_gets_user_list_with_2_per_page( user_2: User, user_3: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?per_page=2', @@ -349,7 +367,9 @@ def test_it_gets_next_page_on_user_list_with_2_per_page( user_2: User, user_3: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?page=2&per_page=2', @@ -371,7 +391,9 @@ def test_it_gets_next_page_on_user_list_with_2_per_page( def test_it_gets_users_list_ordered_by_username( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=username', @@ -396,7 +418,9 @@ def test_it_gets_users_list_ordered_by_username( def test_it_gets_users_list_ordered_by_username_ascending( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=username&order=asc', @@ -421,7 +445,9 @@ def test_it_gets_users_list_ordered_by_username_ascending( def test_it_gets_users_list_ordered_by_username_descending( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=username&order=desc', @@ -450,7 +476,7 @@ def test_it_gets_users_list_ordered_by_creation_date( user_3.created_at = datetime.utcnow() - timedelta(hours=1) user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -480,7 +506,7 @@ def test_it_gets_users_list_ordered_by_creation_date_ascending( user_3.created_at = datetime.utcnow() - timedelta(hours=1) user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -510,7 +536,7 @@ def test_it_gets_users_list_ordered_by_creation_date_descending( user_3.created_at = datetime.utcnow() - timedelta(hours=1) user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -537,7 +563,7 @@ def test_it_gets_users_list_ordered_by_admin_rights( self, app: Flask, user_2: User, user_1_admin: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -564,7 +590,7 @@ def test_it_gets_users_list_ordered_by_admin_rights_ascending( self, app: Flask, user_2: User, user_1_admin: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -591,7 +617,7 @@ def test_it_gets_users_list_ordered_by_admin_rights_descending( self, app: Flask, user_2: User, user_3: User, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -623,7 +649,9 @@ def test_it_gets_users_list_ordered_by_workouts_count( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=workouts_count', @@ -657,7 +685,9 @@ def test_it_gets_users_list_ordered_by_workouts_count_ascending( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=workouts_count&order=asc', @@ -691,7 +721,9 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=workouts_count&order=desc', @@ -719,7 +751,9 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( def test_it_gets_users_list_filtering_on_username( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?q=toto', @@ -742,7 +776,9 @@ def test_it_gets_users_list_filtering_on_username( def test_it_returns_empty_users_list_filtering_on_username( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?q=not_existing', @@ -764,7 +800,9 @@ def test_it_returns_empty_users_list_filtering_on_username( def test_it_users_list_with_complex_query( self, app: Flask, user_1: User, user_2: User, user_3: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/users?order_by=username&order=desc&page=2&per_page=2', @@ -816,7 +854,7 @@ def test_it_adds_admin_rights_to_a_user( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -838,7 +876,7 @@ def test_it_removes_admin_rights_to_a_user( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -861,7 +899,7 @@ def test_it_returns_error_if_payload_for_admin_rights_is_empty( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -880,7 +918,7 @@ def test_it_returns_error_if_payload_for_admin_rights_is_invalid( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -901,7 +939,9 @@ def test_it_returns_error_if_payload_for_admin_rights_is_invalid( def test_it_returns_error_if_user_can_not_change_admin_rights( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( '/api/users/toto', @@ -920,7 +960,9 @@ class TestDeleteUser(ApiTestCaseMixin): def test_user_can_delete_its_own_account( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( '/api/users/test', @@ -932,7 +974,9 @@ def test_user_can_delete_its_own_account( def test_user_with_workout_can_delete_its_own_account( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.post( '/api/workouts', data=dict( @@ -959,7 +1003,9 @@ def test_user_with_preferences_can_delete_its_own_account( sport_1_cycling: Sport, user_sport_1_preference: UserSportPreference, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( '/api/users/test', @@ -971,7 +1017,9 @@ def test_user_with_preferences_can_delete_its_own_account( def test_user_with_picture_can_delete_its_own_account( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.post( '/api/auth/picture', data=dict(file=(BytesIO(b'avatar'), 'avatar.png')), @@ -991,7 +1039,9 @@ def test_user_with_picture_can_delete_its_own_account( def test_user_can_not_delete_another_user_account( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( '/api/users/toto', @@ -1006,7 +1056,9 @@ def test_user_can_not_delete_another_user_account( def test_it_returns_error_when_deleting_non_existing_user( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( '/api/users/not_existing', @@ -1022,7 +1074,7 @@ def test_admin_can_delete_another_user_account( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.delete( @@ -1036,7 +1088,7 @@ def test_admin_can_delete_its_own_account( self, app: Flask, user_1_admin: User, user_2_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.delete( @@ -1050,7 +1102,7 @@ def test_admin_can_not_delete_its_own_account_if_no_other_admin( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.delete( @@ -1074,7 +1126,7 @@ def test_it_enables_registration_on_user_delete( user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_3_users_max, as_admin=True + app_with_3_users_max, user_1_admin.email ) client.delete( '/api/users/toto', @@ -1104,7 +1156,7 @@ def test_it_does_not_enable_registration_on_user_delete( user_1_paris: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_3_users_max, as_admin=True + app_with_3_users_max, user_1_admin.email ) client.delete( diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index a05fe4203..96773ba6c 100644 --- a/fittrackee/tests/users/test_users_follow_api.py +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -14,7 +14,9 @@ class TestFollowWithoutFederation(ApiTestCaseMixin): def test_it_raises_error_if_target_user_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( f'/api/users/{random_string()}/follow', @@ -36,7 +38,9 @@ def test_it_raises_error_if_target_user_has_already_rejected_request( ) -> None: follow_request_from_user_1_to_user_2.is_approved = False follow_request_from_user_1_to_user_2.updated_at = datetime.now() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( f'/api/users/{user_2.username}/follow', @@ -52,7 +56,9 @@ def test_it_raises_error_if_target_user_has_already_rejected_request( def test_it_creates_follow_request( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( f'/api/users/{user_2.username}/follow', @@ -75,7 +81,9 @@ def test_it_returns_success_if_follow_request_already_exists( user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( f'/api/users/{user_2.username}/follow', @@ -99,7 +107,9 @@ def test_it_does_not_call_send_to_user_inbox( user_1: User, user_2: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.post( f'/api/users/{user_2.username}/follow', diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py index 92b9de979..e7c379f1f 100644 --- a/fittrackee/tests/users/test_users_follow_request_api.py +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -15,7 +15,9 @@ class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): def test_it_returns_empty_list_if_no_follow_request( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests', @@ -37,7 +39,9 @@ def test_it_returns_current_user_pending_follow_requests( follow_request_from_user_3_to_user_2: FollowRequest, ) -> None: follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests', @@ -61,7 +65,9 @@ def test_it_returns_pagination( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests', @@ -89,7 +95,9 @@ def test_it_returns_second_page( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests?page=2', @@ -118,7 +126,9 @@ def test_it_returns_max_follow_request_per_page( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests?per_page=10', @@ -146,7 +156,9 @@ def test_it_returns_follow_requests_with_descending_order( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests?order=desc', @@ -168,7 +180,9 @@ def test_it_returns_one_request_per_page( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests?per_page=1', @@ -196,7 +210,9 @@ def test_it_returns_second_page_with_one_request_per_page_with_descending_order( follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/follow_requests?page=2&per_page=1&order=desc', @@ -280,7 +296,9 @@ def test_it_raises_error_if_target_user_does_not_exist( app: Flask, user_1: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_return_user_not_found( f'/api/follow_requests/{random_string()}/accept', @@ -291,7 +309,9 @@ def test_it_raises_error_if_target_user_does_not_exist( def test_it_raises_error_if_follow_request_does_not_exist( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_not_found( client, auth_token, user_2.username, 'accept' @@ -305,7 +325,9 @@ def test_it_raises_error_if_follow_request_already_accepted( follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_already_processed( client, auth_token, user_2.username, 'accept' @@ -318,7 +340,9 @@ def test_it_accepts_follow_request( user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_processed( client, auth_token, user_2.username, 'accept' @@ -331,7 +355,9 @@ def test_it_raises_error_if_target_user_does_not_exist( app: Flask, user_1: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_return_user_not_found( f'/api/follow_requests/{random_string()}/reject', @@ -342,7 +368,9 @@ def test_it_raises_error_if_target_user_does_not_exist( def test_it_raises_error_if_follow_request_does_not_exist( self, app: Flask, user_1: User, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_not_found( client, auth_token, user_2.username, 'reject' @@ -356,7 +384,9 @@ def test_it_raises_error_if_follow_request_already_rejected( follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_already_processed( client, auth_token, user_2.username, 'reject' @@ -369,7 +399,9 @@ def test_it_rejects_follow_request( user_2: User, follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) self.assert_it_returns_follow_request_processed( client, auth_token, user_2.username, 'reject' diff --git a/fittrackee/tests/workouts/test_records_api.py b/fittrackee/tests/workouts/test_records_api.py index 361e082f4..549cd19f7 100644 --- a/fittrackee/tests/workouts/test_records_api.py +++ b/fittrackee/tests/workouts/test_records_api.py @@ -19,7 +19,9 @@ def test_it_gets_records_for_authenticated_user( workout_cycling_user_1: Workout, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/records', @@ -92,7 +94,9 @@ def test_it_gets_no_records_if_user_has_no_workout( sport_2_running: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/records', @@ -111,7 +115,9 @@ def test_it_gets_no_records_if_workout_has_zero_value( sport_1_cycling: Sport, sport_2_running: Sport, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.post( '/api/workouts/no_gpx', @@ -141,7 +147,9 @@ def test_it_gets_no_records_if_workout_has_zero_value( def test_it_gets_updated_records_after_workouts_post_and_patch( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', content_type='application/json', @@ -628,7 +636,9 @@ def test_it_gets_updated_records_after_sport_change( sport_1_cycling: Sport, sport_2_running: Sport, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', diff --git a/fittrackee/tests/workouts/test_sports_api.py b/fittrackee/tests/workouts/test_sports_api.py index b55045602..df3f311b1 100644 --- a/fittrackee/tests/workouts/test_sports_api.py +++ b/fittrackee/tests/workouts/test_sports_api.py @@ -52,7 +52,9 @@ def test_it_gets_all_sports( sport_1_cycling: Sport, sport_2_running: Sport, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports', @@ -73,7 +75,9 @@ def test_it_gets_all_sports_with_inactive_one( sport_1_cycling_inactive: Sport, sport_2_running: Sport, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports', @@ -98,7 +102,7 @@ def test_it_gets_all_sports_with_admin_rights( sport_2_running: Sport, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -132,7 +136,7 @@ def test_it_gets_sports_with_auth_user_preferences( db.session.commit() client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -156,7 +160,9 @@ class TestGetSport(ApiTestCaseMixin): def test_it_gets_a_sport( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports/1', @@ -176,7 +182,9 @@ def test_it_gets_a_sport_with_preferences( sport_1_cycling: Sport, user_sport_1_preference: UserSportPreference, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports/1', @@ -192,7 +200,9 @@ def test_it_gets_a_sport_with_preferences( def test_it_returns_404_if_sport_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports/1', @@ -207,7 +217,9 @@ def test_it_returns_404_if_sport_does_not_exist( def test_it_gets_a_inactive_sport( self, app: Flask, user_1: User, sport_1_cycling_inactive: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/sports/1', @@ -227,7 +239,7 @@ def test_it_get_an_inactive_sport_with_admin_rights( self, app: Flask, user_1_admin: User, sport_1_cycling_inactive: Sport ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -250,7 +262,7 @@ def test_it_disables_a_sport( self, app: Flask, user_1_admin: User, sport_1_cycling: Sport ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -273,7 +285,7 @@ def test_it_enables_a_sport( ) -> None: sport_1_cycling.is_active = False client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -299,7 +311,7 @@ def test_it_disables_a_sport_with_workouts( workout_cycling_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -326,7 +338,7 @@ def test_it_enables_a_sport_with_workouts( ) -> None: sport_1_cycling.is_active = False client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -352,7 +364,7 @@ def test_it_disables_a_sport_with_preferences( user_admin_sport_1_preference: UserSportPreference, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -380,7 +392,7 @@ def test_it_enables_a_sport_with_preferences( ) -> None: sport_1_cycling.is_active = False client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -401,7 +413,9 @@ def test_it_enables_a_sport_with_preferences( def test_returns_error_if_user_has_no_admin_rights( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( '/api/sports/1', @@ -420,7 +434,7 @@ def test_returns_error_if_payload_is_invalid( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( @@ -439,7 +453,7 @@ def test_it_returns_error_if_sport_does_not_exist( self, app: Flask, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.patch( diff --git a/fittrackee/tests/workouts/test_stats_api.py b/fittrackee/tests/workouts/test_stats_api.py index 1e22131d5..b4bd949ba 100644 --- a/fittrackee/tests/workouts/test_stats_api.py +++ b/fittrackee/tests/workouts/test_stats_api.py @@ -12,7 +12,9 @@ class TestGetStatsByTime(ApiTestCaseMixin): def test_it_gets_no_stats_when_user_has_no_workouts( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time', @@ -27,7 +29,9 @@ def test_it_gets_no_stats_when_user_has_no_workouts( def test_it_returns_error_when_user_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/stats/1000/by_time', @@ -48,7 +52,9 @@ def test_it_returns_error_if_date_format_is_invalid( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( ( @@ -75,7 +81,9 @@ def test_it_returns_error_if_period_is_invalid( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30&time=day', # noqa @@ -96,7 +104,9 @@ def test_it_gets_stats_by_time_all_workouts( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time', @@ -146,7 +156,9 @@ def test_it_gets_stats_for_april_2018( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30', # noqa @@ -186,7 +198,9 @@ def test_it_gets_stats_for_april_2018_with_paris_timezone( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_paris.email + ) response = client.get( f'/api/stats/{user_1_paris.username}/by_time?' @@ -227,7 +241,9 @@ def test_it_gets_stats_by_year( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?time=year', @@ -277,7 +293,9 @@ def test_it_gets_stats_by_year_for_april_2018( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30&time=year', # noqa @@ -318,7 +336,9 @@ def test_it_gets_stats_by_year_for_april_2018_with_paris_timezone( workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_paris.email + ) response = client.get( f'/api/stats/{user_1_paris.username}/by_time?from=2018-04-01&to=2018-04-30&time=year', # noqa @@ -358,7 +378,9 @@ def test_it_gets_stats_by_month( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?time=month', @@ -448,7 +470,9 @@ def test_it_gets_stats_by_month_with_new_york_timezone( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_full.email + ) response = client.get( f'/api/stats/{user_1_full.username}/by_time?time=month', @@ -538,7 +562,9 @@ def test_it_gets_stats_by_month_for_april_2018( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30&time=month', # noqa @@ -578,7 +604,9 @@ def test_it_gets_stats_by_week( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_full.email + ) response = client.get( f'/api/stats/{user_1_full.username}/by_time?time=week', @@ -668,7 +696,9 @@ def test_it_gets_stats_by_week_for_week_13( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30&time=week', # noqa @@ -708,7 +738,9 @@ def test_if_get_stats_by_week_starting_with_monday( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?time=weekm', @@ -798,7 +830,9 @@ def test_it_gets_stats_by_week_starting_with_monday_for_week_13( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_time?from=2018-04-01&to=2018-04-30&time=weekm', # noqa @@ -840,7 +874,9 @@ def test_it_gets_stats_by_sport( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_sport', @@ -878,7 +914,9 @@ def test_it_get_stats_for_sport_1( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_sport?sport_id=1', @@ -908,7 +946,9 @@ def test_it_returns_errors_if_user_does_not_exist( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/stats/1000/by_sport?sport_id=1', @@ -929,7 +969,9 @@ def test_it_returns_error_if_sport_does_not_exist( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_sport?sport_id=999', @@ -950,7 +992,9 @@ def test_it_returns_error_if_sport_id_is_invalid( seven_workouts_user_1: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/stats/{user_1.username}/by_sport?sport_id="999', @@ -971,7 +1015,7 @@ def test_it_returns_all_stats_when_users_have_no_workouts( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -1000,7 +1044,7 @@ def test_it_gets_app_all_stats_with_workouts( workout_running_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, as_admin=True + app, user_1_admin.email ) response = client.get( @@ -1028,7 +1072,9 @@ def test_it_returns_error_if_user_has_no_admin_rights( workout_cycling_user_2: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/stats/all', diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get.py b/fittrackee/tests/workouts/test_workouts_api_0_get.py index f7266652e..9228c80af 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get.py @@ -23,7 +23,9 @@ def test_it_gets_all_workouts_for_authenticated_user( workout_cycling_user_2: Workout, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts', @@ -119,7 +121,9 @@ def test_it_gets_workouts_with_default_pagination( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts', @@ -157,7 +161,9 @@ def test_it_gets_first_page( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?page=1', @@ -195,7 +201,9 @@ def test_it_gets_second_page( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?page=2', @@ -233,7 +241,9 @@ def test_it_gets_empty_third_page( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?page=3', @@ -259,7 +269,9 @@ def test_it_returns_error_on_invalid_page_value( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?page=A', @@ -282,7 +294,9 @@ def test_it_gets_max_workouts_per_page_if_per_page_exceeds_max( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?per_page=10', @@ -317,7 +331,9 @@ def test_it_gets_given_number_of_workouts_per_page( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?per_page=3', @@ -353,7 +369,9 @@ def test_it_gets_workouts_with_default_order( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts', @@ -387,7 +405,9 @@ def test_it_gets_workouts_with_ascending_order( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order=asc', @@ -421,7 +441,9 @@ def test_it_gets_workouts_with_descending_order( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order=desc', @@ -457,7 +479,9 @@ def test_it_gets_workouts_ordered_by_workout_date( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order_by=workout_date', @@ -491,7 +515,9 @@ def test_it_gets_workouts_ordered_by_distance( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order_by=distance', @@ -519,7 +545,9 @@ def test_it_gets_workouts_ordered_by_duration( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order_by=duration', @@ -547,7 +575,9 @@ def test_it_gets_workouts_ordered_by_average_speed( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?order_by=ave_speed', @@ -577,7 +607,9 @@ def test_it_gets_workouts_with_date_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?from=2018-02-01&to=2018-02-28', @@ -615,7 +647,9 @@ def test_it_gets_no_workouts_with_date_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?from=2018-03-01&to=2018-03-30', @@ -641,7 +675,9 @@ def test_if_gets_workouts_with_date_filter_from( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?from=2018-04-01', @@ -676,7 +712,9 @@ def test_it_gets_workouts_with_date_filter_to( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?to=2017-12-31', @@ -710,7 +748,9 @@ def test_it_gets_workouts_with_distance_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?distance_from=5&distance_to=8.1', @@ -744,7 +784,9 @@ def test_it_gets_workouts_with_duration_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?duration_from=00:52&duration_to=01:20', @@ -774,7 +816,9 @@ def test_it_gets_workouts_with_average_speed_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?ave_speed_from=5&ave_speed_to=10', @@ -808,7 +852,9 @@ def test_it_gets_workouts_with_max_speed_filter( ) -> None: workout_cycling_user_1.max_speed = 25 workout_running_user_1.max_speed = 11 - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?max_speed_from=10&max_speed_to=20', @@ -840,7 +886,9 @@ def test_it_gets_workouts_with_sport_filter( sport_2_running: Sport, workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?sport_id=2', @@ -872,7 +920,9 @@ def test_it_gets_page_2_with_date_filter( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?from=2017-01-01&page=2', @@ -906,7 +956,9 @@ def test_it_get_page_2_with_date_filter_and_ascending_order( sport_1_cycling: Sport, seven_workouts_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( '/api/workouts?from=2017-01-01&page=2&order=asc', @@ -942,7 +994,9 @@ def test_it_gets_an_workout( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_1.short_id}', @@ -971,7 +1025,9 @@ def test_it_returns_403_if_workout_belongs_to_a_different_user( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_2.short_id}', @@ -986,7 +1042,9 @@ def test_it_returns_403_if_workout_belongs_to_a_different_user( def test_it_returns_404_if_workout_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{get_random_short_id()}', @@ -1002,7 +1060,9 @@ def test_it_returns_404_on_getting_gpx_if_workout_does_not_exist( self, app: Flask, user_1: User ) -> None: random_short_id = get_random_short_id() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{random_short_id}/gpx', @@ -1019,7 +1079,9 @@ def test_it_returns_404_on_getting_chart_data_if_workout_does_not_exist( self, app: Flask, user_1: User ) -> None: random_short_id = get_random_short_id() - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{random_short_id}/chart_data', @@ -1040,7 +1102,9 @@ def test_it_returns_404_on_getting_gpx_if_workout_have_no_gpx( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_short_id}/gpx', @@ -1063,7 +1127,9 @@ def test_it_returns_404_if_workout_have_no_chart_data( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_short_id}/chart_data', @@ -1086,7 +1152,9 @@ def test_it_returns_500_on_getting_gpx_if_an_workout_has_invalid_gpx_pathname( workout_cycling_user_1: Workout, ) -> None: workout_cycling_user_1.gpx = "some path" - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_1.short_id}/gpx', @@ -1110,7 +1178,9 @@ def test_it_returns_500_on_getting_chart_data_if_an_workout_has_invalid_gpx_path workout_cycling_user_1: Workout, ) -> None: workout_cycling_user_1.gpx = 'some path' - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_1.short_id}/chart_data', @@ -1129,7 +1199,9 @@ def test_it_returns_500_on_getting_chart_data_if_an_workout_has_invalid_gpx_path def test_it_returns_404_if_workout_has_no_map( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/map/{uuid4().hex}', headers=dict(Authorization=f'Bearer {auth_token}'), @@ -1147,7 +1219,9 @@ def test_it_returns_404_if_workout_does_not_exist( app: Flask, user_1: User, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{get_random_short_id()}/gpx/download', @@ -1166,7 +1240,9 @@ def test_it_returns_404_if_workout_does_not_have_gpx( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_1.short_id}/gpx/download', @@ -1186,7 +1262,9 @@ def test_it_returns_404_if_workout_belongs_to_a_different_user( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.get( f'/api/workouts/{workout_cycling_user_2.short_id}/gpx/download', @@ -1209,7 +1287,9 @@ def test_it_calls_send_from_directory_if_workout_has_gpx( workout_cycling_user_1.gpx = gpx_file_path with patch('fittrackee.workouts.workouts.send_from_directory') as mock: mock.return_value = 'file' - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.get( ( diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index 18f30fd5c..eca0672f6 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -210,7 +210,9 @@ class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin): def test_it_adds_an_workout_with_gpx_file( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -238,7 +240,9 @@ def test_it_adds_an_workout_with_gpx_without_name( sport_1_cycling: Sport, gpx_file_wo_name: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -270,7 +274,9 @@ def test_it_adds_an_workout_with_gpx_without_name_timezone( gpx_file_wo_name: str, ) -> None: user_1.timezone = 'Europe/Paris' - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -311,7 +317,9 @@ def test_it_adds_a_workout_with_gpx_notes( sport_1_cycling: Sport, gpx_file: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -339,7 +347,9 @@ def test_it_calls_configured_tile_server_for_static_map( gpx_file: str, static_map_get_mock: Mock, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) client.post( '/api/workouts', data=dict( @@ -369,7 +379,7 @@ def test_it_calls_default_tile_server_for_static_map( static_map_get_mock: Mock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_default_static_map + app_default_static_map, user_1.email ) client.post( '/api/workouts', @@ -398,7 +408,9 @@ def test_it_returns_500_if_gpx_file_has_not_tracks( sport_1_cycling: Sport, gpx_file_wo_track: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -425,7 +437,9 @@ def test_it_returns_500_if_gpx_has_invalid_xml( sport_1_cycling: Sport, gpx_file_invalid_xml: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -451,7 +465,9 @@ def test_it_returns_500_if_gpx_has_invalid_xml( def test_it_returns_400_if_workout_gpx_has_invalid_extension( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -473,7 +489,9 @@ def test_it_returns_400_if_workout_gpx_has_invalid_extension( def test_it_returns_400_if_sport_id_is_not_provided( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -494,7 +512,9 @@ def test_it_returns_400_if_sport_id_is_not_provided( def test_it_returns_500_if_sport_id_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -516,7 +536,9 @@ def test_it_returns_500_if_sport_id_does_not_exist( def test_returns_400_if_no_gpx_file_is_provided( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -540,7 +562,7 @@ def test_it_returns_error_if_file_size_exceeds_limit( gpx_file: str, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_file_size + app_with_max_file_size, user_1.email ) response = client.post( @@ -568,7 +590,9 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin): def test_it_adds_an_workout_without_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -593,7 +617,9 @@ def test_it_adds_an_workout_without_gpx( def test_it_returns_400_if_workout_date_is_missing( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -610,7 +636,9 @@ def test_it_returns_400_if_workout_date_is_missing( def test_it_returns_500_if_workout_format_is_invalid( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -638,7 +666,9 @@ def test_it_adds_workout_with_zero_value( sport_1_cycling: Sport, sport_2_running: Sport, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -690,7 +720,9 @@ def test_it_adds_workouts_with_zip_archive( file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file with open(file_path, 'rb') as zip_file: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -719,7 +751,9 @@ def test_it_returns_400_if_folder_is_present_in_zip_archive( # 'gpx_test_folder.zip' contains 3 gpx files (same data) and 1 non-gpx # file in a folder with open(file_path, 'rb') as zip_file: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -746,7 +780,9 @@ def test_it_returns_500_if_one_file_in_zip_archive_is_invalid( ) # 'gpx_test_incorrect.zip' contains 2 gpx files, one is incorrect with open(file_path, 'rb') as zip_file: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -778,7 +814,7 @@ def test_it_imports_only_max_number_of_files( # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_workouts + app_with_max_workouts, user_1.email ) client.post( @@ -811,7 +847,7 @@ def test_it_returns_error_if_archive_size_exceeds_limit( # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( - app_with_max_zip_file_size + app_with_max_zip_file_size, user_1.email ) response = client.post( @@ -836,9 +872,11 @@ def test_it_returns_error_if_archive_size_exceeds_limit( class TestPostAndGetWorkoutWithGpx(ApiTestCaseMixin): def workout_assertion( - self, app: Flask, gpx_file: str, with_segments: bool + self, app: Flask, user_1: User, gpx_file: str, with_segments: bool ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', data=dict( @@ -917,7 +955,7 @@ def workout_assertion( def test_it_gets_an_workout_created_with_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - return self.workout_assertion(app, gpx_file, False) + return self.workout_assertion(app, user_1, gpx_file, False) def test_it_gets_an_workout_created_with_gpx_with_segments( self, @@ -926,12 +964,16 @@ def test_it_gets_an_workout_created_with_gpx_with_segments( sport_1_cycling: Sport, gpx_file_with_segments: str, ) -> None: - return self.workout_assertion(app, gpx_file_with_segments, True) + return self.workout_assertion( + app, user_1, gpx_file_with_segments, True + ) def test_it_gets_chart_data_for_an_workout_created_with_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -960,7 +1002,9 @@ def test_it_gets_chart_data_for_an_workout_created_with_gpx( def test_it_gets_segment_chart_data_for_an_workout_created_with_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -994,7 +1038,9 @@ def test_it_returns_403_on_getting_chart_data_if_workout_belongs_to_another_user sport_1_cycling: Sport, gpx_file: str, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', data=dict( @@ -1030,7 +1076,9 @@ def test_it_returns_403_on_getting_chart_data_if_workout_belongs_to_another_user def test_it_returns_500_on_invalid_segment_id( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -1059,7 +1107,9 @@ def test_it_returns_500_on_invalid_segment_id( def test_it_returns_404_if_segment_id_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts', @@ -1090,7 +1140,9 @@ class TestPostAndGetWorkoutWithoutGpx(ApiTestCaseMixin): def test_it_add_and_gets_an_workout_wo_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -1121,7 +1173,9 @@ def test_it_add_and_gets_an_workout_wo_gpx( def test_it_adds_and_gets_an_workout_wo_gpx_notes( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -1156,7 +1210,9 @@ def test_it_add_and_gets_an_workout_wo_gpx_with_timezone( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: user_1.timezone = 'Europe/Paris' - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.post( '/api/workouts/no_gpx', @@ -1194,7 +1250,9 @@ def test_it_add_and_gets_an_workout_wo_gpx_with_timezone( def test_it_adds_and_gets_workouts_date_filter_with_timezone_new_york( self, app: Flask, user_1_full: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_full.email + ) client.post( '/api/workouts/no_gpx', @@ -1234,7 +1292,9 @@ def test_it_adds_and_gets_workouts_date_filter_with_timezone_paris( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_paris.email + ) client.post( '/api/workouts/no_gpx', diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index 09c192d43..10f0515ed 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -250,7 +250,9 @@ def test_it_updates_an_workout_wo_gpx( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_short_id}', @@ -337,7 +339,9 @@ def test_it_adds_notes_to_a_workout_wo_gpx( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_short_id}', @@ -361,7 +365,9 @@ def test_it_empties_workout_notes( ) -> None: workout_short_id = workout_cycling_user_1.short_id workout_cycling_user_1.notes = uuid4().hex - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_short_id}', @@ -384,7 +390,9 @@ def test_returns_403_when_editing_an_workout_wo_gpx_from_different_user( sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_cycling_user_2.short_id}', @@ -415,7 +423,9 @@ def test_it_updates_an_workout_wo_gpx_with_timezone( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_paris.email + ) response = client.patch( f'/api/workouts/{workout_short_id}', @@ -488,7 +498,9 @@ def test_it_updates_only_sport_and_distance_an_workout_wo_gpx( workout_cycling_user_1: Workout, ) -> None: workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_short_id}', @@ -551,7 +563,9 @@ def test_it_returns_400_if_payload_is_empty( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_cycling_user_1.short_id}', @@ -572,7 +586,9 @@ def test_it_returns_500_if_date_format_is_invalid( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{workout_cycling_user_1.short_id}', content_type='application/json', @@ -599,7 +615,9 @@ def test_it_returns_500_if_date_format_is_invalid( def test_it_returns_404_if_edited_workout_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.patch( f'/api/workouts/{get_random_short_id()}', content_type='application/json', diff --git a/fittrackee/tests/workouts/test_workouts_api_3_delete.py b/fittrackee/tests/workouts/test_workouts_api_3_delete.py index f393cccfc..101df6142 100644 --- a/fittrackee/tests/workouts/test_workouts_api_3_delete.py +++ b/fittrackee/tests/workouts/test_workouts_api_3_delete.py @@ -63,7 +63,9 @@ def test_it_returns_403_when_deleting_an_workout_from_different_user( def test_it_returns_404_if_workout_does_not_exist( self, app: Flask, user_1: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( f'/api/workouts/{get_random_short_id()}', headers=dict(Authorization=f'Bearer {auth_token}'), @@ -104,7 +106,9 @@ def test_it_deletes_an_workout_wo_gpx( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token(app) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) response = client.delete( f'/api/workouts/{workout_cycling_user_1.short_id}', headers=dict(Authorization=f'Bearer {auth_token}'), From 7169e68bfc4b8ee7a5161e1ef75f3f21c1adc6a0 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 16:52:22 +0100 Subject: [PATCH 051/238] API - init role management on getting user infos --- fittrackee/tests/users/test_auth_api.py | 4 +- fittrackee/tests/users/test_users_api.py | 295 +++++++++++++-------- fittrackee/tests/users/test_users_model.py | 60 ++++- fittrackee/users/auth.py | 10 +- fittrackee/users/models.py | 26 +- fittrackee/users/roles.py | 7 + fittrackee/users/users.py | 9 +- 7 files changed, 280 insertions(+), 131 deletions(-) create mode 100644 fittrackee/users/roles.py diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 3788d06df..e06f23ada 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -475,7 +475,7 @@ def test_it_returns_error_with_invalid_headers(self, app: Flask) -> None: class TestUserProfile(ApiTestCaseMixin): - def test_it_returns_user_minimal_profile( + def test_it_returns_user_profile_when_no_workouts_and_no_preferences( self, app: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -506,7 +506,7 @@ def test_it_returns_user_minimal_profile( assert data['data']['total_duration'] == '0:00:00' assert response.status_code == 200 - def test_it_returns_user_full_profile( + def test_it_returns_user_profile_with_updated_fields( self, app: Flask, user_1_full: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 4f719e643..4dddedf7d 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timedelta from io import BytesIO +from typing import Dict from unittest.mock import patch from flask import Flask @@ -11,7 +12,114 @@ from ..test_case_mixins import ApiTestCaseMixin -class TestGetUser(ApiTestCaseMixin): +class GetUserTestCase(ApiTestCaseMixin): + @staticmethod + def assert_user(user_dict: Dict, user: User) -> None: + assert user_dict['username'] == user.username + assert user_dict['created_at'] == user.created_at.strftime( + '%a, %d %b %Y %H:%M:%S GMT' + ) + assert user_dict['admin'] == user.admin + assert user_dict['first_name'] is None + assert user_dict['last_name'] is None + assert user_dict['birth_date'] is None + assert user_dict['bio'] is None + assert user_dict['location'] is None + assert 'imperial_units' not in user_dict + assert 'language' not in user_dict + assert 'timezone' not in user_dict + assert 'weekm' not in user_dict + + @staticmethod + def assert_user_2_without_workouts(user_dict: Dict) -> None: + assert user_dict['nb_sports'] == 0 + assert user_dict['nb_workouts'] == 0 + assert user_dict['records'] == [] + assert user_dict['sports_list'] == [] + assert user_dict['total_distance'] == 0 + assert user_dict['total_duration'] == '0:00:00' + + @staticmethod + def assert_user_1_with_workouts(user_dict: Dict) -> None: + assert user_dict['nb_sports'] == 2 + assert user_dict['nb_workouts'] == 2 + assert len(user_dict['records']) == 8 + assert user_dict['sports_list'] == [1, 2] + assert user_dict['total_distance'] == 22 + assert user_dict['total_duration'] == '2:40:00' + + +class TestGetUserAsAdmin(GetUserTestCase): + def test_it_gets_single_user_without_workouts( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + self.assert_user(user, user_2) + self.assert_user_2_without_workouts(user) + assert user['email'] == user_2.email + + def test_it_gets_single_user_with_workouts( + self, + app: Flask, + user_1_admin: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_1_admin.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + self.assert_user(user, user_1_admin) + self.assert_user_1_with_workouts(user) + assert user['email'] == user_1_admin.email + + def test_it_returns_error_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/users/not_existing', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + data = json.loads(response.data.decode()) + + assert response.status_code == 404 + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + +class TestGetUserAsUser(GetUserTestCase): def test_it_gets_single_user_without_workouts( self, app: Flask, user_1: User, user_2: User ) -> None: @@ -30,25 +138,9 @@ def test_it_gets_single_user_without_workouts( assert data['status'] == 'success' assert len(data['data']['users']) == 1 user = data['data']['users'][0] - assert user['username'] == 'toto' - assert user['email'] == 'toto@toto.com' - assert user['created_at'] - assert not user['admin'] - assert user['first_name'] is None - assert user['last_name'] is None - assert user['birth_date'] is None - assert user['bio'] is None - assert user['imperial_units'] is False - assert user['location'] is None - assert user['timezone'] is None - assert user['weekm'] is False - assert user['language'] is None - assert user['nb_sports'] == 0 - assert user['nb_workouts'] == 0 - assert user['records'] == [] - assert user['sports_list'] == [] - assert user['total_distance'] == 0 - assert user['total_duration'] == '0:00:00' + self.assert_user(user, user_2) + self.assert_user_2_without_workouts(user) + assert 'email' not in user def test_it_gets_single_user_with_workouts( self, @@ -74,25 +166,9 @@ def test_it_gets_single_user_with_workouts( assert data['status'] == 'success' assert len(data['data']['users']) == 1 user = data['data']['users'][0] - assert user['username'] == 'test' - assert user['email'] == 'test@test.com' - assert user['created_at'] - assert not user['admin'] - assert user['first_name'] is None - assert user['last_name'] is None - assert user['birth_date'] is None - assert user['bio'] is None - assert user['imperial_units'] is False - assert user['location'] is None - assert user['timezone'] is None - assert user['weekm'] is False - assert user['language'] is None - assert len(user['records']) == 8 - assert user['nb_sports'] == 2 - assert user['nb_workouts'] == 2 - assert user['sports_list'] == [1, 2] - assert user['total_distance'] == 22 - assert user['total_duration'] == '2:40:00' + self.assert_user(user, user_1) + self.assert_user_1_with_workouts(user) + assert 'email' not in user def test_it_returns_error_if_user_does_not_exist( self, app: Flask, user_1: User @@ -113,12 +189,12 @@ def test_it_returns_error_if_user_does_not_exist( assert 'user does not exist' in data['message'] -class TestGetUsers(ApiTestCaseMixin): - def test_it_get_users_list( - self, app: Flask, user_1: User, user_2: User, user_3: User +class TestGetUsersAsAdmin(GetUserTestCase): + def test_it_gets_users_list( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -133,42 +209,42 @@ def test_it_get_users_list( assert 'created_at' in data['data']['users'][0] assert 'created_at' in data['data']['users'][1] assert 'created_at' in data['data']['users'][2] - assert 'test' in data['data']['users'][0]['username'] + assert 'admin' in data['data']['users'][0]['username'] assert 'toto' in data['data']['users'][1]['username'] assert 'sam' in data['data']['users'][2]['username'] - assert 'test@test.com' in data['data']['users'][0]['email'] + assert 'admin@example.com' in data['data']['users'][0]['email'] assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'sam@test.com' in data['data']['users'][2]['email'] - assert data['data']['users'][0]['imperial_units'] is False - assert data['data']['users'][0]['timezone'] is None - assert data['data']['users'][0]['weekm'] is False - assert data['data']['users'][0]['language'] is None assert data['data']['users'][0]['nb_sports'] == 0 assert data['data']['users'][0]['nb_workouts'] == 0 assert data['data']['users'][0]['records'] == [] assert data['data']['users'][0]['sports_list'] == [] assert data['data']['users'][0]['total_distance'] == 0 assert data['data']['users'][0]['total_duration'] == '0:00:00' - assert data['data']['users'][1]['imperial_units'] is False - assert data['data']['users'][1]['timezone'] is None - assert data['data']['users'][1]['weekm'] is False - assert data['data']['users'][1]['language'] is None assert data['data']['users'][1]['nb_sports'] == 0 assert data['data']['users'][1]['nb_workouts'] == 0 assert data['data']['users'][1]['records'] == [] assert data['data']['users'][1]['sports_list'] == [] assert data['data']['users'][1]['total_distance'] == 0 assert data['data']['users'][1]['total_duration'] == '0:00:00' - assert data['data']['users'][2]['imperial_units'] is False - assert data['data']['users'][2]['timezone'] is None - assert data['data']['users'][2]['weekm'] is True - assert data['data']['users'][2]['language'] is None assert data['data']['users'][2]['records'] == [] assert data['data']['users'][2]['nb_sports'] == 0 assert data['data']['users'][2]['nb_workouts'] == 0 assert data['data']['users'][2]['sports_list'] == [] assert data['data']['users'][2]['total_distance'] == 0 assert data['data']['users'][2]['total_duration'] == '0:00:00' + assert 'imperial_units' not in data['data']['users'][0] + assert 'imperial_units' not in data['data']['users'][1] + assert 'imperial_units' not in data['data']['users'][2] + assert 'language' not in data['data']['users'][0] + assert 'language' not in data['data']['users'][1] + assert 'language' not in data['data']['users'][2] + assert 'timezone' not in data['data']['users'][0] + assert 'timezone' not in data['data']['users'][1] + assert 'timezone' not in data['data']['users'][2] + assert 'weekm' not in data['data']['users'][0] + assert 'weekm' not in data['data']['users'][1] + assert 'weekm' not in data['data']['users'][2] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -180,7 +256,7 @@ def test_it_get_users_list( def test_it_gets_users_list_with_workouts( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, sport_1_cycling: Sport, @@ -190,7 +266,7 @@ def test_it_gets_users_list_with_workouts( workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -205,39 +281,42 @@ def test_it_gets_users_list_with_workouts( assert 'created_at' in data['data']['users'][0] assert 'created_at' in data['data']['users'][1] assert 'created_at' in data['data']['users'][2] - assert 'test' in data['data']['users'][0]['username'] + assert 'admin' in data['data']['users'][0]['username'] assert 'toto' in data['data']['users'][1]['username'] assert 'sam' in data['data']['users'][2]['username'] - assert 'test@test.com' in data['data']['users'][0]['email'] + assert 'admin@example.com' in data['data']['users'][0]['email'] assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'sam@test.com' in data['data']['users'][2]['email'] - assert data['data']['users'][0]['imperial_units'] is False - assert data['data']['users'][0]['timezone'] is None - assert data['data']['users'][0]['weekm'] is False assert data['data']['users'][0]['nb_sports'] == 2 assert data['data']['users'][0]['nb_workouts'] == 2 assert len(data['data']['users'][0]['records']) == 8 assert data['data']['users'][0]['sports_list'] == [1, 2] assert data['data']['users'][0]['total_distance'] == 22.0 assert data['data']['users'][0]['total_duration'] == '2:40:00' - assert data['data']['users'][1]['imperial_units'] is False - assert data['data']['users'][1]['timezone'] is None - assert data['data']['users'][1]['weekm'] is False assert data['data']['users'][1]['nb_sports'] == 1 assert data['data']['users'][1]['nb_workouts'] == 1 assert len(data['data']['users'][1]['records']) == 4 assert data['data']['users'][1]['sports_list'] == [1] assert data['data']['users'][1]['total_distance'] == 15 assert data['data']['users'][1]['total_duration'] == '1:00:00' - assert data['data']['users'][2]['imperial_units'] is False - assert data['data']['users'][2]['timezone'] is None - assert data['data']['users'][2]['weekm'] is True assert data['data']['users'][2]['nb_sports'] == 0 assert data['data']['users'][2]['nb_workouts'] == 0 assert len(data['data']['users'][2]['records']) == 0 assert data['data']['users'][2]['sports_list'] == [] assert data['data']['users'][2]['total_distance'] == 0 assert data['data']['users'][2]['total_duration'] == '0:00:00' + assert 'imperial_units' not in data['data']['users'][0] + assert 'imperial_units' not in data['data']['users'][1] + assert 'imperial_units' not in data['data']['users'][2] + assert 'language' not in data['data']['users'][0] + assert 'language' not in data['data']['users'][1] + assert 'language' not in data['data']['users'][2] + assert 'timezone' not in data['data']['users'][0] + assert 'timezone' not in data['data']['users'][1] + assert 'timezone' not in data['data']['users'][2] + assert 'weekm' not in data['data']['users'][0] + assert 'weekm' not in data['data']['users'][1] + assert 'weekm' not in data['data']['users'][2] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -250,12 +329,12 @@ def test_it_gets_users_list_with_workouts( def test_it_gets_first_page_on_users_list( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -279,12 +358,12 @@ def test_it_gets_first_page_on_users_list( def test_it_gets_next_page_on_users_list( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -307,12 +386,12 @@ def test_it_gets_next_page_on_users_list( def test_it_gets_empty_next_page_on_users_list( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -335,12 +414,12 @@ def test_it_gets_empty_next_page_on_users_list( def test_it_gets_user_list_with_2_per_page( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -363,12 +442,12 @@ def test_it_gets_user_list_with_2_per_page( def test_it_gets_next_page_on_user_list_with_2_per_page( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -389,10 +468,10 @@ def test_it_gets_next_page_on_user_list_with_2_per_page( } def test_it_gets_users_list_ordered_by_username( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -404,8 +483,8 @@ def test_it_gets_users_list_ordered_by_username( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'sam' in data['data']['users'][0]['username'] - assert 'test' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -416,10 +495,10 @@ def test_it_gets_users_list_ordered_by_username( } def test_it_gets_users_list_ordered_by_username_ascending( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -431,8 +510,8 @@ def test_it_gets_users_list_ordered_by_username_ascending( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'sam' in data['data']['users'][0]['username'] - assert 'test' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -443,10 +522,10 @@ def test_it_gets_users_list_ordered_by_username_ascending( } def test_it_gets_users_list_ordered_by_username_descending( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -459,8 +538,8 @@ def test_it_gets_users_list_ordered_by_username_descending( assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] - assert 'test' in data['data']['users'][1]['username'] - assert 'sam' in data['data']['users'][2]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -643,14 +722,14 @@ def test_it_gets_users_list_ordered_by_admin_rights_descending( def test_it_gets_users_list_ordered_by_workouts_count( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -662,7 +741,7 @@ def test_it_gets_users_list_ordered_by_workouts_count( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'test' in data['data']['users'][0]['username'] + assert 'admin' in data['data']['users'][0]['username'] assert 0 == data['data']['users'][0]['nb_workouts'] assert 'sam' in data['data']['users'][1]['username'] assert 0 == data['data']['users'][1]['nb_workouts'] @@ -679,14 +758,14 @@ def test_it_gets_users_list_ordered_by_workouts_count( def test_it_gets_users_list_ordered_by_workouts_count_ascending( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -698,7 +777,7 @@ def test_it_gets_users_list_ordered_by_workouts_count_ascending( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'test' in data['data']['users'][0]['username'] + assert 'admin' in data['data']['users'][0]['username'] assert 0 == data['data']['users'][0]['nb_workouts'] assert 'sam' in data['data']['users'][1]['username'] assert 0 == data['data']['users'][1]['nb_workouts'] @@ -715,14 +794,14 @@ def test_it_gets_users_list_ordered_by_workouts_count_ascending( def test_it_gets_users_list_ordered_by_workouts_count_descending( self, app: Flask, - user_1: User, + user_1_admin: User, user_2: User, user_3: User, sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -736,7 +815,7 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] assert 1 == data['data']['users'][0]['nb_workouts'] - assert 'test' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][1]['username'] assert 0 == data['data']['users'][1]['nb_workouts'] assert 'sam' in data['data']['users'][2]['username'] assert 0 == data['data']['users'][2]['nb_workouts'] @@ -749,10 +828,10 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( } def test_it_gets_users_list_filtering_on_username( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -774,10 +853,10 @@ def test_it_gets_users_list_filtering_on_username( } def test_it_returns_empty_users_list_filtering_on_username( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -798,10 +877,10 @@ def test_it_returns_empty_users_list_filtering_on_username( } def test_it_users_list_with_complex_query( - self, app: Flask, user_1: User, user_2: User, user_3: User + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( @@ -813,7 +892,7 @@ def test_it_users_list_with_complex_query( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 1 - assert 'sam' in data['data']['users'][0]['username'] + assert 'admin' in data['data']['users'][0]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': True, diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 5e961c3ce..ac040fe94 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Dict from unittest.mock import Mock, patch import pytest @@ -10,27 +11,22 @@ NotExistingFollowRequestError, ) from fittrackee.users.models import FollowRequest, User, UserSportPreference +from fittrackee.users.roles import UserRole from fittrackee.workouts.models import Sport, Workout class TestUserModel: - def test_user_model(self, app: Flask, user_1: User) -> None: - assert '' == str(user_1) - - serialized_user = user_1.serialize() + @staticmethod + def assert_serialized_used(serialized_user: Dict) -> None: assert 'test' == serialized_user['username'] assert 'created_at' in serialized_user assert serialized_user['admin'] is False assert serialized_user['first_name'] is None assert serialized_user['last_name'] is None - assert serialized_user['imperial_units'] is False assert serialized_user['bio'] is None assert serialized_user['location'] is None assert serialized_user['birth_date'] is None assert serialized_user['picture'] is False - assert serialized_user['timezone'] is None - assert serialized_user['weekm'] is False - assert serialized_user['language'] is None assert serialized_user['nb_sports'] == 0 assert serialized_user['nb_workouts'] == 0 assert serialized_user['records'] == [] @@ -38,6 +34,54 @@ def test_user_model(self, app: Flask, user_1: User) -> None: assert serialized_user['total_distance'] == 0 assert serialized_user['total_duration'] == '0:00:00' + def test_user_model_as_auth_user(self, app: Flask, user_1: User) -> None: + assert '' == str(user_1) + + serialized_user = user_1.serialize(role=UserRole.AUTH_USER) + self.assert_serialized_used(serialized_user) + assert 'test@test.com' == serialized_user['email'] + assert serialized_user['imperial_units'] is False + assert serialized_user['language'] is None + assert serialized_user['timezone'] is None + assert serialized_user['weekm'] is False + + def test_user_model_as_admin(self, app: Flask, user_1: User) -> None: + assert '' == str(user_1) + + serialized_user = user_1.serialize(role=UserRole.ADMIN) + self.assert_serialized_used(serialized_user) + assert 'test@test.com' == serialized_user['email'] + assert 'imperial_units' not in serialized_user + assert 'language' not in serialized_user + assert 'timezone' not in serialized_user + assert 'weekm' not in serialized_user + + def test_user_model_as_regular_user( + self, app: Flask, user_1: User + ) -> None: + assert '' == str(user_1) + + serialized_user = user_1.serialize(role=UserRole.USER) + self.assert_serialized_used(serialized_user) + assert 'email' not in serialized_user + assert 'imperial_units' not in serialized_user + assert 'language' not in serialized_user + assert 'timezone' not in serialized_user + assert 'weekm' not in serialized_user + + def test_user_model_when_no_role_provided( + self, app: Flask, user_1: User + ) -> None: + assert '' == str(user_1) + + serialized_user = user_1.serialize() + self.assert_serialized_used(serialized_user) + assert 'email' not in serialized_user + assert 'imperial_units' not in serialized_user + assert 'language' not in serialized_user + assert 'timezone' not in serialized_user + assert 'weekm' not in serialized_user + def test_encode_auth_token(self, app: Flask, user_1: User) -> None: auth_token = user_1.encode_auth_token(user_1.id) assert isinstance(auth_token, str) diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 35e1c558e..618a53afb 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -27,6 +27,7 @@ from .decorators import authenticate from .models import User, UserSportPreference +from .roles import UserRole from .utils.controls import check_passwords, register_controls from .utils.token import decode_user_token @@ -384,7 +385,10 @@ def get_authenticated_user_profile( - invalid token, please log in again """ - return {'status': 'success', 'data': auth_user.serialize()} + return { + 'status': 'success', + 'data': auth_user.serialize(role=UserRole.AUTH_USER), + } @auth_blueprint.route('/auth/profile/edit', methods=['POST']) @@ -541,7 +545,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: return { 'status': 'success', 'message': 'user profile updated', - 'data': auth_user.serialize(), + 'data': auth_user.serialize(role=UserRole.AUTH_USER), } # handler errors @@ -680,7 +684,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: return { 'status': 'success', 'message': 'user preferences updated', - 'data': auth_user.serialize(), + 'data': auth_user.serialize(role=UserRole.AUTH_USER), } # handler errors diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index f0a9c676c..f0e5a7288 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -20,6 +20,7 @@ FollowRequestAlreadyRejectedError, NotExistingFollowRequestError, ) +from .roles import UserRole from .utils.token import decode_user_token, get_user_token @@ -340,7 +341,7 @@ def create_actor(self) -> None: self.actor_id = actor.id db.session.commit() - def serialize(self) -> Dict: + def serialize(self, role: Optional[UserRole] = None) -> Dict: sports = [] total = (0, '0:00:00') if self.workouts_count > 0: # type: ignore @@ -358,9 +359,8 @@ def serialize(self) -> Dict: .filter(Workout.user_id == self.id) .first() ) - return { + serialized_user = { 'username': self.username, - 'email': self.email, 'created_at': self.created_at, 'admin': self.admin, 'first_name': self.first_name, @@ -369,9 +369,6 @@ def serialize(self) -> Dict: 'location': self.location, 'birth_date': self.birth_date, 'picture': self.picture is not None, - 'timezone': self.timezone, - 'weekm': self.weekm, - 'language': self.language, 'nb_sports': len(sports), 'nb_workouts': self.workouts_count, 'records': [record.serialize() for record in self.records], @@ -380,9 +377,24 @@ def serialize(self) -> Dict: ], 'total_distance': float(total[0]), 'total_duration': str(total[1]), - 'imperial_units': self.imperial_units, } + if role in [UserRole.AUTH_USER, UserRole.ADMIN]: + serialized_user['email'] = self.email + + if role == UserRole.AUTH_USER: + serialized_user = { + **serialized_user, + **{ + 'timezone': self.timezone, + 'weekm': self.weekm, + 'language': self.language, + 'imperial_units': self.imperial_units, + }, + } + + return serialized_user + class UserSportPreference(BaseModel): __tablename__ = 'users_sports_preferences' diff --git a/fittrackee/users/roles.py b/fittrackee/users/roles.py new file mode 100644 index 000000000..3cfb7b75b --- /dev/null +++ b/fittrackee/users/roles.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class UserRole(Enum): + ADMIN = 'admin' + AUTH_USER = 'auth_user' + USER = 'user' diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index f53170efd..db05433e4 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -29,6 +29,7 @@ UserNotFoundException, ) from .models import User, UserSportPreference +from .roles import UserRole from .utils.admin import set_admin_rights users_blueprint = Blueprint('users', __name__) @@ -223,9 +224,10 @@ def get_users(auth_user: User) -> Dict: .paginate(page, per_page, False) ) users = users_pagination.items + role = UserRole.ADMIN if auth_user.admin else UserRole.USER return { 'status': 'success', - 'data': {'users': [user.serialize() for user in users]}, + 'data': {'users': [user.serialize(role) for user in users]}, 'pagination': { 'has_next': users_pagination.has_next, 'has_prev': users_pagination.has_prev, @@ -340,10 +342,11 @@ def get_single_user( """ try: user = User.query.filter_by(username=user_name).first() + role = UserRole.ADMIN if auth_user.admin else UserRole.USER if user: return { 'status': 'success', - 'data': {'users': [user.serialize()]}, + 'data': {'users': [user.serialize(role=role)]}, } except ValueError: pass @@ -507,7 +510,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: db.session.commit() return { 'status': 'success', - 'data': {'users': [user.serialize()]}, + 'data': {'users': [user.serialize(role=UserRole.ADMIN)]}, } except exc.StatementError as e: return handle_error_and_return_response(e, db=db) From 151f604d6eb3ebb166f669d91dd53af9c66f7a2c Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 2 Dec 2021 14:24:39 +0100 Subject: [PATCH 052/238] API - add endpoints to get followers/following users --- .../tests/users/test_users_followers_api.py | 497 ++++++++++++++++++ fittrackee/users/users.py | 72 ++- 2 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 fittrackee/tests/users/test_users_followers_api.py diff --git a/fittrackee/tests/users/test_users_followers_api.py b/fittrackee/tests/users/test_users_followers_api.py new file mode 100644 index 000000000..e9fab673f --- /dev/null +++ b/fittrackee/tests/users/test_users_followers_api.py @@ -0,0 +1,497 @@ +import json +from datetime import datetime +from typing import List +from unittest.mock import patch + +from flask import Flask + +from fittrackee.users.models import FollowRequest, User + +from ..test_case_mixins import ApiTestCaseMixin +from ..utils import random_string + + +class FollowersAsUserTestCase(ApiTestCaseMixin): + @staticmethod + def approves_follow_requests( + follows_requests: List[FollowRequest], + ) -> None: + for follows_request in follows_requests: + follows_request.is_approved = True + follows_request.updated_at = datetime.utcnow() + + +class TestFollowersAsUser(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{random_string()}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_followers( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['followers'] == [] + + def test_it_returns_followers( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_2_to_user_1, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['followers']) == 2 + assert data['data']['followers'][0]['username'] == user_3.username + assert data['data']['followers'][1]['username'] == user_2.username + assert 'email' not in data['data']['followers'][0] + assert 'email' not in data['data']['followers'][1] + + +class TestFollowersAsAdmin(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{random_string()}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_followers( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_1_admin.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['followers'] == [] + + def test_it_returns_followers( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['followers']) == 2 + assert data['data']['followers'][0]['email'] == user_3.email + assert data['data']['followers'][1]['email'] == user_1.email + + +class TestFollowersPagination(FollowersAsUserTestCase): + def test_it_returns_pagination_info( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + @patch('fittrackee.users.users.USER_PER_PAGE', 1) + def test_it_returns_first_page_on_followers_list( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.users.USER_PER_PAGE', 1) + def test_it_returns_page_2_on_followers_list( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers?page=2', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + +class TestFollowingAsUser(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{random_string()}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_following_users( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['following'] == [] + + def test_it_returns_following_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['following']) == 2 + assert data['data']['following'][0]['username'] == user_1.username + assert data['data']['following'][1]['username'] == user_2.username + assert 'email' not in data['data']['following'][0] + assert 'email' not in data['data']['following'][1] + + +class TestFollowingAsAdmin(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{random_string()}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_following_users( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_1_admin.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data']['following'] == [] + + def test_it_returns_following_users( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['following']) == 2 + assert data['data']['following'][0]['email'] == user_1.email + assert data['data']['following'][1]['email'] == user_2.email + + +class TestFollowingPagination(FollowersAsUserTestCase): + def test_it_returns_pagination_info( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + @patch('fittrackee.users.users.USER_PER_PAGE', 1) + def test_it_returns_first_page_on_following_list( + self, + app: Flask, + user_1: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_1, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.users.USER_PER_PAGE', 1) + def test_it_returns_page_2_on_followers_list( + self, + app: Flask, + user_1: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_1, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following?page=2', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index db05433e4..a81e650c5 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -28,7 +28,7 @@ FollowRequestAlreadyRejectedError, UserNotFoundException, ) -from .models import User, UserSportPreference +from .models import FollowRequest, User, UserSportPreference from .roles import UserRole from .utils.admin import set_admin_rights @@ -633,3 +633,73 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: except FollowRequestAlreadyRejectedError: return ForbiddenErrorResponse() return successful_response_dict + + +@users_blueprint.route('/users//followers', methods=['GET']) +@authenticate +def get_followers( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + params = request.args.copy() + page = int(params.get('page', 1)) + user = User.query.filter_by(username=user_name).first() + if not user: + return UserNotFoundErrorResponse() + + followers_pagination = user.followers.order_by( + FollowRequest.updated_at.desc() + ).paginate(page, USER_PER_PAGE, False) + + return { + 'status': 'success', + 'data': { + 'followers': [ + follower.serialize( + role=UserRole.ADMIN if auth_user.admin else UserRole.USER + ) + for follower in followers_pagination.items + ] + }, + 'pagination': { + 'has_next': followers_pagination.has_next, + 'has_prev': followers_pagination.has_prev, + 'page': followers_pagination.page, + 'pages': followers_pagination.pages, + 'total': followers_pagination.total, + }, + } + + +@users_blueprint.route('/users//following', methods=['GET']) +@authenticate +def get_following( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + params = request.args.copy() + page = int(params.get('page', 1)) + user = User.query.filter_by(username=user_name).first() + if not user: + return UserNotFoundErrorResponse() + + following_pagination = user.following.order_by( + FollowRequest.updated_at.desc() + ).paginate(page, USER_PER_PAGE, False) + + return { + 'status': 'success', + 'data': { + 'following': [ + following.serialize( + role=UserRole.ADMIN if auth_user.admin else UserRole.USER + ) + for following in following_pagination.items + ] + }, + 'pagination': { + 'has_next': following_pagination.has_next, + 'has_prev': following_pagination.has_prev, + 'page': following_pagination.page, + 'pages': following_pagination.pages, + 'total': following_pagination.total, + }, + } From fe20ac4a69b28e0c3f57c3bb6b467f16bc0f7873 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 2 Dec 2021 14:43:16 +0100 Subject: [PATCH 053/238] API - add follower/following count to user serializer --- fittrackee/tests/users/test_users_api.py | 2 ++ fittrackee/tests/users/test_users_model.py | 26 ++++++++++++++++++++++ fittrackee/users/models.py | 2 ++ 3 files changed, 30 insertions(+) diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 4dddedf7d..05a32ef8e 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -25,6 +25,8 @@ def assert_user(user_dict: Dict, user: User) -> None: assert user_dict['birth_date'] is None assert user_dict['bio'] is None assert user_dict['location'] is None + assert user_dict['followers'] == 0 + assert user_dict['following'] == 0 assert 'imperial_units' not in user_dict assert 'language' not in user_dict assert 'timezone' not in user_dict diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index ac040fe94..f2746c6b9 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -22,6 +22,8 @@ def assert_serialized_used(serialized_user: Dict) -> None: assert 'created_at' in serialized_user assert serialized_user['admin'] is False assert serialized_user['first_name'] is None + assert serialized_user['followers'] == 0 + assert serialized_user['following'] == 0 assert serialized_user['last_name'] is None assert serialized_user['bio'] is None assert serialized_user['location'] is None @@ -114,6 +116,30 @@ def test_it_returns_user_records( ) assert serialized_user['records'][0]['workout_date'] + def test_it_returns_followers_count( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + + serialized_user = user_1.serialize() + assert serialized_user['followers'] == 1 + + def test_it_returns_following_count( + self, + app: Flask, + user_1: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + + serialized_user = user_1.serialize() + assert serialized_user['following'] == 1 + class TestUserSportModel: def test_user_model( diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index f0e5a7288..1e5cf66a3 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -366,6 +366,8 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: 'first_name': self.first_name, 'last_name': self.last_name, 'bio': self.bio, + 'followers': self.followers.count(), + 'following': self.following.count(), 'location': self.location, 'birth_date': self.birth_date, 'picture': self.picture is not None, From df03dcc2ff601b2092eabeb6a2fa3c84cc4e2524 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 2 Dec 2021 16:56:43 +0100 Subject: [PATCH 054/238] API - add decorator to get local actor --- fittrackee/federation/decorators.py | 21 ++++++++++++++++++++- fittrackee/federation/federation.py | 27 ++++++++++----------------- fittrackee/federation/inbox.py | 18 +++--------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/fittrackee/federation/decorators.py b/fittrackee/federation/decorators.py index ca2a3ca91..673a5c634 100644 --- a/fittrackee/federation/decorators.py +++ b/fittrackee/federation/decorators.py @@ -7,9 +7,10 @@ from fittrackee.responses import ( DisabledFederationErrorResponse, InternalServerErrorResponse, + UserNotFoundErrorResponse, ) -from .models import Domain +from .models import Actor, Domain def federation_required(f: Callable) -> Callable: @@ -26,3 +27,21 @@ def decorated_function(*args: Any, **kwargs: Any) -> Callable: return f(app_domain, *args, **kwargs) return decorated_function + + +def get_local_actor_from_username(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Callable: + app_domain = args[0] + preferred_username = kwargs.get('preferred_username') + if not preferred_username: + return UserNotFoundErrorResponse() + actor = Actor.query.filter_by( + preferred_username=preferred_username, + domain_id=app_domain.id, + ).first() + if not actor: + return UserNotFoundErrorResponse() + return f(actor, *args, **kwargs) + + return decorated_function diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 84d691325..4d2929afc 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -4,15 +4,11 @@ from fittrackee.federation.exceptions import RemoteActorException from fittrackee.federation.utils_user import create_remote_user -from fittrackee.responses import ( - HttpResponse, - InvalidPayloadErrorResponse, - UserNotFoundErrorResponse, -) +from fittrackee.responses import HttpResponse, InvalidPayloadErrorResponse from fittrackee.users.decorators import authenticate from fittrackee.users.models import User -from .decorators import federation_required +from .decorators import federation_required, get_local_actor_from_username from .inbox import inbox from .models import Actor, Domain @@ -23,7 +19,10 @@ '/user/', methods=['GET'] ) @federation_required -def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: +@get_local_actor_from_username +def get_actor( + local_actor: Actor, app_domain: Domain, preferred_username: str +) -> HttpResponse: """ Get a local actor @@ -73,15 +72,8 @@ def get_actor(app_domain: Domain, preferred_username: str) -> HttpResponse: :statuscode 404: user does not exist """ - actor = Actor.query.filter_by( - preferred_username=preferred_username, - domain_id=app_domain.id, - ).first() - if not actor: - return UserNotFoundErrorResponse() - return HttpResponse( - response=actor.serialize(), + response=local_actor.serialize(), content_type='application/jrd+json; charset=utf-8', ) @@ -171,8 +163,9 @@ def remote_actor( '/user//inbox', methods=['POST'] ) @federation_required +@get_local_actor_from_username def user_inbox( - app_domain: Domain, preferred_username: str + local_actor: Actor, app_domain: Domain, preferred_username: str ) -> Union[Dict, HttpResponse]: """ Post an activity to user inbox @@ -205,4 +198,4 @@ def user_inbox( :statuscode 404: user does not exist """ - return inbox(request, app_domain, preferred_username) + return inbox(request) diff --git a/fittrackee/federation/inbox.py b/fittrackee/federation/inbox.py index 1e8c53d1e..b5bad5350 100644 --- a/fittrackee/federation/inbox.py +++ b/fittrackee/federation/inbox.py @@ -1,6 +1,6 @@ from datetime import datetime from json import dumps -from typing import Dict, Optional, Union +from typing import Dict, Union from urllib.parse import urlparse import requests @@ -11,11 +11,10 @@ HttpResponse, InvalidPayloadErrorResponse, UnauthorizedErrorResponse, - UserNotFoundErrorResponse, ) from .exceptions import InvalidSignatureException -from .models import Actor, Domain +from .models import Actor from .signature import ( VALID_DATE_FORMAT, SignatureVerification, @@ -26,18 +25,7 @@ from .utils import is_invalid_activity_data -def inbox( - request: Request, app_domain: Domain, username: Optional[str] -) -> Union[Dict, HttpResponse]: - # if user inbox - if username: - recipient = Actor.query.filter_by( - preferred_username=username, - domain_id=app_domain.id, - ).first() - if not recipient: - return UserNotFoundErrorResponse() - +def inbox(request: Request) -> Union[Dict, HttpResponse]: activity_data = request.get_json() if not activity_data or is_invalid_activity_data(activity_data): return InvalidPayloadErrorResponse() From 137dc0a31e984fd35e532530fb1658ec9fb354c5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 17:14:25 +0100 Subject: [PATCH 055/238] API - add endpoints for federation following/followers URLs wip --- fittrackee/federation/collections.py | 47 ++ fittrackee/federation/federation.py | 189 +++++++- .../federation/test_federation_federation.py | 402 +++++++++++++++++- .../fixtures/fixtures_federation_users.py | 26 ++ 4 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 fittrackee/federation/collections.py diff --git a/fittrackee/federation/collections.py b/fittrackee/federation/collections.py new file mode 100644 index 000000000..0a5e281aa --- /dev/null +++ b/fittrackee/federation/collections.py @@ -0,0 +1,47 @@ +from typing import Dict + +from flask_sqlalchemy import BaseQuery, Pagination + +CONTEXT = 'https://www.w3.org/ns/activitystreams' + + +class OrderedCollection: + def __init__(self, url: str, base_query: BaseQuery) -> None: + self._url = url + self._base_query = base_query + self._type = 'OrderedCollection' + + def serialize(self) -> Dict: + return { + '@context': CONTEXT, + 'id': self._url, + 'first': f'{self._url}?page=1', + 'totalItems': self._base_query.count(), + 'type': self._type, + } + + +class OrderedCollectionPage: + def __init__(self, url: str, pagination: Pagination) -> None: + self._url = url + self._pagination = pagination + self._type = 'OrderedCollectionPage' + + def serialize(self) -> Dict: + ordered_items = [ + user.actor.activitypub_id for user in self._pagination.items + ] + page = self._pagination.page + collection_page_dict = { + '@context': CONTEXT, + 'id': f'{self._url}?page={page}', + 'orderedItems': ordered_items, + 'partOf': self._url, + 'totalItems': self._pagination.total, + 'type': self._type, + } + if self._pagination.has_next and self._pagination.total > 0: + collection_page_dict['next'] = f'{self._url}?page={page + 1}' + if self._pagination.has_prev and self._pagination.total > 0: + collection_page_dict['prev'] = f'{self._url}?page={page - 1}' + return collection_page_dict diff --git a/fittrackee/federation/federation.py b/fittrackee/federation/federation.py index 4d2929afc..3b3fc38c9 100644 --- a/fittrackee/federation/federation.py +++ b/fittrackee/federation/federation.py @@ -4,10 +4,15 @@ from fittrackee.federation.exceptions import RemoteActorException from fittrackee.federation.utils_user import create_remote_user -from fittrackee.responses import HttpResponse, InvalidPayloadErrorResponse +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + handle_error_and_return_response, +) from fittrackee.users.decorators import authenticate -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User +from .collections import OrderedCollection, OrderedCollectionPage from .decorators import federation_required, get_local_actor_from_username from .inbox import inbox from .models import Actor, Domain @@ -15,6 +20,9 @@ ap_federation_blueprint = Blueprint('ap_federation', __name__) +USERS_PER_PAGE = 10 + + @ap_federation_blueprint.route( '/user/', methods=['GET'] ) @@ -199,3 +207,180 @@ def user_inbox( """ return inbox(request) + + +def get_relationships( + local_actor: Actor, relation: str +) -> Union[Dict, HttpResponse]: + params = request.args.copy() + page = params.get('page') + + if relation == 'followers': + relations_object = local_actor.user.followers + url = local_actor.followers_url + else: + relations_object = local_actor.user.following + url = local_actor.following_url + + if page is None: + collection = OrderedCollection(url, relations_object) + return collection.serialize() + + try: + paginated_relations = relations_object.order_by( + FollowRequest.updated_at.desc() + ).paginate(int(page), USERS_PER_PAGE, False) + collection_page = OrderedCollectionPage(url, paginated_relations) + return collection_page.serialize() + except ValueError as e: + return handle_error_and_return_response(e) + + +@ap_federation_blueprint.route( + '/user//followers', methods=['GET'] +) +@federation_required +@get_local_actor_from_username +def user_followers( + local_actor: Actor, app_domain: Domain, preferred_username: str +) -> Union[Dict, HttpResponse]: + """ + Get local actor followers + + - ordered collection + + **Example request**: + + .. sourcecode:: http + + GET /federation/user/sam/followers HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": "https://www.w3.org/ns/activitystreams", + "first": "https://example.com/federation/user/Sam/followers?page=1", + "id": "https://example.com/federation/user/Sam/followers", + "totalItems": 1, + "type": "OrderedCollection" + } + + - ordered collection page + + **Example request**: + + .. sourcecode:: http + + GET /federation/user/sam/followers?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/federation/user/Sam/followers?page=1", + "orderedItems": [ + "https://another-instance.com/users/admin" + ], + "partOf": "https://example.com/federation/user/Sam/followers", + "totalItems": 1, + "type": "OrderedCollectionPage" + } + + :param string preferred_username: actor preferred username + + :query integer page: page if using pagination (default: 1) + + :statuscode 200: success + :statuscode 403: error, federation is disabled for this instance + :statuscode 404: user does not exist + :statuscode 500: error, please try again or contact the administrator + + """ + return get_relationships(local_actor, relation='followers') + + +@ap_federation_blueprint.route( + '/user//following', methods=['GET'] +) +@federation_required +@get_local_actor_from_username +def user_following( + local_actor: Actor, app_domain: Domain, preferred_username: str +) -> Union[Dict, HttpResponse]: + """ + Get local actor following + + - ordered collection + + **Example request**: + + .. sourcecode:: http + + GET /federation/user/sam/following HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": "https://www.w3.org/ns/activitystreams", + "first": "https://example.com/federation/user/Sam/following?page=1", + "id": "https://example.com/federation/user/Sam/following", + "totalItems": 1, + "type": "OrderedCollection" + } + + - ordered collection page + + **Example request**: + + .. sourcecode:: http + + GET /federation/user/sam/following?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/jrd+json; charset=utf-8 + + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/federation/user/Sam/following?page=1", + "orderedItems": [ + "https://another-instance.com/users/admin" + ], + "partOf": "https://example.com/federation/user/Sam/following", + "totalItems": 1, + "type": "OrderedCollectionPage" + } + + :param string preferred_username: actor preferred username + + :query integer page: page if using pagination (default: 1) + + :statuscode 200: success + :statuscode 403: error, federation is disabled for this instance + :statuscode 404: user does not exist + :statuscode 500: error, please try again or contact the administrator + + """ + return get_relationships(local_actor, relation='following') diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index df76c3a83..c43d86046 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -6,7 +6,7 @@ from fittrackee.federation.exceptions import ActorNotFoundException from fittrackee.federation.models import Actor -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User from ...test_case_mixins import ApiTestCaseMixin from ...utils import RandomActor @@ -174,3 +174,403 @@ def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( assert response.status_code == 200 data = json.loads(response.data.decode()) assert data == remote_user_object + + +class TestLocalActorFollowers(ApiTestCaseMixin): + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get( + f'/federation/user/{uuid4().hex}/followers', + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, federation is disabled for this instance' + in data['message'] + ) + + def test_it_returns_404_if_actor_does_not_exist( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{uuid4().hex}/followers', + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + def test_it_returns_ordered_collection_without_follower( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor_1.followers_url, + 'type': 'OrderedCollection', + 'totalItems': 0, + 'first': f'{actor_1.followers_url}?page=1', + } + + def test_it_returns_first_page_without_followers( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.followers_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': actor_1.followers_url, + 'orderedItems': [], + } + + def test_it_returns_error_if_page_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=un', + ) + + assert response.status_code == 500 + data = json.loads(response.data.decode()) + assert data == { + 'message': 'error, please try again or contact the administrator', + 'status': 'error', + } + + def test_it_does_not_return_error_when_page_that_does_not_return_followers( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=2', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.followers_url}?page=2', + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': actor_1.followers_url, + 'orderedItems': [], + } + + def test_it_returns_first_page_with_followers( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_2.user) + actor_1.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.followers_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_1.followers_url, + 'orderedItems': [actor_3.activitypub_id, actor_2.activitypub_id], + } + + @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) + def test_it_returns_first_page_with_next_page_link( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_2.user) + actor_1.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.followers_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_1.followers_url, + 'next': f'{actor_1.followers_url}?page=2', + 'orderedItems': [ + actor_3.activitypub_id, + ], + } + + @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) + def test_it_returns_next_page( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_2.user) + actor_1.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/followers?page=2', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.followers_url}?page=2', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_1.followers_url, + 'prev': f'{actor_1.followers_url}?page=1', + 'orderedItems': [ + actor_2.activitypub_id, + ], + } + + +class TestLocalActorFollowing(ApiTestCaseMixin): + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get( + f'/federation/user/{uuid4().hex}/following', + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert 'error' in data['status'] + assert ( + 'error, federation is disabled for this instance' + in data['message'] + ) + + def test_it_returns_404_if_actor_does_not_exist( + self, app_with_federation: Flask + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{uuid4().hex}/following', + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + def test_it_returns_ordered_collection_without_following( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/following', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor_1.following_url, + 'type': 'OrderedCollection', + 'totalItems': 0, + 'first': f'{actor_1.following_url}?page=1', + } + + def test_it_returns_first_page_without_following( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/following?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.following_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': actor_1.following_url, + 'orderedItems': [], + } + + def test_it_returns_error_if_page_is_invalid( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/following?page=un', + ) + + assert response.status_code == 500 + data = json.loads(response.data.decode()) + assert data == { + 'message': 'error, please try again or contact the administrator', + 'status': 'error', + } + + def test_it_does_not_return_error_when_page_that_does_not_return_following( + self, app_with_federation: Flask, actor_1: Actor + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_1.preferred_username}/following?page=2', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_1.following_url}?page=2', + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': actor_1.following_url, + 'orderedItems': [], + } + + def test_it_returns_first_page_with_following_users( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_3_to_user_2_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_3.user) + actor_2.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_3.preferred_username}/following?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_3.following_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_3.following_url, + 'orderedItems': [actor_2.activitypub_id, actor_1.activitypub_id], + } + + @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) + def test_it_returns_first_page_with_next_page_link( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_3_to_user_2_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_3.user) + actor_2.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_3.preferred_username}/following?page=1', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_3.following_url}?page=1', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_3.following_url, + 'next': f'{actor_3.following_url}?page=2', + 'orderedItems': [ + actor_2.activitypub_id, + ], + } + + @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) + def test_it_returns_next_page( + self, + app_with_federation: Flask, + actor_1: Actor, + actor_2: Actor, + actor_3: Actor, + follow_request_from_user_3_to_user_2_with_federation: FollowRequest, + follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + ) -> None: + actor_1.user.approves_follow_request_from(actor_3.user) + actor_2.user.approves_follow_request_from(actor_3.user) + client = app_with_federation.test_client() + + response = client.get( + f'/federation/user/{actor_3.preferred_username}/following?page=2', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data == { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{actor_3.following_url}?page=2', + 'type': 'OrderedCollectionPage', + 'totalItems': 2, + 'partOf': actor_3.following_url, + 'prev': f'{actor_3.following_url}?page=1', + 'orderedItems': [ + actor_1.activitypub_id, + ], + } diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py index 47c866829..e363089d0 100644 --- a/fittrackee/tests/fixtures/fixtures_federation_users.py +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -31,6 +31,32 @@ def follow_request_from_user_2_to_user_1_with_federation( return follow_request +@pytest.fixture() +def follow_request_from_user_3_to_user_1_with_federation( + actor_1: Actor, + actor_3: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=actor_3.user.id, followed_user_id=actor_1.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + +@pytest.fixture() +def follow_request_from_user_3_to_user_2_with_federation( + actor_2: Actor, + actor_3: Actor, +) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=actor_3.user.id, followed_user_id=actor_2.user.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + @pytest.fixture() def follow_request_from_remote_user_to_user_1( actor_1: Actor, From 5c7c9918e7cdfa2b42a8ba39a2653f3358e487c5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Feb 2022 11:45:06 +0100 Subject: [PATCH 056/238] API - refacto --- fittrackee/application/app_config.py | 9 +- fittrackee/tests/users/test_users_api.py | 4 +- .../tests/users/test_users_followers_api.py | 8 +- fittrackee/users/follow_requests.py | 211 +++++++++++++ fittrackee/users/users.py | 277 +++++++++++++++--- 5 files changed, 460 insertions(+), 49 deletions(-) diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index b11529e16..f209029cb 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -39,6 +39,7 @@ def get_application_config() -> Union[Dict, HttpResponse]: { "data": { + "federation_enabled": false, "gpx_limit_import": 10, "is_registration_enabled": false, "max_single_file_size": 1048576, @@ -87,17 +88,21 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: { "data": { + "federation_enabled": true, "gpx_limit_import": 10, "is_registration_enabled": true, "max_single_file_size": 1048576, "max_zip_file_size": 10485760, - "max_users": 10 + "max_users": 10, + "map_attribution": "© OpenStreetMap contributors" + "version": "0.5.1" }, "status": "success" } + : Dict: + """ + Get follow requests to process, received by authenticated user. + + **Example requests**: + + - without parameters + + .. sourcecode:: http + + GET /api/follow_requests/ HTTP/1.1 + + - with some query parameters + + .. sourcecode:: http + + GET /api/follow_requests?page=1&order=desc HTTP/1.1 + + **Example responses**: + + - if federation is disabled + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "follow_requests": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "last_name": null, + "location": null, + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "Sam" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + - if federation is enabled + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "follow_requests": [ + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "endpoints": { + "sharedInbox": "https://example.com/federation/inbox" + }, + "followers": "https://example.com/federation/user/Sam/followers", + "following": "https://example.com/federation/user/Sam/following", + "id": "https://example.com/federation/user/Sam", + "inbox": "https://example.com/federation/user/Sam/inbox", + "manuallyApprovesFollowers": true, + "name": "Sam", + "outbox": "https://example.com/federation/user/Sam/outbox", + "preferredUsername": "Sam", + "publicKey": { + "id": "https://example.com/federation/user/Sam#main-key", + "owner": "https://example.com/federation/user/Sam", + "publicKeyPem": "[PUBLIC KEY]" + }, + "type": "Person", + "url": "https://example.com/users/Sam" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + :query integer page: page if using pagination (default: 1) + :query integer per_page: number of follow requests per page + (default: 10, max: 50) + :query string order: sorting order (default: ``asc``) + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 500: + + """ params = request.args.copy() page = int(params.get('page', 1)) per_page = int(params.get('per_page', FOLLOW_REQUESTS_PER_PAGE)) @@ -108,6 +229,51 @@ def process_follow_request( def accept_follow_request( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: + """ + Accept a follow request. + + **Example requests**: + + - from local instance + + .. sourcecode:: http + + POST /api/follow_requests/Sam/accept HTTP/1.1 + + - from remote instance + + .. sourcecode:: http + + POST /api/follow_requests/sam@remote-instance.net/accept HTTP/1.1 + + **Example responses**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request from user 'Sam' is accepted.", + } + + :param string user_name: user name + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 400: + - Follow request from user 'user_name' already accepted. + :statuscode 404: + - user does not exist + - Follow request does not exist. + + """ return process_follow_request(auth_user, user_name, 'accept') @@ -118,4 +284,49 @@ def accept_follow_request( def reject_follow_request( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: + """ + Reject a follow request. + + **Example requests**: + + - from local instance + + .. sourcecode:: http + + POST /api/follow_requests/Sam/reject HTTP/1.1 + + - from remote instance + + .. sourcecode:: http + + POST /api/follow_requests/sam@remote-instance.net/reject HTTP/1.1 + + **Example responses**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request from user 'Sam' is rejected.", + } + + :param string user_name: user name + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 400: + - Follow request from user 'user_name' already rejected. + :statuscode 404: + - user does not exist + - Follow request does not exist. + + """ return process_follow_request(auth_user, user_name, 'reject') diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index a81e650c5..4a3d58edb 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -34,7 +34,7 @@ users_blueprint = Blueprint('users', __name__) -USER_PER_PAGE = 10 +USERS_PER_PAGE = 10 @users_blueprint.cli.command('set-admin') @@ -185,7 +185,7 @@ def get_users(auth_user: User) -> Dict: """ params = request.args.copy() page = int(params.get('page', 1)) - per_page = int(params.get('per_page', USER_PER_PAGE)) + per_page = int(params.get('per_page', USERS_PER_PAGE)) if per_page > 50: per_page = 50 order_by = params.get('order_by') @@ -613,6 +613,56 @@ def delete_user( @users_blueprint.route('/users//follow', methods=['POST']) @authenticate def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: + """ + Send a follow request to a user. + If federation is enabled, it sends a follow request to remote instance + if the targeted user is a remote user. + + **Example request**: + + - follow local user + + .. sourcecode:: http + + POST /api/users/john_doe/follow HTTP/1.1 + Content-Type: application/json + + - follow remote user + + .. sourcecode:: http + + POST /api/users/sam@remote-instance.net/follow HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request to user 'john_doe' is sent.", + } + + + :param string user_name: user name + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 403: + - you do not have permissions + :statuscode 404: + - user does not exist + :statuscode 500: error, please try again or contact the administrator + + """ successful_response_dict = { 'status': 'success', 'message': f"Follow request to user '{user_name}' is sent.", @@ -635,71 +685,216 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: return successful_response_dict -@users_blueprint.route('/users//followers', methods=['GET']) -@authenticate -def get_followers( - auth_user: User, user_name: str +def get_user_relationships( + auth_user: User, user_name: str, relation: str ) -> Union[Dict, HttpResponse]: params = request.args.copy() - page = int(params.get('page', 1)) + try: + page = int(params.get('page', 1)) + except ValueError: + page = 1 + user = User.query.filter_by(username=user_name).first() if not user: return UserNotFoundErrorResponse() - followers_pagination = user.followers.order_by( + relations_object = ( + user.followers if relation == 'followers' else user.following + ) + + paginated_relations = relations_object.order_by( FollowRequest.updated_at.desc() - ).paginate(page, USER_PER_PAGE, False) + ).paginate(page, USERS_PER_PAGE, False) return { 'status': 'success', 'data': { - 'followers': [ - follower.serialize( + relation: [ + user.serialize( role=UserRole.ADMIN if auth_user.admin else UserRole.USER ) - for follower in followers_pagination.items + for user in paginated_relations.items ] }, 'pagination': { - 'has_next': followers_pagination.has_next, - 'has_prev': followers_pagination.has_prev, - 'page': followers_pagination.page, - 'pages': followers_pagination.pages, - 'total': followers_pagination.total, + 'has_next': paginated_relations.has_next, + 'has_prev': paginated_relations.has_prev, + 'page': paginated_relations.page, + 'pages': paginated_relations.pages, + 'total': paginated_relations.total, }, } +@users_blueprint.route('/users//followers', methods=['GET']) +@authenticate +def get_followers( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Get user followers. + If the authenticate user has admin rights, it returns following users with + additional field 'email' + + **Example request**: + + - without parameters + + .. sourcecode:: http + + GET /api/users/sam/followers HTTP/1.1 + Content-Type: application/json + + - with page parameter + + .. sourcecode:: http + + GET /api/users/sam/followers?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "followers": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "last_name": null, + "location": null, + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "JohnDoe" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + :param string user_name: user name + + :query integer page: page if using pagination (default: 1) + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 403: + - you do not have permissions + :statuscode 404: + - user does not exist + + """ + return get_user_relationships(auth_user, user_name, 'followers') + + @users_blueprint.route('/users//following', methods=['GET']) @authenticate def get_following( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: - params = request.args.copy() - page = int(params.get('page', 1)) - user = User.query.filter_by(username=user_name).first() - if not user: - return UserNotFoundErrorResponse() + """ + Get user following. + If the authenticate user has admin rights, it returns following users with + additional field 'email' - following_pagination = user.following.order_by( - FollowRequest.updated_at.desc() - ).paginate(page, USER_PER_PAGE, False) + **Example request**: - return { - 'status': 'success', - 'data': { - 'following': [ - following.serialize( - role=UserRole.ADMIN if auth_user.admin else UserRole.USER - ) - for following in following_pagination.items - ] + - without parameters + + .. sourcecode:: http + + GET /api/users/sam/following HTTP/1.1 + Content-Type: application/json + + - with page parameter + + .. sourcecode:: http + + GET /api/users/sam/following?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "following": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "last_name": null, + "location": null, + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "JohnDoe" + } + ] }, - 'pagination': { - 'has_next': following_pagination.has_next, - 'has_prev': following_pagination.has_prev, - 'page': following_pagination.page, - 'pages': following_pagination.pages, - 'total': following_pagination.total, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 }, - } + "status": "success" + } + + :param string user_name: user name + + :query integer page: page if using pagination (default: 1) + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 403: + - you do not have permissions + :statuscode 404: + - user does not exist + + """ + return get_user_relationships(auth_user, user_name, 'following') From d8c44bb7f9254ab60b08c716602acd22bc49f545 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 17:17:05 +0100 Subject: [PATCH 057/238] API - tests fixtures refactoring --- .../federation/test_federation_activities.py | 123 +++++++------- .../federation/test_federation_federation.py | 154 ++++++++++-------- .../federation/test_federation_models.py | 36 ++-- .../federation/test_federation_nodeinfo.py | 14 +- .../test_federation_remote_inbox.py | 20 ++- .../federation/test_federation_signature.py | 36 ++-- .../test_federation_tasks_user_inbox.py | 32 ++-- .../federation/test_federation_utils_user.py | 6 +- .../federation/test_federation_webfinger.py | 15 +- .../federation/federation/test_users_inbox.py | 27 +-- .../federation/users/test_users_follow_api.py | 75 +++++---- .../users/test_users_follow_request_api.py | 149 ++++++++--------- .../federation/users/test_users_model.py | 45 +++-- fittrackee/tests/fixtures/fixtures_app.py | 8 +- .../tests/fixtures/fixtures_federation.py | 110 ++++--------- .../fixtures/fixtures_federation_users.py | 80 +-------- fittrackee/tests/fixtures/fixtures_users.py | 46 +++--- fittrackee/tests/utils.py | 11 ++ 18 files changed, 475 insertions(+), 512 deletions(-) diff --git a/fittrackee/tests/federation/federation/test_federation_activities.py b/fittrackee/tests/federation/federation/test_federation_activities.py index d9bf19a49..6aa85f5c4 100644 --- a/fittrackee/tests/federation/federation/test_federation_activities.py +++ b/fittrackee/tests/federation/federation/test_federation_activities.py @@ -17,7 +17,7 @@ FollowRequestAlreadyRejectedError, NotExistingFollowRequestError, ) -from fittrackee.users.models import FollowRequest +from fittrackee.users.models import FollowRequest, User from ...utils import RandomActor, random_string @@ -109,10 +109,10 @@ def generate_reject_activity( class TestFollowActivity(FollowRequestActivitiesTestCase): def test_it_raises_error_if_followed_actor_does_not_exist( - self, app_with_federation: Flask, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: follow_activity = self.generate_follow_activity( - follower_actor_id=remote_actor.activitypub_id + follower_actor_id=remote_user.actor.activitypub_id ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -127,12 +127,12 @@ def test_it_raises_error_if_followed_actor_does_not_exist( def test_it_creates_actor_if_remote_actor_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, random_actor: RandomActor, ) -> None: follow_activity = self.generate_follow_activity( follower_actor_id=random_actor.activitypub_id, - followed_actor=actor_1, + followed_actor=user_1.actor, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -153,14 +153,14 @@ def test_it_creates_actor_if_remote_actor_does_not_exist( def test_it_raises_error_if_follow_request_already_rejected( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: - actor_1.user.rejects_follow_request_from(remote_actor.user) + user_1.rejects_follow_request_from(remote_user) follow_activity = self.generate_follow_activity( - follower_actor_id=remote_actor.activitypub_id, - followed_actor=actor_1, + follower_actor_id=remote_user.actor.activitypub_id, + followed_actor=user_1.actor, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -170,11 +170,14 @@ def test_it_raises_error_if_follow_request_already_rejected( activity.process_activity() def test_it_creates_follow_request( - self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, ) -> None: follow_activity = self.generate_follow_activity( - follower_actor_id=remote_actor.activitypub_id, - followed_actor=actor_1, + follower_actor_id=remote_user.actor.activitypub_id, + followed_actor=user_1.actor, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -183,21 +186,21 @@ def test_it_creates_follow_request( activity.process_activity() follow_request = FollowRequest.query.filter_by( - follower_user_id=remote_actor.user.id, - followed_user_id=actor_1.user.id, + follower_user_id=remote_user.id, + followed_user_id=user_1.id, ).first() assert follow_request is not None def test_it_does_not_raise_error_if_pending_follow_request_already_exist( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: follow_activity = self.generate_follow_activity( - follower_actor_id=remote_actor.activitypub_id, - followed_actor=actor_1, + follower_actor_id=remote_user.actor.activitypub_id, + followed_actor=remote_user.actor, ) activity = get_activity_instance({'type': follow_activity['type']})( activity_dict=follow_activity @@ -206,18 +209,18 @@ def test_it_does_not_raise_error_if_pending_follow_request_already_exist( activity.process_activity() follow_request = FollowRequest.query.filter_by( - follower_user_id=remote_actor.user.id, - followed_user_id=actor_1.user.id, + follower_user_id=remote_user.id, + followed_user_id=user_1.id, ).first() assert follow_request.updated_at is None class TestAcceptActivity(FollowRequestActivitiesTestCase): def test_it_raises_error_if_follower_actor_does_not_exist( - self, app_with_federation: Flask, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: accept_activity = self.generate_accept_activity( - followed_actor=remote_actor + followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -230,9 +233,11 @@ def test_it_raises_error_if_follower_actor_does_not_exist( activity.process_activity() def test_it_raises_error_if_followed_actor_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: - accept_activity = self.generate_accept_activity(follower_actor=actor_1) + accept_activity = self.generate_accept_activity( + follower_actor=user_1.actor + ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity ) @@ -246,11 +251,11 @@ def test_it_raises_error_if_followed_actor_does_not_exist( def test_it_raises_error_if_follow_request_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: accept_activity = self.generate_accept_activity( - follower_actor=actor_1, followed_actor=remote_actor + follower_actor=user_1.actor, followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -262,13 +267,13 @@ def test_it_raises_error_if_follow_request_does_not_exist( def test_it_raises_error_if_follow_request_already_rejected( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, - follow_request_from_user_1_to_remote_actor: FollowRequest, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, ) -> None: - remote_actor.user.rejects_follow_request_from(actor_1.user) + remote_user.rejects_follow_request_from(user_1) accept_activity = self.generate_accept_activity( - follower_actor=actor_1, followed_actor=remote_actor + followed_actor=remote_user.actor, follower_actor=user_1.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -280,12 +285,12 @@ def test_it_raises_error_if_follow_request_already_rejected( def test_it_accepts_follow_request( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, - follow_request_from_user_1_to_remote_actor: FollowRequest, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, ) -> None: accept_activity = self.generate_accept_activity( - follower_actor=actor_1, followed_actor=remote_actor + follower_actor=user_1.actor, followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -294,8 +299,8 @@ def test_it_accepts_follow_request( activity.process_activity() follow_request = FollowRequest.query.filter_by( - follower_user_id=actor_1.user.id, - followed_user_id=remote_actor.user.id, + follower_user_id=user_1.id, + followed_user_id=remote_user.id, ).first() assert follow_request.is_approved @@ -304,10 +309,10 @@ def test_it_accepts_follow_request( class TestRejectActivity(FollowRequestActivitiesTestCase): def test_it_raises_error_if_follower_actor_does_not_exist( - self, app_with_federation: Flask, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: accept_activity = self.generate_reject_activity( - followed_actor=remote_actor + followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -320,9 +325,11 @@ def test_it_raises_error_if_follower_actor_does_not_exist( activity.process_activity() def test_it_raises_error_if_followed_actor_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: - accept_activity = self.generate_reject_activity(follower_actor=actor_1) + accept_activity = self.generate_reject_activity( + follower_actor=user_1.actor + ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity ) @@ -336,11 +343,11 @@ def test_it_raises_error_if_followed_actor_does_not_exist( def test_it_raises_error_if_follow_request_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: accept_activity = self.generate_reject_activity( - follower_actor=actor_1, followed_actor=remote_actor + follower_actor=user_1.actor, followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -352,13 +359,13 @@ def test_it_raises_error_if_follow_request_does_not_exist( def test_it_raises_error_if_follow_request_already_rejected( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, - follow_request_from_user_1_to_remote_actor: FollowRequest, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, ) -> None: - remote_actor.user.rejects_follow_request_from(actor_1.user) - accept_activity = self.generate_reject_activity( - follower_actor=actor_1, followed_actor=remote_actor + remote_user.rejects_follow_request_from(user_1) + accept_activity = self.generate_accept_activity( + followed_actor=remote_user.actor, follower_actor=user_1.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -370,12 +377,12 @@ def test_it_raises_error_if_follow_request_already_rejected( def test_it_rejects_follow_request( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, - follow_request_from_user_1_to_remote_actor: FollowRequest, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, ) -> None: accept_activity = self.generate_reject_activity( - follower_actor=actor_1, followed_actor=remote_actor + follower_actor=user_1.actor, followed_actor=remote_user.actor ) activity = get_activity_instance({'type': accept_activity['type']})( activity_dict=accept_activity @@ -384,8 +391,8 @@ def test_it_rejects_follow_request( activity.process_activity() follow_request = FollowRequest.query.filter_by( - follower_user_id=actor_1.user.id, - followed_user_id=remote_actor.user.id, + follower_user_id=user_1.id, + followed_user_id=remote_user.id, ).first() assert follow_request.is_approved is False diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py index c43d86046..fe97e17a0 100644 --- a/fittrackee/tests/federation/federation/test_federation_federation.py +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -27,26 +27,26 @@ def test_it_returns_404_if_user_does_not_exist( assert 'user does not exist' in data['message'] def test_it_returns_json_resource_descriptor_as_content_type( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() response = client.get( - f'/federation/user/{actor_1.preferred_username}', + f'/federation/user/{user_1.actor.preferred_username}', ) assert response.status_code == 200 assert response.content_type == 'application/jrd+json; charset=utf-8' def test_it_returns_actor( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() response = client.get( - f'/federation/user/{actor_1.preferred_username}', + f'/federation/user/{user_1.actor.preferred_username}', ) data = json.loads(response.data.decode()) - assert data == actor_1.serialize() + assert data == user_1.actor.serialize() def test_it_returns_error_if_federation_is_disabled( self, app: Flask, app_actor: Actor @@ -103,10 +103,10 @@ def test_it_returns_error_if_user_is_not_logged( assert 'provide a valid auth token' in data['message'] def test_it_returns_400_if_remote_user_url_is_missing( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( '/federation/remote-user', @@ -125,11 +125,11 @@ def test_it_returns_400_if_remote_user_url_is_missing( def test_it_returns_error_if_create_remote_user_returns_error( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) with patch( 'fittrackee.federation.utils_user.get_remote_actor' @@ -153,12 +153,12 @@ def test_it_returns_error_if_create_remote_user_returns_error( def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, random_actor: RandomActor, ) -> None: remote_user_object = random_actor.get_remote_user_object() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) with patch( 'fittrackee.federation.utils_user.get_remote_actor' @@ -209,8 +209,9 @@ def test_it_returns_404_if_actor_does_not_exist( assert 'user does not exist' in data['message'] def test_it_returns_ordered_collection_without_follower( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -228,8 +229,9 @@ def test_it_returns_ordered_collection_without_follower( } def test_it_returns_first_page_without_followers( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -248,8 +250,9 @@ def test_it_returns_first_page_without_followers( } def test_it_returns_error_if_page_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -264,8 +267,9 @@ def test_it_returns_error_if_page_is_invalid( } def test_it_does_not_return_error_when_page_that_does_not_return_followers( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -286,14 +290,15 @@ def test_it_does_not_return_error_when_page_that_does_not_return_followers( def test_it_returns_first_page_with_followers( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_2.user) - actor_1.user.approves_follow_request_from(actor_3.user) + actor_1 = user_1.actor + user_1.approves_follow_request_from(user_2) + user_1.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -308,21 +313,25 @@ def test_it_returns_first_page_with_followers( 'type': 'OrderedCollectionPage', 'totalItems': 2, 'partOf': actor_1.followers_url, - 'orderedItems': [actor_3.activitypub_id, actor_2.activitypub_id], + 'orderedItems': [ + user_3.actor.activitypub_id, + user_2.actor.activitypub_id, + ], } @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) def test_it_returns_first_page_with_next_page_link( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_2.user) - actor_1.user.approves_follow_request_from(actor_3.user) + actor_1 = user_1.actor + user_1.approves_follow_request_from(user_2) + user_1.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -339,7 +348,7 @@ def test_it_returns_first_page_with_next_page_link( 'partOf': actor_1.followers_url, 'next': f'{actor_1.followers_url}?page=2', 'orderedItems': [ - actor_3.activitypub_id, + user_3.actor.activitypub_id, ], } @@ -347,14 +356,15 @@ def test_it_returns_first_page_with_next_page_link( def test_it_returns_next_page( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_2.user) - actor_1.user.approves_follow_request_from(actor_3.user) + actor_1 = user_1.actor + user_1.approves_follow_request_from(user_2) + user_1.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -371,7 +381,7 @@ def test_it_returns_next_page( 'partOf': actor_1.followers_url, 'prev': f'{actor_1.followers_url}?page=1', 'orderedItems': [ - actor_2.activitypub_id, + user_2.actor.activitypub_id, ], } @@ -409,8 +419,9 @@ def test_it_returns_404_if_actor_does_not_exist( assert 'user does not exist' in data['message'] def test_it_returns_ordered_collection_without_following( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -428,8 +439,9 @@ def test_it_returns_ordered_collection_without_following( } def test_it_returns_first_page_without_following( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -448,8 +460,9 @@ def test_it_returns_first_page_without_following( } def test_it_returns_error_if_page_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -464,8 +477,9 @@ def test_it_returns_error_if_page_is_invalid( } def test_it_does_not_return_error_when_page_that_does_not_return_following( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( @@ -486,14 +500,15 @@ def test_it_does_not_return_error_when_page_that_does_not_return_following( def test_it_returns_first_page_with_following_users( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_3_to_user_2_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_3.user) - actor_2.user.approves_follow_request_from(actor_3.user) + actor_3 = user_3.actor + user_1.approves_follow_request_from(user_3) + user_2.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -508,21 +523,25 @@ def test_it_returns_first_page_with_following_users( 'type': 'OrderedCollectionPage', 'totalItems': 2, 'partOf': actor_3.following_url, - 'orderedItems': [actor_2.activitypub_id, actor_1.activitypub_id], + 'orderedItems': [ + user_2.actor.activitypub_id, + user_1.actor.activitypub_id, + ], } @patch('fittrackee.federation.federation.USERS_PER_PAGE', 1) def test_it_returns_first_page_with_next_page_link( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_3_to_user_2_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_3.user) - actor_2.user.approves_follow_request_from(actor_3.user) + actor_3 = user_3.actor + user_1.approves_follow_request_from(user_3) + user_2.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -539,7 +558,7 @@ def test_it_returns_first_page_with_next_page_link( 'partOf': actor_3.following_url, 'next': f'{actor_3.following_url}?page=2', 'orderedItems': [ - actor_2.activitypub_id, + user_2.actor.activitypub_id, ], } @@ -547,14 +566,15 @@ def test_it_returns_first_page_with_next_page_link( def test_it_returns_next_page( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, - follow_request_from_user_3_to_user_2_with_federation: FollowRequest, - follow_request_from_user_3_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, ) -> None: - actor_1.user.approves_follow_request_from(actor_3.user) - actor_2.user.approves_follow_request_from(actor_3.user) + actor_3 = user_3.actor + user_1.approves_follow_request_from(user_3) + user_2.approves_follow_request_from(user_3) client = app_with_federation.test_client() response = client.get( @@ -571,6 +591,6 @@ def test_it_returns_next_page( 'partOf': actor_3.following_url, 'prev': f'{actor_3.following_url}?page=1', 'orderedItems': [ - actor_1.activitypub_id, + user_1.actor.activitypub_id, ], } diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index 5aec26c1a..0c7ceb43b 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -9,6 +9,7 @@ from fittrackee.federation.constants import AP_CTX from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils import get_ap_url +from fittrackee.users.models import User from ...utils import random_actor_url @@ -61,26 +62,28 @@ def test_it_returns_serialized_object( class TestActivityPubLocalPersonActorModel: def test_it_returns_string_representation( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: - assert '' == str(actor_1) + assert '' == str(user_1.actor) def test_actor_is_local( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: - assert not actor_1.is_remote + assert not user_1.actor.is_remote def test_it_returns_fullname( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor assert ( actor_1.fullname == f'{actor_1.preferred_username}@{actor_1.domain.name}' ) def test_it_returns_serialized_object( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor serialized_actor = actor_1.serialize() ap_url = app_with_federation.config['AP_DOMAIN'] assert serialized_actor['@context'] == AP_CTX @@ -120,8 +123,9 @@ def test_it_returns_serialized_object( ) def test_generated_key_is_valid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor actor_1.generate_keys() signer = pkcs1_15.new(RSA.import_key(actor_1.private_key)) @@ -132,13 +136,14 @@ def test_generated_key_is_valid( class TestActivityPubRemotePersonActorModel: def test_actor_is_remote( - self, app_with_federation: Flask, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: - assert remote_actor.is_remote + assert remote_user.actor.is_remote def test_it_returns_fullname( - self, app_with_federation: Flask, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: + remote_actor = remote_user.actor assert ( remote_actor.fullname == f'{remote_actor.preferred_username}@{remote_actor.domain.name}' @@ -147,19 +152,18 @@ def test_it_returns_fullname( def test_it_returns_ap_id_if_no_profile_url_provided( self, app_with_federation: Flask, - remote_actor_without_profile_page: Actor, + remote_user_without_profile_page: User, ) -> None: - assert ( - remote_actor_without_profile_page.profile_url - == remote_actor_without_profile_page.activitypub_id - ) + remote_actor = remote_user_without_profile_page.actor + assert remote_actor.profile_url == remote_actor.activitypub_id def test_it_returns_serialized_object( self, app_with_federation: Flask, - remote_actor: Actor, + remote_user: User, remote_domain: Domain, ) -> None: + remote_actor = remote_user.actor serialized_actor = remote_actor.serialize() remote_domain_url = f'https://{remote_domain.name}' user_url = random_actor_url( diff --git a/fittrackee/tests/federation/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py index bb6c850d9..8fe3bc963 100644 --- a/fittrackee/tests/federation/federation/test_federation_nodeinfo.py +++ b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py @@ -3,7 +3,7 @@ from flask import Flask from fittrackee import VERSION -from fittrackee.federation.models import Actor +from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout @@ -26,7 +26,7 @@ def test_it_returns_error_if_federation_is_disabled( ) def test_it_returns_instance_nodeinfo_url_if_federation_is_enabled( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() response = client.get( @@ -86,7 +86,7 @@ def test_it_returns_error_if_federation_is_disabled( def test_it_returns_instance_nodeinfo_if_federation_is_enabled( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: client = app_with_federation.test_client() response = client.get( @@ -107,7 +107,7 @@ def test_it_returns_instance_nodeinfo_if_federation_is_enabled( def test_it_displays_workouts_count( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: @@ -124,8 +124,8 @@ def test_it_displays_workouts_count( def test_only_local_actors_are_counted( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: client = app_with_federation.test_client() response = client.get( @@ -141,7 +141,7 @@ def test_only_local_actors_are_counted( def test_it_displays_if_registration_is_disabled( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: diff --git a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py index 358817bff..fd22d0de5 100644 --- a/fittrackee/tests/federation/federation/test_federation_remote_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -8,7 +8,7 @@ from freezegun import freeze_time from fittrackee.federation.inbox import send_to_remote_user_inbox -from fittrackee.federation.models import Actor +from fittrackee.users.models import User from ...test_case_mixins import BaseTestMixin from ...utils import generate_response, get_date_string, random_string @@ -24,9 +24,11 @@ def test_it_calls_generate_signature_header( generate_signature_header_mock: Mock, generate_digest_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor now = datetime.utcnow() parsed_inbox_url = urlparse(remote_actor.inbox_url) requests_mock.post.return_value = generate_response(status_code=200) @@ -57,9 +59,11 @@ def test_it_calls_requests_post( generate_signature_header_mock: Mock, generate_digest_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor activity = {'foo': 'bar'} now = datetime.utcnow() parsed_inbox_url = urlparse(remote_actor.inbox_url) @@ -95,10 +99,12 @@ def test_it_logs_error_if_remote_inbox_returns_error( requests_mock: Mock, generate_signature_header_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, caplog: LogCaptureFixture, ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor status_code = 404 content = 'error' requests_mock.post.return_value = generate_response( diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 5f146e3f0..92ddbf25a 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -19,6 +19,7 @@ generate_signature, generate_signature_header, ) +from fittrackee.users.models import User from ...utils import generate_response, get_date_string, random_string @@ -359,7 +360,7 @@ def test_verify_raises_error_if_http_digest_is_invalid( input_description: str, input_digest: str, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -387,9 +388,9 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( input_description: str, input_algorithm: str, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: - activity = self.get_activity(actor=actor_1) + activity = self.get_activity(actor=user_1.actor) sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_headers( @@ -407,22 +408,22 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( class TestSignatureVerify(SignatureVerificationTestCase): def test_it_raises_error_if_header_actor_is_different_from_activity_actor( - self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + self, app_with_federation: Flask, user_1: User, user_2: User ) -> None: sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_valid_headers( host=app_with_federation.config['AP_DOMAIN'], - actor=actor_1, + actor=user_1.actor, date_str=datetime.utcnow().strftime(VALID_DATE_FORMAT), - activity=self.get_activity(actor=actor_2), + activity=self.get_activity(actor=user_2.actor), ) ) ) with patch.object(requests, 'get') as requests_mock: requests_mock.return_value = generate_response( status_code=200, - content=actor_1.serialize(), + content=user_1.actor.serialize(), ) with pytest.raises( @@ -431,8 +432,9 @@ def test_it_raises_error_if_header_actor_is_different_from_activity_actor( sig_verification.verify() def test_verify_raises_error_if_header_date_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -457,8 +459,9 @@ def test_verify_raises_error_if_header_date_is_invalid( sig_verification.verify() def test_verify_raises_error_if_public_key_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -479,8 +482,9 @@ def test_verify_raises_error_if_public_key_is_invalid( sig_verification.verify() def test_verify_raises_error_if_algorithm_is_not_supported( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor algorithm = random_string() activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( @@ -507,8 +511,9 @@ def test_verify_raises_error_if_algorithm_is_not_supported( sig_verification.verify() def test_verify_raises_error_if_http_digest_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor sig_verification = SignatureVerification.get_signature( self.get_request_mock( self.generate_headers( @@ -533,8 +538,9 @@ def test_verify_raises_error_if_http_digest_is_invalid( sig_verification.verify() def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -560,8 +566,9 @@ def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( sig_verification.verify() def test_verify_does_not_raise_error_if_signature_is_valid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( @@ -582,8 +589,9 @@ def test_verify_does_not_raise_error_if_signature_is_valid( sig_verification.verify() def test_verify_does_not_raise_error_if_signature_without_digest_is_valid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor activity = self.get_activity(actor=actor_1) sig_verification = SignatureVerification.get_signature( self.get_request_mock( diff --git a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py index 96ea6db91..988131a0c 100644 --- a/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py +++ b/fittrackee/tests/federation/federation/test_federation_tasks_user_inbox.py @@ -4,9 +4,8 @@ from flask import Flask from fittrackee.federation.exceptions import SenderNotFoundException -from fittrackee.federation.models import Actor from fittrackee.federation.tasks.user_inbox import send_to_users_inbox -from fittrackee.users.models import FollowRequest +from fittrackee.users.models import FollowRequest, User from ...utils import random_domain_with_scheme, random_string @@ -15,19 +14,14 @@ class TestSendToUsersInbox: def test_it_raises_error_if_sender_does_not_exist( self, app_with_federation: Flask, - follow_request_from_user_1_to_user_2_with_federation: FollowRequest, - remote_actor: Actor, + follow_request_from_user_1_to_user_2: FollowRequest, + remote_user: User, ) -> None: with pytest.raises(SenderNotFoundException): send_to_users_inbox( sender_id=0, - activity=( - # fmt: off - follow_request_from_user_1_to_user_2_with_federation. - get_activity() - # fmt: on - ), - recipients=[remote_actor.inbox_url], + activity=follow_request_from_user_1_to_user_2.get_activity(), + recipients=[remote_user.actor.inbox_url], ) @patch('fittrackee.federation.tasks.user_inbox.send_to_remote_user_inbox') @@ -35,20 +29,20 @@ def test_it_calls_send_to_remote_user_inbox( self, send_to_remote_user_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: activity = {'foo': 'bar'} send_to_users_inbox( - sender_id=actor_1.id, + sender_id=user_1.actor.id, activity=activity, - recipients=[remote_actor.inbox_url], + recipients=[remote_user.actor.inbox_url], ) send_to_remote_user_inbox_mock.assert_called_with( - sender=actor_1, + sender=user_1.actor, activity=activity, - recipient_inbox_url=remote_actor.inbox_url, + recipient_inbox_url=remote_user.actor.inbox_url, ) @patch('fittrackee.federation.tasks.user_inbox.send_to_remote_user_inbox') @@ -56,7 +50,7 @@ def test_it_calls_send_to_remote_user_inbox_for_each_recipient( self, send_to_remote_user_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: nb_recipients = 3 recipients = [ @@ -65,7 +59,7 @@ def test_it_calls_send_to_remote_user_inbox_for_each_recipient( ] send_to_users_inbox( - sender_id=actor_1.id, + sender_id=user_1.actor.id, activity={}, recipients=recipients, ) diff --git a/fittrackee/tests/federation/federation/test_federation_utils_user.py b/fittrackee/tests/federation/federation/test_federation_utils_user.py index ee6f55126..4e035ef35 100644 --- a/fittrackee/tests/federation/federation/test_federation_utils_user.py +++ b/fittrackee/tests/federation/federation/test_federation_utils_user.py @@ -10,6 +10,7 @@ ) from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils_user import create_remote_user +from fittrackee.users.models import User from ...utils import RandomActor, random_string @@ -122,7 +123,6 @@ def test_it_creates_remote_actor_if_actor_does_not_exist( def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( self, app_with_federation: Flask, - actor_1: Actor, random_actor: RandomActor, ) -> None: remote_user_object = random_actor.get_remote_user_object() @@ -139,8 +139,9 @@ def test_it_creates_remote_actor_if_actor_and_domain_dont_exist( assert actor == expected_actor def test_it_returns_updated_remote_actor_if_remote_actor_exists( - self, app_with_federation: Flask, actor_1: Actor, remote_actor: Actor + self, app_with_federation: Flask, remote_user: User ) -> None: + remote_actor = remote_user.actor remote_user_object = remote_actor.serialize() updated_name = random_string() remote_user_object['name'] = updated_name @@ -161,7 +162,6 @@ def test_it_returns_updated_remote_actor_if_remote_actor_exists( def test_it_creates_several_remote_actors( self, app_with_federation: Flask, - actor_1: Actor, random_actor: RandomActor, random_actor_2: RandomActor, ) -> None: diff --git a/fittrackee/tests/federation/federation/test_federation_webfinger.py b/fittrackee/tests/federation/federation/test_federation_webfinger.py index 2dd9cedd0..e8af5ccf8 100644 --- a/fittrackee/tests/federation/federation/test_federation_webfinger.py +++ b/fittrackee/tests/federation/federation/test_federation_webfinger.py @@ -4,6 +4,7 @@ from flask import Flask from fittrackee.federation.models import Actor +from fittrackee.users.models import User class TestWebfinger: @@ -65,12 +66,12 @@ def test_it_returns_404_if_user_does_not_exist( assert 'user does not exist' in data['message'] def test_it_returns_404_if_domain_is_not_instance_domain( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' - f'{actor_1.preferred_username}@{uuid4().hex}', + f'{user_1.actor.preferred_username}@{uuid4().hex}', content_type='application/json', ) @@ -80,11 +81,11 @@ def test_it_returns_404_if_domain_is_not_instance_domain( assert 'user does not exist' in data['message'] def test_it_returns_json_resource_descriptor_as_content_type( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() response = client.get( - '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', + '/.well-known/webfinger?resource=acct:' f'{user_1.actor.fullname}', content_type='application/json', ) @@ -92,8 +93,9 @@ def test_it_returns_json_resource_descriptor_as_content_type( assert response.content_type == 'application/jrd+json; charset=utf-8' def test_it_returns_subject_with_user_data( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', @@ -105,8 +107,9 @@ def test_it_returns_subject_with_user_data( assert f'acct:{actor_1.fullname}' in data['subject'] def test_it_returns_user_links( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: + actor_1 = user_1.actor client = app_with_federation.test_client() response = client.get( '/.well-known/webfinger?resource=acct:' f'{actor_1.fullname}', diff --git a/fittrackee/tests/federation/federation/test_users_inbox.py b/fittrackee/tests/federation/federation/test_users_inbox.py index 5d5ecbaf5..d7cc0ebac 100644 --- a/fittrackee/tests/federation/federation/test_users_inbox.py +++ b/fittrackee/tests/federation/federation/test_users_inbox.py @@ -14,6 +14,7 @@ generate_digest, generate_signature_header, ) +from fittrackee.users.models import User from ...test_case_mixins import ApiTestCaseMixin from ...utils import ( @@ -73,7 +74,7 @@ def post_to_user_inbox( return follow_activity, response def test_it_returns_404_if_user_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() @@ -106,14 +107,14 @@ def test_it_returns_404_if_user_does_not_exist( def test_it_returns_400_if_activity_is_invalid( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, input_description: str, input_activity: Dict, ) -> None: client = app_with_federation.test_client() response = client.post( - f'/federation/user/{actor_1.preferred_username}/inbox', + f'/federation/user/{user_1.actor.preferred_username}/inbox', content_type='application/json', data=json.dumps(input_activity), ) @@ -124,7 +125,7 @@ def test_it_returns_400_if_activity_is_invalid( assert 'invalid payload' in data['message'] def test_it_returns_401_if_headers_are_missing( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() follow_activity = { @@ -136,7 +137,7 @@ def test_it_returns_401_if_headers_are_missing( } response = client.post( - f'/federation/user/{actor_1.preferred_username}/inbox', + f'/federation/user/{user_1.actor.preferred_username}/inbox', content_type='application/json', data=json.dumps(follow_activity), ) @@ -147,7 +148,7 @@ def test_it_returns_401_if_headers_are_missing( assert 'Invalid signature.' in data['message'] def test_it_returns_401_if_signature_is_invalid( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client = app_with_federation.test_client() follow_activity = { @@ -159,7 +160,7 @@ def test_it_returns_401_if_signature_is_invalid( } response = client.post( - f'/federation/user/{actor_1.preferred_username}/inbox', + f'/federation/user/{user_1.actor.preferred_username}/inbox', content_type='application/json', headers={ 'Host': random_string(), @@ -179,11 +180,11 @@ def test_it_returns_200_if_activity_and_signature_are_valid( self, handle_activity: Mock, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, ) -> None: _, response = self.post_to_user_inbox( - app_with_federation, actor_1, actor_2 + app_with_federation, user_1.actor, user_2.actor ) assert response.status_code == 200 @@ -195,11 +196,11 @@ def test_it_calls_handle_activity_task( self, handle_activity: Mock, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, ) -> None: activity_dict, response = self.post_to_user_inbox( - app_with_federation, actor_1, actor_2 + app_with_federation, user_1.actor, user_2.actor ) handle_activity.send.assert_called_with(activity=activity_dict) diff --git a/fittrackee/tests/federation/users/test_users_follow_api.py b/fittrackee/tests/federation/users/test_users_follow_api.py index e77ac555b..7aacf46c2 100644 --- a/fittrackee/tests/federation/users/test_users_follow_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -4,8 +4,8 @@ from flask import Flask -from fittrackee.federation.models import Actor, Domain -from fittrackee.users.models import FollowRequest +from fittrackee.federation.models import Domain +from fittrackee.users.models import FollowRequest, User from ...test_case_mixins import ApiTestCaseMixin, UserInboxTestMixin from ...utils import RandomActor, random_string @@ -17,10 +17,10 @@ class TestFollowWithFederation(ApiTestCaseMixin): def test_it_raises_error_if_target_user_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( @@ -37,18 +37,18 @@ def test_it_raises_error_if_target_user_does_not_exist( def test_it_raises_error_if_target_user_has_already_rejected_request( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: follow_request_from_user_1_to_user_2.is_approved = False follow_request_from_user_1_to_user_2.updated_at = datetime.now() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( - f'/api/users/{actor_2.preferred_username}/follow', + f'/api/users/{user_2.actor.preferred_username}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -59,14 +59,14 @@ def test_it_raises_error_if_target_user_has_already_rejected_request( assert data['message'] == 'you do not have permissions' def test_it_creates_follow_request( - self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + self, app_with_federation: Flask, user_1: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( - f'/api/users/{actor_2.preferred_username}/follow', + f'/api/users/{user_2.actor.preferred_username}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -76,23 +76,23 @@ def test_it_creates_follow_request( assert data['status'] == 'success' assert ( data['message'] - == f"Follow request to user '{actor_2.preferred_username}' " + == f"Follow request to user '{user_2.actor.preferred_username}' " f"is sent." ) def test_it_returns_success_if_follow_request_already_exists( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( - f'/api/users/{actor_2.preferred_username}/follow', + f'/api/users/{user_2.actor.preferred_username}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -102,7 +102,7 @@ def test_it_returns_success_if_follow_request_already_exists( assert data['status'] == 'success' assert ( data['message'] - == f"Follow request to user '{actor_2.preferred_username}' " + == f"Follow request to user '{user_2.actor.preferred_username}' " f"is sent." ) @@ -111,15 +111,15 @@ def test_it_does_not_call_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( - f'/api/users/{actor_2.preferred_username}/follow', + f'/api/users/{user_2.actor.preferred_username}/follow', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -133,11 +133,11 @@ class TestRemoteFollowWithFederation(ApiTestCaseMixin, UserInboxTestMixin): def test_it_raise_error_if_remote_actor_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( @@ -154,12 +154,12 @@ def test_it_raise_error_if_remote_actor_does_not_exist( def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domain( # noqa self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, remote_domain: Domain, random_actor: RandomActor, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( @@ -178,11 +178,12 @@ def test_it_creates_follow_request( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + remote_actor = remote_user.actor client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( @@ -202,12 +203,13 @@ def test_it_creates_follow_request( def test_it_returns_success_if_follow_request_already_exists( self, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: + remote_actor = remote_user.actor client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.post( @@ -229,11 +231,12 @@ def test_it_calls_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + remote_actor = remote_user.actor client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( @@ -243,12 +246,12 @@ def test_it_calls_send_to_user_inbox( ) follow_request = FollowRequest.query.filter_by( - follower_user_id=actor_1.user.id, + follower_user_id=user_1.id, followed_user_id=remote_actor.user.id, ).first() self.assert_send_to_users_inbox_called_once( send_to_users_inbox_mock, - local_actor=actor_1, + local_actor=user_1.actor, remote_actor=remote_actor, base_object=follow_request, ) diff --git a/fittrackee/tests/federation/users/test_users_follow_request_api.py b/fittrackee/tests/federation/users/test_users_follow_request_api.py index 55ae0c635..585401c24 100644 --- a/fittrackee/tests/federation/users/test_users_follow_request_api.py +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -4,8 +4,7 @@ from flask import Flask -from fittrackee.federation.models import Actor -from fittrackee.users.models import FollowRequest +from fittrackee.users.models import FollowRequest, User from ...test_case_mixins import ApiTestCaseMixin, UserInboxTestMixin from ...users.test_users_follow_request_api import FollowRequestTestCase @@ -14,10 +13,10 @@ class TestGetFollowRequestWithFederation(ApiTestCaseMixin): def test_it_returns_empty_list_if_no_follow_request( - self, app_with_federation: Flask, actor_1: Actor + self, app_with_federation: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.get( @@ -34,16 +33,16 @@ def test_it_returns_empty_list_if_no_follow_request( def test_it_returns_current_user_follow_requests_with_actors( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - actor_3: Actor, + user_1: User, + user_2: User, + user_3: User, follow_request_from_user_2_to_user_1: FollowRequest, follow_request_from_user_3_to_user_1: FollowRequest, follow_request_from_user_3_to_user_2: FollowRequest, ) -> None: follow_request_from_user_3_to_user_1.updated_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) response = client.get( @@ -64,10 +63,10 @@ class TestAcceptLocalFollowRequestWithFederation(FollowRequestTestCase): def test_it_raises_error_if_target_user_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_return_user_not_found( @@ -77,48 +76,46 @@ def test_it_raises_error_if_target_user_does_not_exist( ) def test_it_raises_error_if_follow_request_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + self, app_with_federation: Flask, user_1: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_not_found( - client, auth_token, actor_2.user.username, 'accept' + client, auth_token, user_2.username, 'accept' ) def test_it_raises_error_if_follow_request_already_accepted( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - follow_request_from_user_2_to_user_1_with_federation.is_approved = True - follow_request_from_user_2_to_user_1_with_federation.updated_at = ( - datetime.utcnow() - ) + follow_request_from_user_2_to_user_1.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_already_processed( - client, auth_token, actor_2.user.username, 'accept' + client, auth_token, user_2.username, 'accept' ) def test_it_accepts_follow_request( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_processed( - client, auth_token, actor_2.user.username, 'accept' + client, auth_token, user_2.username, 'accept' ) @patch('fittrackee.users.models.send_to_users_inbox') @@ -126,16 +123,16 @@ def test_it_does_not_call_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( - f'/api/follow_requests/{actor_2.user.username}/accept', + f'/api/follow_requests/{user_2.username}/accept', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -151,16 +148,19 @@ def test_it_accepts_follow_request( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_processed( - client, auth_token, remote_actor.fullname, 'accept' # type: ignore + client, + auth_token, + remote_user.actor.fullname, + 'accept', ) @patch('fittrackee.users.models.send_to_users_inbox') @@ -168,12 +168,13 @@ def test_it_calls_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: + remote_actor = remote_user.actor client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( @@ -184,11 +185,11 @@ def test_it_calls_send_to_user_inbox( follow_request = FollowRequest.query.filter_by( follower_user_id=remote_actor.user.id, - followed_user_id=actor_1.user.id, + followed_user_id=user_1.id, ).first() self.assert_send_to_users_inbox_called_once( send_to_users_inbox_mock, - local_actor=actor_1, + local_actor=user_1.actor, remote_actor=remote_actor, base_object=follow_request, ) @@ -198,10 +199,10 @@ class TestRejectLocalFollowRequestWithFederation(FollowRequestTestCase): def test_it_raises_error_if_target_user_does_not_exist( self, app_with_federation: Flask, - actor_1: Actor, + user_1: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_return_user_not_found( @@ -211,47 +212,45 @@ def test_it_raises_error_if_target_user_does_not_exist( ) def test_it_raises_error_if_follow_request_does_not_exist( - self, app_with_federation: Flask, actor_1: Actor, actor_2: Actor + self, app_with_federation: Flask, user_1: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_not_found( - client, auth_token, actor_2.user.username, 'reject' + client, auth_token, user_2.username, 'reject' ) def test_it_raises_error_if_follow_request_already_accepted( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - follow_request_from_user_2_to_user_1_with_federation.updated_at = ( - datetime.utcnow() - ) + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_already_processed( - client, auth_token, actor_2.user.username, 'reject' + client, auth_token, user_2.username, 'reject' ) def test_it_rejects_follow_request( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_processed( - client, auth_token, actor_2.user.username, 'reject' + client, auth_token, user_2.username, 'reject' ) @patch('fittrackee.users.models.send_to_users_inbox') @@ -259,16 +258,16 @@ def test_it_does_not_call_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_2_to_user_1_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( - f'/api/follow_requests/{actor_2.user.username}/reject', + f'/api/follow_requests/{user_2.username}/reject', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -284,16 +283,19 @@ def test_it_accepts_follow_request( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) self.assert_it_returns_follow_request_processed( - client, auth_token, remote_actor.fullname, 'reject' # type: ignore + client, + auth_token, + remote_user.actor.fullname, + 'reject', ) @patch('fittrackee.users.models.send_to_users_inbox') @@ -301,12 +303,13 @@ def test_it_calls_send_to_user_inbox( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, follow_request_from_remote_user_to_user_1: FollowRequest, ) -> None: + remote_actor = remote_user.actor client, auth_token = self.get_test_client_and_auth_token( - app_with_federation, actor_1.user.email + app_with_federation, user_1.email ) client.post( @@ -317,11 +320,11 @@ def test_it_calls_send_to_user_inbox( follow_request = FollowRequest.query.filter_by( follower_user_id=remote_actor.user.id, - followed_user_id=actor_1.user.id, + followed_user_id=user_1.id, ).first() self.assert_send_to_users_inbox_called_once( send_to_users_inbox_mock, - local_actor=actor_1, + local_actor=user_1.actor, remote_actor=remote_actor, base_object=follow_request, ) diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index 3ab4b90ba..3f81aa452 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -4,24 +4,25 @@ from flask import Flask from fittrackee.federation.constants import AP_CTX -from fittrackee.federation.models import Actor -from fittrackee.users.models import FollowRequest +from fittrackee.users.models import FollowRequest, User class TestFollowRequestModelWithFederation: def test_follow_request_model( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, - follow_request_from_user_1_to_user_2_with_federation: FollowRequest, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: + actor_1 = user_1.actor + actor_2 = user_2.actor assert '' == str( - follow_request_from_user_1_to_user_2_with_federation + follow_request_from_user_1_to_user_2 ) serialized_follow_request = ( - follow_request_from_user_1_to_user_2_with_federation.serialize() + follow_request_from_user_1_to_user_2.serialize() ) assert serialized_follow_request['from_user'] == actor_1.serialize() assert serialized_follow_request['to_user'] == actor_2.serialize() @@ -29,10 +30,12 @@ def test_follow_request_model( def test_it_returns_follow_activity_object( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: + actor_1 = user_1.actor + actor_2 = user_2.actor activity_object = follow_request_from_user_1_to_user_2.get_activity() assert activity_object == { @@ -46,10 +49,12 @@ def test_it_returns_follow_activity_object( def test_it_returns_accept_activity_object_when_follow_request_is_accepted( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: + actor_1 = user_1.actor + actor_2 = user_2.actor follow_request_from_user_1_to_user_2.is_approved = True follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() activity_object = follow_request_from_user_1_to_user_2.get_activity() @@ -72,10 +77,12 @@ def test_it_returns_accept_activity_object_when_follow_request_is_accepted( def test_it_returns_reject_activity_object_when_follow_request_is_rejected( self, app_with_federation: Flask, - actor_1: Actor, - actor_2: Actor, + user_1: User, + user_2: User, follow_request_from_user_1_to_user_2: FollowRequest, ) -> None: + actor_1 = user_1.actor + actor_2 = user_2.actor follow_request_from_user_1_to_user_2.is_approved = False follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() activity_object = follow_request_from_user_1_to_user_2.get_activity() @@ -102,9 +109,11 @@ def test_local_actor_sends_follow_requests_to_remote_actor( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor follow_request = actor_1.user.send_follow_request_to(remote_actor.user) assert follow_request in actor_1.user.sent_follow_requests.all() @@ -121,10 +130,12 @@ def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( self, send_to_users_inbox_mock: Mock, app_with_federation: Flask, - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> None: + actor_1 = user_1.actor actor_1.user.manually_approves_followers = False + remote_actor = remote_user.actor follow_request = remote_actor.user.send_follow_request_to(actor_1.user) assert follow_request in remote_actor.user.sent_follow_requests.all() diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index 2429955d7..5a1205e53 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -61,10 +61,10 @@ def get_app( ) if app_db_config: update_app_config_from_database(app, app_db_config) - if with_domain: - domain = Domain(name=app.config['AP_DOMAIN']) - db.session.add(domain) - db.session.commit() + if with_domain: + domain = Domain(name=app.config['AP_DOMAIN']) + db.session.add(domain) + db.session.commit() yield app except Exception as e: print(f'Error with app configuration: {e}') diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index 23e594765..b360e1a19 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -17,45 +17,6 @@ def app_actor(app: Flask) -> Actor: return actor -@pytest.fixture() -def actor_1(user_1: User, app_with_federation: Flask) -> Actor: - domain = Domain.query.filter_by( - name=app_with_federation.config['AP_DOMAIN'] - ).first() - actor = Actor(preferred_username=user_1.username, domain_id=domain.id) - db.session.add(actor) - db.session.flush() - user_1.actor_id = actor.id - db.session.commit() - return actor - - -@pytest.fixture() -def actor_2(user_2: User, app_with_federation: Flask) -> Actor: - domain = Domain.query.filter_by( - name=app_with_federation.config['AP_DOMAIN'] - ).first() - actor = Actor(preferred_username=user_2.username, domain_id=domain.id) - db.session.add(actor) - db.session.flush() - user_2.actor_id = actor.id - db.session.commit() - return actor - - -@pytest.fixture() -def actor_3(user_3: User, app_with_federation: Flask) -> Actor: - domain = Domain.query.filter_by( - name=app_with_federation.config['AP_DOMAIN'] - ).first() - actor = Actor(preferred_username=user_3.username, domain_id=domain.id) - db.session.add(actor) - db.session.flush() - user_3.actor_id = actor.id - db.session.commit() - return actor - - @pytest.fixture() def remote_domain(app_with_federation: Flask) -> Domain: remote_domain = Domain(name=random_domain()) @@ -64,55 +25,50 @@ def remote_domain(app_with_federation: Flask) -> Domain: return remote_domain -@pytest.fixture() -def remote_actor( - user_2: User, - app_with_federation: Flask, - remote_domain: Domain, -) -> Actor: +def generate_remote_user( + remote_domain: Domain, without_profile_page: bool = False +) -> User: domain = f'https://{remote_domain.name}' - remote_user_object = get_remote_user_object( - username=user_2.username, - preferred_username=user_2.username, - domain=domain, - profile_url=f'{domain}/{user_2.username}', - ) + user_name = 'test' + if without_profile_page: + remote_user_object = get_remote_user_object( + username='Test', + preferred_username=user_name, + domain=domain, + ) + else: + remote_user_object = get_remote_user_object( + username='Test', + preferred_username=user_name, + domain=domain, + profile_url=f'{domain}/{user_name}', + ) actor = Actor( - preferred_username=user_2.username, + preferred_username=user_name, domain_id=remote_domain.id, remote_user_data=remote_user_object, ) db.session.add(actor) db.session.flush() - user_2.name = user_2.username.capitalize() - user_2.actor_id = actor.id + user = User( + username=user_name, + email=None, + password=None, + ) + db.session.add(user) + user.actor_id = actor.id db.session.commit() - return actor + return user @pytest.fixture() -def remote_actor_without_profile_page( - user_2: User, - app_with_federation: Flask, - remote_domain: Domain, -) -> Actor: - domain = f'https://{remote_domain.name}' - remote_user_object = get_remote_user_object( - username=user_2.username, - preferred_username=user_2.username, - domain=domain, - ) - actor = Actor( - preferred_username=user_2.username, - domain_id=remote_domain.id, - remote_user_data=remote_user_object, - ) - db.session.add(actor) - db.session.flush() - user_2.name = user_2.username.capitalize() - user_2.actor_id = actor.id - db.session.commit() - return actor +def remote_user(remote_domain: Domain) -> User: + return generate_remote_user(remote_domain) + + +@pytest.fixture() +def remote_user_without_profile_page(remote_domain: Domain) -> User: + return generate_remote_user(remote_domain, without_profile_page=True) @pytest.fixture() diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py index e363089d0..c10746730 100644 --- a/fittrackee/tests/fixtures/fixtures_federation_users.py +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -1,83 +1,21 @@ import pytest -from fittrackee import db -from fittrackee.federation.models import Actor -from fittrackee.users.models import FollowRequest +from fittrackee.users.models import FollowRequest, User - -@pytest.fixture() -def follow_request_from_user_1_to_user_2_with_federation( - actor_1: Actor, - actor_2: Actor, -) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=actor_1.user.id, followed_user_id=actor_2.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - -@pytest.fixture() -def follow_request_from_user_2_to_user_1_with_federation( - actor_1: Actor, - actor_2: Actor, -) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=actor_2.user.id, followed_user_id=actor_1.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - -@pytest.fixture() -def follow_request_from_user_3_to_user_1_with_federation( - actor_1: Actor, - actor_3: Actor, -) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=actor_3.user.id, followed_user_id=actor_1.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request - - -@pytest.fixture() -def follow_request_from_user_3_to_user_2_with_federation( - actor_2: Actor, - actor_3: Actor, -) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=actor_3.user.id, followed_user_id=actor_2.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request +from ..utils import generate_follow_request @pytest.fixture() def follow_request_from_remote_user_to_user_1( - actor_1: Actor, - remote_actor: Actor, + user_1: User, + remote_user: User, ) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=remote_actor.user.id, followed_user_id=actor_1.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(remote_user, user_1) @pytest.fixture() -def follow_request_from_user_1_to_remote_actor( - actor_1: Actor, - remote_actor: Actor, +def follow_request_from_user_1_to_remote_user( + user_1: User, + remote_user: User, ) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=actor_1.user.id, followed_user_id=remote_actor.user.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(user_1, remote_user) diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index 88e83e011..1266a4b27 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -6,11 +6,15 @@ from fittrackee.users.models import FollowRequest, User, UserSportPreference from fittrackee.workouts.models import Sport +from ..utils import generate_follow_request + @pytest.fixture() def user_1() -> User: user = User(username='test', email='test@test.com', password='12345678') db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -19,6 +23,8 @@ def user_1() -> User: def user_1_upper() -> User: user = User(username='TEST', email='TEST@TEST.COM', password='12345678') db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -30,6 +36,8 @@ def user_1_admin() -> User: ) admin.admin = True db.session.add(admin) + db.session.flush() + admin.create_actor() db.session.commit() return admin @@ -45,6 +53,8 @@ def user_1_full() -> User: user.timezone = 'America/New_York' user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y') db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -54,6 +64,8 @@ def user_1_paris() -> User: user = User(username='test', email='test@test.com', password='12345678') user.timezone = 'Europe/Paris' db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -62,6 +74,8 @@ def user_1_paris() -> User: def user_2() -> User: user = User(username='toto', email='toto@toto.com', password='87654321') db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -71,6 +85,8 @@ def user_2_admin() -> User: user = User(username='toto', email='toto@toto.com', password='87654321') user.admin = True db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -80,6 +96,8 @@ def user_3() -> User: user = User(username='sam', email='sam@test.com', password='12345678') user.weekm = True db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -116,45 +134,25 @@ def user_admin_sport_1_preference( def follow_request_from_user_1_to_user_2( user_1: User, user_2: User ) -> FollowRequest: - follow_request = FollowRequest( - follower_user_id=user_1.id, followed_user_id=user_2.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(user_1, user_2) @pytest.fixture() def follow_request_from_user_2_to_user_1( user_1: User, user_2: User ) -> FollowRequest: - follow_request = FollowRequest( - followed_user_id=user_1.id, follower_user_id=user_2.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(user_2, user_1) @pytest.fixture() def follow_request_from_user_3_to_user_1( user_1: User, user_3: User ) -> FollowRequest: - follow_request = FollowRequest( - followed_user_id=user_1.id, follower_user_id=user_3.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(user_3, user_1) @pytest.fixture() def follow_request_from_user_3_to_user_2( user_2: User, user_3: User ) -> FollowRequest: - follow_request = FollowRequest( - followed_user_id=user_2.id, follower_user_id=user_3.id - ) - db.session.add(follow_request) - db.session.commit() - return follow_request + return generate_follow_request(user_3, user_2) diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index c2cf1ce46..c81e7d174 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -7,7 +7,9 @@ from requests import Response +from fittrackee import db from fittrackee.federation.signature import VALID_DATE_FORMAT +from fittrackee.users.models import FollowRequest, User def random_string( @@ -131,3 +133,12 @@ def random_actor_url( else random_domain_with_scheme() ) return f'{remote_domain}/users/{username}' + + +def generate_follow_request(follower: User, followed: User) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=follower.id, followed_user_id=followed.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request From cb0a189d2b29dd5bbe29c18a868456826c00eb10 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 17:33:12 +0100 Subject: [PATCH 058/238] API - get remote users --- .../tests/federation/users/test_users_api.py | 61 +++++ .../federation/users/test_users_model.py | 14 ++ fittrackee/tests/users/test_users_api.py | 96 ++++--- fittrackee/tests/users/test_users_model.py | 6 + fittrackee/users/auth.py | 9 + fittrackee/users/models.py | 5 + fittrackee/users/users.py | 237 +++++++++++++----- 7 files changed, 327 insertions(+), 101 deletions(-) create mode 100644 fittrackee/tests/federation/users/test_users_api.py diff --git a/fittrackee/tests/federation/users/test_users_api.py b/fittrackee/tests/federation/users/test_users_api.py new file mode 100644 index 000000000..9214bbdd6 --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_api.py @@ -0,0 +1,61 @@ +import json + +from flask import Flask + +from fittrackee.users.models import User + +from ...test_case_mixins import ApiTestCaseMixin + + +class TestGetLocalUsers(ApiTestCaseMixin): + def test_it_gets_users_list( + self, + app_with_federation: Flask, + user_1: User, + user_3: User, + remote_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + '/api/users', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 2 + assert data['data']['users'][0]['username'] == user_3.username + assert data['data']['users'][0]['is_remote'] is False + assert data['data']['users'][1]['username'] == user_1.username + assert data['data']['users'][1]['is_remote'] is False + + +class TestGetRemoteUsers(ApiTestCaseMixin): + def test_it_gets_users_list( + self, + app_with_federation: Flask, + user_1: User, + user_3: User, + remote_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + '/api/users/remote', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0]['username'] == remote_user.username + assert data['data']['users'][0]['is_remote'] diff --git a/fittrackee/tests/federation/users/test_users_model.py b/fittrackee/tests/federation/users/test_users_model.py index 3f81aa452..d16c19f66 100644 --- a/fittrackee/tests/federation/users/test_users_model.py +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -7,6 +7,20 @@ from fittrackee.users.models import FollowRequest, User +class TestUserModel: + def test_user_is_not_remote_when_actor_is_local( + self, app_with_federation: Flask, user_1: User + ) -> None: + assert user_1.is_remote is False + assert user_1.serialize()['is_remote'] is False + + def test_user_is_remote_when_actor_is_remote( + self, app_with_federation: Flask, remote_user: User + ) -> None: + assert remote_user.is_remote is True + assert remote_user.serialize()['is_remote'] is True + + class TestFollowRequestModelWithFederation: def test_follow_request_model( self, diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 7853b3f34..ba22fd66f 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -20,13 +20,14 @@ def assert_user(user_dict: Dict, user: User) -> None: '%a, %d %b %Y %H:%M:%S GMT' ) assert user_dict['admin'] == user.admin - assert user_dict['first_name'] is None - assert user_dict['last_name'] is None - assert user_dict['birth_date'] is None assert user_dict['bio'] is None - assert user_dict['location'] is None + assert user_dict['birth_date'] is None + assert user_dict['first_name'] is None assert user_dict['followers'] == 0 assert user_dict['following'] == 0 + assert user_dict['is_remote'] is False + assert user_dict['last_name'] is None + assert user_dict['location'] is None assert 'imperial_units' not in user_dict assert 'language' not in user_dict assert 'timezone' not in user_dict @@ -212,26 +213,29 @@ def test_it_gets_users_list( assert 'created_at' in data['data']['users'][1] assert 'created_at' in data['data']['users'][2] assert 'admin' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] - assert 'sam' in data['data']['users'][2]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert 'admin@example.com' in data['data']['users'][0]['email'] - assert 'toto@toto.com' in data['data']['users'][1]['email'] - assert 'sam@test.com' in data['data']['users'][2]['email'] + assert 'sam@test.com' in data['data']['users'][1]['email'] + assert 'toto@toto.com' in data['data']['users'][2]['email'] + assert data['data']['users'][0]['is_remote'] is False assert data['data']['users'][0]['nb_sports'] == 0 assert data['data']['users'][0]['nb_workouts'] == 0 assert data['data']['users'][0]['records'] == [] assert data['data']['users'][0]['sports_list'] == [] assert data['data']['users'][0]['total_distance'] == 0 assert data['data']['users'][0]['total_duration'] == '0:00:00' + assert data['data']['users'][1]['is_remote'] is False + assert data['data']['users'][1]['records'] == [] assert data['data']['users'][1]['nb_sports'] == 0 assert data['data']['users'][1]['nb_workouts'] == 0 - assert data['data']['users'][1]['records'] == [] assert data['data']['users'][1]['sports_list'] == [] assert data['data']['users'][1]['total_distance'] == 0 assert data['data']['users'][1]['total_duration'] == '0:00:00' - assert data['data']['users'][2]['records'] == [] + assert data['data']['users'][2]['is_remote'] is False assert data['data']['users'][2]['nb_sports'] == 0 assert data['data']['users'][2]['nb_workouts'] == 0 + assert data['data']['users'][2]['records'] == [] assert data['data']['users'][2]['sports_list'] == [] assert data['data']['users'][2]['total_distance'] == 0 assert data['data']['users'][2]['total_duration'] == '0:00:00' @@ -284,29 +288,32 @@ def test_it_gets_users_list_with_workouts( assert 'created_at' in data['data']['users'][1] assert 'created_at' in data['data']['users'][2] assert 'admin' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] - assert 'sam' in data['data']['users'][2]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert 'admin@example.com' in data['data']['users'][0]['email'] - assert 'toto@toto.com' in data['data']['users'][1]['email'] - assert 'sam@test.com' in data['data']['users'][2]['email'] + assert 'sam@test.com' in data['data']['users'][1]['email'] + assert 'toto@toto.com' in data['data']['users'][2]['email'] + assert data['data']['users'][0]['is_remote'] is False assert data['data']['users'][0]['nb_sports'] == 2 assert data['data']['users'][0]['nb_workouts'] == 2 assert len(data['data']['users'][0]['records']) == 8 assert data['data']['users'][0]['sports_list'] == [1, 2] assert data['data']['users'][0]['total_distance'] == 22.0 assert data['data']['users'][0]['total_duration'] == '2:40:00' - assert data['data']['users'][1]['nb_sports'] == 1 - assert data['data']['users'][1]['nb_workouts'] == 1 - assert len(data['data']['users'][1]['records']) == 4 - assert data['data']['users'][1]['sports_list'] == [1] - assert data['data']['users'][1]['total_distance'] == 15 - assert data['data']['users'][1]['total_duration'] == '1:00:00' - assert data['data']['users'][2]['nb_sports'] == 0 - assert data['data']['users'][2]['nb_workouts'] == 0 - assert len(data['data']['users'][2]['records']) == 0 - assert data['data']['users'][2]['sports_list'] == [] - assert data['data']['users'][2]['total_distance'] == 0 - assert data['data']['users'][2]['total_duration'] == '0:00:00' + assert data['data']['users'][1]['is_remote'] is False + assert data['data']['users'][1]['nb_sports'] == 0 + assert data['data']['users'][1]['nb_workouts'] == 0 + assert len(data['data']['users'][1]['records']) == 0 + assert data['data']['users'][1]['sports_list'] == [] + assert data['data']['users'][1]['total_distance'] == 0 + assert data['data']['users'][1]['total_duration'] == '0:00:00' + assert data['data']['users'][2]['is_remote'] is False + assert data['data']['users'][2]['nb_sports'] == 1 + assert data['data']['users'][2]['nb_workouts'] == 1 + assert len(data['data']['users'][2]['records']) == 4 + assert data['data']['users'][2]['sports_list'] == [1] + assert data['data']['users'][2]['total_distance'] == 15 + assert data['data']['users'][2]['total_duration'] == '1:00:00' assert 'imperial_units' not in data['data']['users'][0] assert 'imperial_units' not in data['data']['users'][1] assert 'imperial_units' not in data['data']['users'][2] @@ -656,8 +663,8 @@ def test_it_gets_users_list_ordered_by_admin_rights( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] + assert 'sam' in data['data']['users'][0]['username'] + assert 'toto' in data['data']['users'][1]['username'] assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -683,8 +690,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_ascending( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] + assert 'sam' in data['data']['users'][0]['username'] + assert 'toto' in data['data']['users'][1]['username'] assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -711,8 +718,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_descending( assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] - assert 'sam' in data['data']['users'][2]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -904,6 +911,31 @@ def test_it_users_list_with_complex_query( } +class TestGetRemoteUsers(ApiTestCaseMixin): + def test_it_returns_error_when_federation_is_disabled( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/users/remote', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert ( + data['message'] + == 'error, federation is disabled for this instance' + ) + + class TestGetUserPicture: def test_it_return_error_if_user_has_no_picture( self, app: Flask, user_1: User diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index f2746c6b9..872ee3a3a 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -140,6 +140,12 @@ def test_it_returns_following_count( serialized_user = user_1.serialize() assert serialized_user['following'] == 1 + def test_user_is_not_remote_when_federation_is_disabled( + self, app: Flask, user_1: User + ) -> None: + assert user_1.is_remote is False + assert user_1.serialize()['is_remote'] is False + class TestUserSportModel: def test_user_model( diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 618a53afb..eab5aea0a 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -317,7 +317,10 @@ def get_authenticated_user_profile( "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "sam@example.com", "first_name": null, + "followers": 0, + "following": 0, "imperial_units": false, + "is_remote": false, "language": "en", "last_name": null, "location": null, @@ -419,7 +422,10 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "sam@example.com", "first_name": null, + "followers": 0, + "following": 0, "imperial_units": false, + "is_remote": false, "language": "en", "last_name": null, "location": null, @@ -581,7 +587,10 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "sam@example.com", "first_name": null, + "followers": 0, + "following": 0, "imperial_units": false, + "is_remote": false, "language": "en", "last_name": null, "location": null, diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 1e5cf66a3..e3e879e76 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -341,6 +341,10 @@ def create_actor(self) -> None: self.actor_id = actor.id db.session.commit() + @property + def is_remote(self) -> bool: + return self.actor.is_remote + def serialize(self, role: Optional[UserRole] = None) -> Dict: sports = [] total = (0, '0:00:00') @@ -368,6 +372,7 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: 'bio': self.bio, 'followers': self.followers.count(), 'following': self.following.count(), + 'is_remote': self.is_remote, 'location': self.location, 'birth_date': self.birth_date, 'picture': self.picture is not None, diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 4a3d58edb..de8e1a671 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -3,14 +3,16 @@ from typing import Any, Dict, Tuple, Union import click -from flask import Blueprint, request, send_file +from flask import Blueprint, current_app, request, send_file from sqlalchemy import exc from fittrackee import appLog, db +from fittrackee.federation.decorators import federation_required from fittrackee.federation.exceptions import ( ActorNotFoundException, DomainNotFoundException, ) +from fittrackee.federation.models import Actor, Domain from fittrackee.federation.utils_user import get_user_from_username from fittrackee.files import get_absolute_file_path from fittrackee.responses import ( @@ -48,11 +50,73 @@ def set_admin(username: str) -> None: print(f"User '{username}' not found.") +def get_users_list(auth_user: User, remote: bool = False) -> Dict: + params = request.args.copy() + page = int(params.get('page', 1)) + per_page = int(params.get('per_page', USERS_PER_PAGE)) + if per_page > 50: + per_page = 50 + order_by = params.get('order_by', 'username') + order = params.get('order', 'asc') + query = params.get('q') + users_pagination = ( + User.query.join(Actor, Actor.id == User.actor_id) + .join(Domain, Domain.id == Actor.domain_id) + .filter( + User.username.like('%' + query + '%') if query else True, + Domain.name != current_app.config['AP_DOMAIN'] + if remote + else Domain.name == current_app.config['AP_DOMAIN'], + ) + .order_by( + User.workouts_count.asc() # type: ignore + if order_by == 'workouts_count' and order == 'asc' + else True, + User.workouts_count.desc() # type: ignore + if order_by == 'workouts_count' and order == 'desc' + else True, + User.username.asc() + if order_by == 'username' and order == 'asc' + else True, + User.username.desc() + if order_by == 'username' and order == 'desc' + else True, + User.created_at.asc() + if order_by == 'created_at' and order == 'asc' + else True, + User.created_at.desc() + if order_by == 'created_at' and order == 'desc' + else True, + User.admin.asc() + if order_by == 'admin' and order == 'asc' + else True, + User.admin.desc() + if order_by == 'admin' and order == 'desc' + else True, + ) + .paginate(page, per_page, False) + ) + users = users_pagination.items + role = UserRole.ADMIN if auth_user.admin else UserRole.USER + return { + 'status': 'success', + 'data': {'users': [user.serialize(role) for user in users]}, + 'pagination': { + 'has_next': users_pagination.has_next, + 'has_prev': users_pagination.has_prev, + 'page': users_pagination.page, + 'pages': users_pagination.pages, + 'total': users_pagination.total, + }, + } + + @users_blueprint.route('/users', methods=['GET']) @authenticate def get_users(auth_user: User) -> Dict: """ - Get all users + Get all users (it returns only local users if federation is enabled). + If authenticated user has admin rights, users email is returned. **Example request**: @@ -87,8 +151,9 @@ def get_users(auth_user: User) -> Dict: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "is_remote": false, "last_name": null, "location": null, "nb_sports": 3, @@ -137,7 +202,6 @@ def get_users(auth_user: User) -> Dict: 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin" @@ -149,7 +213,9 @@ def get_users(auth_user: User) -> Dict: "created_at": "Sat, 20 Jul 2019 11:27:03 GMT", "email": "sam@example.com", "first_name": null, - "language": "fr", + "followers": 0, + "following": 0, + "is_remote": false, "last_name": null, "location": null, "nb_sports": 0, @@ -157,7 +223,6 @@ def get_users(auth_user: User) -> Dict: "picture": false, "records": [], "sports_list": [], - "timezone": "Europe/Paris", "total_distance": 0, "total_duration": "0:00:00", "username": "sam" @@ -171,8 +236,9 @@ def get_users(auth_user: User) -> Dict: :query integer per_page: number of users per page (default: 10, max: 50) :query string q: query on user name :query string order_by: sorting criteria (``username``, ``created_at``, - ``workouts_count``, ``admin``) - :query string order: sorting order (default: ``asc``) + ``workouts_count``, ``admin``, + default: ``username``) + :query string order: sorting order (``asc``, ``desc``, default: ``asc``) :reqheader Authorization: OAuth 2.0 Bearer Token @@ -183,59 +249,89 @@ def get_users(auth_user: User) -> Dict: - invalid token, please log in again """ - params = request.args.copy() - page = int(params.get('page', 1)) - per_page = int(params.get('per_page', USERS_PER_PAGE)) - if per_page > 50: - per_page = 50 - order_by = params.get('order_by') - order = params.get('order', 'asc') - query = params.get('q') - users_pagination = ( - User.query.filter( - User.username.like('%' + query + '%') if query else True, - ) - .order_by( - User.workouts_count.asc() # type: ignore - if order_by == 'workouts_count' and order == 'asc' - else True, - User.workouts_count.desc() # type: ignore - if order_by == 'workouts_count' and order == 'desc' - else True, - User.username.asc() - if order_by == 'username' and order == 'asc' - else True, - User.username.desc() - if order_by == 'username' and order == 'desc' - else True, - User.created_at.asc() - if order_by == 'created_at' and order == 'asc' - else True, - User.created_at.desc() - if order_by == 'created_at' and order == 'desc' - else True, - User.admin.asc() - if order_by == 'admin' and order == 'asc' - else True, - User.admin.desc() - if order_by == 'admin' and order == 'desc' - else True, - ) - .paginate(page, per_page, False) - ) - users = users_pagination.items - role = UserRole.ADMIN if auth_user.admin else UserRole.USER - return { - 'status': 'success', - 'data': {'users': [user.serialize(role) for user in users]}, - 'pagination': { - 'has_next': users_pagination.has_next, - 'has_prev': users_pagination.has_prev, - 'page': users_pagination.page, - 'pages': users_pagination.pages, - 'total': users_pagination.total, + return get_users_list(auth_user) + + +@users_blueprint.route('/users/remote', methods=['GET']) +@federation_required +@authenticate +def get_remote_users( + auth_user: User, + app_domain: Domain, +) -> Dict: + """ + Get all remote users (only if federation is enabled) + + **Example request**: + + - without parameters + + .. sourcecode:: http + + GET /api/users/remote HTTP/1.1 + Content-Type: application/json + + - with some query parameters + + .. sourcecode:: http + + GET /api/users/remote?order_by=username HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "users": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Sat, 20 Jul 2019 11:27:03 GMT", + "first_name": null, + "followers": 0, + "following": 0, + "is_remote": true, + "last_name": null, + "location": null, + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0, + "total_duration": "0:00:00", + "username": "sam" + } + ] }, - } + "status": "success" + } + + :query integer page: page if using pagination (default: 1) + :query integer per_page: number of users per page (default: 10, max: 50) + :query string q: query on user name + :query string order_by: sorting criteria (``username``, ``created_at``, + ``workouts_count``, ``admin``, + default: ``username``) + :query string order: sorting order (``asc``, ``desc``, default: ``asc``) + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: success + :statuscode 401: + - provide a valid auth token + - signature expired, please log in again + - invalid token, please log in again + :statuscode 403: Error. Federation is disabled for this instance. + + """ + return get_users_list(auth_user, remote=True) @users_blueprint.route('/users/', methods=['GET']) @@ -244,7 +340,8 @@ def get_single_user( auth_user: User, user_name: str ) -> Union[Dict, HttpResponse]: """ - Get single user details + Get single user details. + If authenticated user has admin rights, user email is returned. **Example request**: @@ -269,8 +366,9 @@ def get_single_user( "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "is_remote": false, "last_name": null, "location": null, "nb_sports": 3, @@ -319,7 +417,6 @@ def get_single_user( 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin" @@ -422,8 +519,9 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "is_remote": false, "last_name": null, "location": null, "nb_workouts": 6, @@ -472,7 +570,6 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin" @@ -733,7 +830,7 @@ def get_followers( ) -> Union[Dict, HttpResponse]: """ Get user followers. - If the authenticate user has admin rights, it returns following users with + If the authenticated user has admin rights, it returns following users with additional field 'email' **Example request**: @@ -770,6 +867,7 @@ def get_followers( "first_name": null, "followers": 1, "following": 1, + "is_remote": false, "last_name": null, "location": null, "nb_sports": 0, @@ -857,6 +955,7 @@ def get_following( "first_name": null, "followers": 1, "following": 1, + "is_remote": false, "last_name": null, "location": null, "nb_sports": 0, From 0cf10d319f9b0a0063d03f3a07bd0f3016dc7a6e Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 17:20:56 +0100 Subject: [PATCH 059/238] API - add is_remote field to user to simplify queries --- fittrackee/federation/utils_user.py | 1 + .../23_8842c351a2d8_init_federation.py | 10 +++- .../tests/fixtures/fixtures_federation.py | 49 +------------------ .../fixtures/fixtures_federation_users.py | 46 ++++++++++++++++- fittrackee/tests/users/test_users_api.py | 12 ++--- fittrackee/users/models.py | 40 +++++++-------- fittrackee/users/users.py | 12 ++--- 7 files changed, 86 insertions(+), 84 deletions(-) diff --git a/fittrackee/federation/utils_user.py b/fittrackee/federation/utils_user.py index 39c6fdb98..8759d373d 100644 --- a/fittrackee/federation/utils_user.py +++ b/fittrackee/federation/utils_user.py @@ -83,6 +83,7 @@ def create_remote_user(remote_actor_url: Optional[str]) -> Actor: username=remote_actor_object['name'], email=None, password=None, + is_remote=True, ) db.session.add(user) user.actor_id = actor.id diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index 3826ff43f..b922b818f 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -88,6 +88,11 @@ def upgrade(): nullable=True) ) op.add_column('users', sa.Column('actor_id', sa.Integer(), nullable=True)) + op.add_column( + 'users', + sa.Column('is_remote', sa.Boolean(), + nullable=True) + ) op.create_unique_constraint('users_actor_id_key', 'users', ['actor_id']) op.create_foreign_key( 'users_actor_id_fkey', 'users', 'actors', ['actor_id'], ['id'] @@ -116,7 +121,9 @@ def upgrade(): domain = connection.execute(domain_table.select()).fetchone() for user in connection.execute(user_helper.select()): op.execute( - "UPDATE users SET manually_approves_followers = True " + "UPDATE users " + "SET manually_approves_followers = True, " + " is_remote = False " f"WHERE users.id = {user.id}" ) created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') @@ -147,6 +154,7 @@ def upgrade(): f'UPDATE users SET actor_id = {actor.id} WHERE users.id = {user.id}' ) op.alter_column('users', 'manually_approves_followers', nullable=False) + op.alter_column('users', 'is_remote', nullable=False) op.create_unique_constraint( 'username_actor_id_unique', 'users', ['username', 'actor_id'] ) diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py index b360e1a19..d8176a2f7 100644 --- a/fittrackee/tests/fixtures/fixtures_federation.py +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -3,9 +3,8 @@ from fittrackee import db from fittrackee.federation.models import Actor, Domain -from fittrackee.users.models import User -from ..utils import RandomActor, get_remote_user_object, random_domain +from ..utils import RandomActor, random_domain @pytest.fixture() @@ -25,52 +24,6 @@ def remote_domain(app_with_federation: Flask) -> Domain: return remote_domain -def generate_remote_user( - remote_domain: Domain, without_profile_page: bool = False -) -> User: - domain = f'https://{remote_domain.name}' - user_name = 'test' - if without_profile_page: - remote_user_object = get_remote_user_object( - username='Test', - preferred_username=user_name, - domain=domain, - ) - else: - remote_user_object = get_remote_user_object( - username='Test', - preferred_username=user_name, - domain=domain, - profile_url=f'{domain}/{user_name}', - ) - actor = Actor( - preferred_username=user_name, - domain_id=remote_domain.id, - remote_user_data=remote_user_object, - ) - db.session.add(actor) - db.session.flush() - user = User( - username=user_name, - email=None, - password=None, - ) - db.session.add(user) - user.actor_id = actor.id - db.session.commit() - return user - - -@pytest.fixture() -def remote_user(remote_domain: Domain) -> User: - return generate_remote_user(remote_domain) - - -@pytest.fixture() -def remote_user_without_profile_page(remote_domain: Domain) -> User: - return generate_remote_user(remote_domain, without_profile_page=True) - - @pytest.fixture() def random_actor() -> RandomActor: return RandomActor() diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py index c10746730..8b84633dc 100644 --- a/fittrackee/tests/fixtures/fixtures_federation_users.py +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -1,8 +1,52 @@ import pytest +from fittrackee import db +from fittrackee.federation.models import Actor, Domain from fittrackee.users.models import FollowRequest, User -from ..utils import generate_follow_request +from ..utils import generate_follow_request, get_remote_user_object + + +def generate_remote_user( + remote_domain: Domain, without_profile_page: bool = False +) -> User: + domain = f'https://{remote_domain.name}' + user_name = 'test' + if without_profile_page: + remote_user_object = get_remote_user_object( + username='Test', + preferred_username=user_name, + domain=domain, + ) + else: + remote_user_object = get_remote_user_object( + username='Test', + preferred_username=user_name, + domain=domain, + profile_url=f'{domain}/{user_name}', + ) + actor = Actor( + preferred_username=user_name, + domain_id=remote_domain.id, + remote_user_data=remote_user_object, + ) + db.session.add(actor) + db.session.flush() + user = User(username=user_name, email=None, password=None, is_remote=True) + db.session.add(user) + user.actor_id = actor.id + db.session.commit() + return user + + +@pytest.fixture() +def remote_user(remote_domain: Domain) -> User: + return generate_remote_user(remote_domain) + + +@pytest.fixture() +def remote_user_without_profile_page(remote_domain: Domain) -> User: + return generate_remote_user(remote_domain, without_profile_page=True) @pytest.fixture() diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index ba22fd66f..7b845e631 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -663,8 +663,8 @@ def test_it_gets_users_list_ordered_by_admin_rights( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'sam' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -690,8 +690,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_ascending( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'sam' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, @@ -718,8 +718,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_descending( assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] + assert 'toto' in data['data']['users'][1]['username'] + assert 'sam' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index e3e879e76..1fd467066 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -126,8 +126,15 @@ class User(BaseModel): bio = db.Column(db.String(200), nullable=True) picture = db.Column(db.String(255), nullable=True) timezone = db.Column(db.String(50), nullable=True) - # does the week start Monday? + # weekm: does the week start Monday? weekm = db.Column(db.Boolean(50), default=False, nullable=False) + language = db.Column(db.String(50), nullable=True) + imperial_units = db.Column(db.Boolean, default=False, nullable=False) + manually_approves_followers = db.Column( + db.Boolean, default=True, nullable=False + ) + is_remote = db.Column(db.Boolean, default=False, nullable=False) + workouts = db.relationship( 'Workout', lazy=True, @@ -138,8 +145,6 @@ class User(BaseModel): lazy=True, backref=db.backref('user', lazy='joined', single_parent=True), ) - language = db.Column(db.String(50), nullable=True) - imperial_units = db.Column(db.Boolean, default=False, nullable=False) actor = db.relationship(Actor, back_populates='user') received_follow_requests = db.relationship( FollowRequest, @@ -153,9 +158,6 @@ class User(BaseModel): primaryjoin=id == FollowRequest.follower_user_id, lazy='dynamic', ) - manually_approves_followers = db.Column( - db.Boolean, default=True, nullable=False - ) followers = db.relationship( 'User', secondary='follow_requests', @@ -192,6 +194,7 @@ def __init__( email: Optional[str], password: Optional[str], created_at: Optional[datetime] = datetime.utcnow(), + is_remote: bool = False, ) -> None: self.username = username self.email = email # email is None for remote actor @@ -200,9 +203,10 @@ def __init__( password, current_app.config.get('BCRYPT_LOG_ROUNDS') ).decode() if email - else None - ) # no password for remote actor + else None # no password for remote actor + ) self.created_at = created_at + self.is_remote = is_remote @staticmethod def encode_auth_token(user_id: int) -> str: @@ -341,10 +345,6 @@ def create_actor(self) -> None: self.actor_id = actor.id db.session.commit() - @property - def is_remote(self) -> bool: - return self.actor.is_remote - def serialize(self, role: Optional[UserRole] = None) -> Dict: sports = [] total = (0, '0:00:00') @@ -364,26 +364,26 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: .first() ) serialized_user = { - 'username': self.username, - 'created_at': self.created_at, 'admin': self.admin, - 'first_name': self.first_name, - 'last_name': self.last_name, 'bio': self.bio, + 'birth_date': self.birth_date, + 'created_at': self.created_at, + 'first_name': self.first_name, 'followers': self.followers.count(), 'following': self.following.count(), 'is_remote': self.is_remote, + 'last_name': self.last_name, 'location': self.location, - 'birth_date': self.birth_date, - 'picture': self.picture is not None, 'nb_sports': len(sports), 'nb_workouts': self.workouts_count, + 'picture': self.picture is not None, 'records': [record.serialize() for record in self.records], 'sports_list': [ sport for sportslist in sports for sport in sportslist ], 'total_distance': float(total[0]), 'total_duration': str(total[1]), + 'username': self.username, } if role in [UserRole.AUTH_USER, UserRole.ADMIN]: @@ -393,10 +393,10 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: serialized_user = { **serialized_user, **{ + 'imperial_units': self.imperial_units, + 'language': self.language, 'timezone': self.timezone, 'weekm': self.weekm, - 'language': self.language, - 'imperial_units': self.imperial_units, }, } diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index de8e1a671..43461fa8e 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -3,7 +3,7 @@ from typing import Any, Dict, Tuple, Union import click -from flask import Blueprint, current_app, request, send_file +from flask import Blueprint, request, send_file from sqlalchemy import exc from fittrackee import appLog, db @@ -12,7 +12,7 @@ ActorNotFoundException, DomainNotFoundException, ) -from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.models import Domain from fittrackee.federation.utils_user import get_user_from_username from fittrackee.files import get_absolute_file_path from fittrackee.responses import ( @@ -60,13 +60,9 @@ def get_users_list(auth_user: User, remote: bool = False) -> Dict: order = params.get('order', 'asc') query = params.get('q') users_pagination = ( - User.query.join(Actor, Actor.id == User.actor_id) - .join(Domain, Domain.id == Actor.domain_id) - .filter( + User.query.filter( User.username.like('%' + query + '%') if query else True, - Domain.name != current_app.config['AP_DOMAIN'] - if remote - else Domain.name == current_app.config['AP_DOMAIN'], + User.is_remote == remote, ) .order_by( User.workouts_count.asc() # type: ignore From 885a24ef2f9f5b2f8a688c5dd0811792df770baa Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 8 Dec 2021 17:53:42 +0100 Subject: [PATCH 060/238] API - exclude remote users for registration status --- fittrackee/application/models.py | 4 +++- .../tests/federation/application/__init__.py | 0 .../application/test_app_config_model.py | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 fittrackee/tests/federation/application/__init__.py create mode 100644 fittrackee/tests/federation/application/test_app_config_model.py diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index e77ae8958..5eb90db7c 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -25,7 +25,9 @@ class AppConfig(BaseModel): @property def is_registration_enabled(self) -> bool: try: - nb_users = User.query.count() + nb_users = User.query.filter( + User.is_remote == False # noqa + ).count() except exc.ProgrammingError as e: # workaround for user model related migrations if 'psycopg2.errors.UndefinedColumn' in str(e): diff --git a/fittrackee/tests/federation/application/__init__.py b/fittrackee/tests/federation/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/application/test_app_config_model.py b/fittrackee/tests/federation/application/test_app_config_model.py new file mode 100644 index 000000000..6f589da07 --- /dev/null +++ b/fittrackee/tests/federation/application/test_app_config_model.py @@ -0,0 +1,22 @@ +from flask import Flask + +from fittrackee.application.models import AppConfig +from fittrackee.users.models import User + + +class TestConfigModelWithRemoteUsers: + def test_it_returns_registration_is_enabled( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + app_config = AppConfig.query.first() + app_config.max_users = 2 + + assert app_config.is_registration_enabled + + def test_it_returns_registration_is_disabled( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + app_config = AppConfig.query.first() + app_config.max_users = 1 + + assert app_config.is_registration_enabled is False From a821a1c294a18d1705e68eb9b10a70f2ed5e9d74 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 8 Dec 2021 18:34:18 +0100 Subject: [PATCH 061/238] API - speed up tests by patching actor keys generation (except for tests that need it) --- fittrackee/tests/conftest.py | 19 +++++++++++++++++++ .../federation/test_federation_models.py | 1 + .../federation/test_federation_signature.py | 8 ++++++++ .../federation/federation/test_users_inbox.py | 3 +++ pyproject.toml | 5 +++++ 5 files changed, 36 insertions(+) diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index a36ba6d9f..ba9e2c44f 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -1,4 +1,9 @@ import os +from typing import Iterator +from unittest.mock import patch +from uuid import uuid4 + +import pytest os.environ['FLASK_ENV'] = 'testing' os.environ['APP_SETTINGS'] = 'fittrackee.config.TestingConfig' @@ -12,3 +17,17 @@ 'fittrackee.tests.fixtures.fixtures_workouts', 'fittrackee.tests.fixtures.fixtures_users', ] + + +@pytest.fixture(autouse=True) +def default_generate_keys_fixture( + request: pytest.FixtureRequest, +) -> Iterator[None]: + if 'disable_autouse_generate_keys' in request.keywords: + yield + else: + with patch( + 'fittrackee.federation.models.generate_keys' + ) as generate_keys_mock: + generate_keys_mock.return_value = (uuid4().hex, uuid4().hex) + yield diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index 0c7ceb43b..d07bec181 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -122,6 +122,7 @@ def test_it_returns_serialized_object( == f'https://{ap_url}/federation/inbox' ) + @pytest.mark.disable_autouse_generate_keys def test_generated_key_is_valid( self, app_with_federation: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py index 92ddbf25a..546ceff64 100644 --- a/fittrackee/tests/federation/federation/test_federation_signature.py +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -407,6 +407,7 @@ def test_verify_do_not_raise_error_if_http_digest_is_valid( class TestSignatureVerify(SignatureVerificationTestCase): + @pytest.mark.disable_autouse_generate_keys def test_it_raises_error_if_header_actor_is_different_from_activity_actor( self, app_with_federation: Flask, user_1: User, user_2: User ) -> None: @@ -431,6 +432,7 @@ def test_it_raises_error_if_header_actor_is_different_from_activity_actor( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_raises_error_if_header_date_is_invalid( self, app_with_federation: Flask, user_1: User ) -> None: @@ -458,6 +460,7 @@ def test_verify_raises_error_if_header_date_is_invalid( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_raises_error_if_public_key_is_invalid( self, app_with_federation: Flask, user_1: User ) -> None: @@ -481,6 +484,7 @@ def test_verify_raises_error_if_public_key_is_invalid( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_raises_error_if_algorithm_is_not_supported( self, app_with_federation: Flask, user_1: User ) -> None: @@ -510,6 +514,7 @@ def test_verify_raises_error_if_algorithm_is_not_supported( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_raises_error_if_http_digest_is_invalid( self, app_with_federation: Flask, user_1: User ) -> None: @@ -537,6 +542,7 @@ def test_verify_raises_error_if_http_digest_is_invalid( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( self, app_with_federation: Flask, user_1: User ) -> None: @@ -565,6 +571,7 @@ def test_verify_raises_error_if_signature_is_invalid_due_to_keys_update( ): sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_does_not_raise_error_if_signature_is_valid( self, app_with_federation: Flask, user_1: User ) -> None: @@ -588,6 +595,7 @@ def test_verify_does_not_raise_error_if_signature_is_valid( sig_verification.verify() + @pytest.mark.disable_autouse_generate_keys def test_verify_does_not_raise_error_if_signature_without_digest_is_valid( self, app_with_federation: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/federation/federation/test_users_inbox.py b/fittrackee/tests/federation/federation/test_users_inbox.py index d7cc0ebac..bac0e7888 100644 --- a/fittrackee/tests/federation/federation/test_users_inbox.py +++ b/fittrackee/tests/federation/federation/test_users_inbox.py @@ -147,6 +147,7 @@ def test_it_returns_401_if_headers_are_missing( assert 'error' in data['status'] assert 'Invalid signature.' in data['message'] + @pytest.mark.disable_autouse_generate_keys def test_it_returns_401_if_signature_is_invalid( self, app_with_federation: Flask, user_1: User ) -> None: @@ -175,6 +176,7 @@ def test_it_returns_401_if_signature_is_invalid( assert 'error' in data['status'] assert 'Invalid signature.' in data['message'] + @pytest.mark.disable_autouse_generate_keys @patch('fittrackee.federation.inbox.handle_activity') def test_it_returns_200_if_activity_and_signature_are_valid( self, @@ -191,6 +193,7 @@ def test_it_returns_200_if_activity_and_signature_are_valid( data = json.loads(response.data.decode()) assert 'success' in data['status'] + @pytest.mark.disable_autouse_generate_keys @patch('fittrackee.federation.inbox.handle_activity') def test_it_calls_handle_activity_task( self, diff --git a/pyproject.toml b/pyproject.toml index 9f7a0c72a..efbd1cf74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,11 @@ include_trailing_comma = true force_grid_wrap = 0 combine_as_imports = true +[tool.pytest.ini_options] +markers = [ + "disable_autouse_generate_keys: disable patch for keys generation", +] + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" From 16f5c90fa46bc56a0fb5f81887683e7693a99bbb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 8 Dec 2021 19:14:20 +0100 Subject: [PATCH 062/238] API - return actor icon if user has picture --- fittrackee/federation/models.py | 19 ++++++++++++++++++- .../federation/test_federation_models.py | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/fittrackee/federation/models.py b/fittrackee/federation/models.py index af719b689..115adbd28 100644 --- a/fittrackee/federation/models.py +++ b/fittrackee/federation/models.py @@ -10,6 +10,12 @@ from .enums import ActorType from .utils import generate_keys, get_ap_url +MEDIA_TYPE = { + 'gif': 'image/gif', + 'jpg': 'image/jpeg', + 'png': 'image/png', +} + class Domain(BaseModel): """ActivityPub Domain""" @@ -144,7 +150,7 @@ def update_remote_data(self, remote_user_data: Dict) -> None: self.last_fetch_date = datetime.utcnow() def serialize(self) -> Dict: - return { + actor_dict = { '@context': AP_CTX, 'id': self.activitypub_id, 'type': self.type.value, @@ -163,3 +169,14 @@ def serialize(self) -> Dict: }, 'endpoints': {'sharedInbox': self.shared_inbox_url}, } + if self.user.picture: + extension = self.user.picture.rsplit('.', 1)[1].lower() + actor_dict['icon'] = { + 'type': 'Image', + 'mediaType': MEDIA_TYPE[extension], + 'url': ( + f'https://{current_app.config["AP_DOMAIN"]}' + f'/api/users/{self.user.username}/picture' + ), + } + return actor_dict diff --git a/fittrackee/tests/federation/federation/test_federation_models.py b/fittrackee/tests/federation/federation/test_federation_models.py index d07bec181..10697e369 100644 --- a/fittrackee/tests/federation/federation/test_federation_models.py +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -121,6 +121,21 @@ def test_it_returns_serialized_object( serialized_actor['endpoints']['sharedInbox'] == f'https://{ap_url}/federation/inbox' ) + assert 'icon' not in serialized_actor + + def test_it_returns_icon_if_user_has_picture( + self, app_with_federation: Flask, user_1: User + ) -> None: + user_1.picture = 'path/image.jpg' + actor_1 = user_1.actor + serialized_actor = actor_1.serialize() + ap_url = app_with_federation.config['AP_DOMAIN'] + + assert serialized_actor['icon'] == { + 'type': 'Image', + 'mediaType': 'image/jpeg', + 'url': f'https://{ap_url}/api/users/{user_1.username}/picture', + } @pytest.mark.disable_autouse_generate_keys def test_generated_key_is_valid( From a5d4aeb77a0b8fb62208ca24e2a127e5e7669d93 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 11 Dec 2021 15:31:09 +0100 Subject: [PATCH 063/238] API - delete actor on user deletion --- .../tests/federation/users/test_users_api.py | 18 ++++++++++++++++++ fittrackee/tests/users/test_users_api.py | 16 ++++++++++++++++ fittrackee/users/users.py | 1 + 3 files changed, 35 insertions(+) diff --git a/fittrackee/tests/federation/users/test_users_api.py b/fittrackee/tests/federation/users/test_users_api.py index 9214bbdd6..d66df4838 100644 --- a/fittrackee/tests/federation/users/test_users_api.py +++ b/fittrackee/tests/federation/users/test_users_api.py @@ -2,6 +2,7 @@ from flask import Flask +from fittrackee.federation.models import Actor from fittrackee.users.models import User from ...test_case_mixins import ApiTestCaseMixin @@ -59,3 +60,20 @@ def test_it_gets_users_list( assert len(data['data']['users']) == 1 assert data['data']['users'][0]['username'] == remote_user.username assert data['data']['users'][0]['is_remote'] + + +class TestDeleteUser(ApiTestCaseMixin): + def test_it_deletes_actor_when_deleting_user( + self, app_with_federation: Flask, user_1_admin: User, user_2: User + ) -> None: + actor_id = user_2.actor_id + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1_admin.email + ) + + client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert Actor.query.filter_by(id=actor_id).first() is None diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 7b845e631..1efc3144c 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -6,6 +6,7 @@ from flask import Flask +from fittrackee.federation.models import Actor from fittrackee.users.models import User, UserSportPreference from fittrackee.workouts.models import Sport, Workout @@ -1293,3 +1294,18 @@ def test_it_does_not_enable_registration_on_user_delete( data = json.loads(response.data.decode()) assert data['status'] == 'error' assert data['message'] == 'error, registration is disabled' + + def test_it_deletes_actor_when_deleting_user( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + actor_id = user_2.actor_id + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert Actor.query.filter_by(id=actor_id).first() is None diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 43461fa8e..70609bcfc 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -680,6 +680,7 @@ def delete_user( db.session.flush() user_picture = user.picture db.session.delete(user) + db.session.delete(user.actor) db.session.commit() if user_picture: picture_path = get_absolute_file_path(user.picture) From 3ce4b426ab167afb1a048a996af810fb98810c60 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Feb 2022 12:23:11 +0100 Subject: [PATCH 064/238] API - return only local users count in app stats --- .../tests/federation/workouts/__init__.py | 0 .../federation/workouts/test_stats_api.py | 26 +++++++++++++++++++ fittrackee/workouts/stats.py | 5 ++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 fittrackee/tests/federation/workouts/__init__.py create mode 100644 fittrackee/tests/federation/workouts/test_stats_api.py diff --git a/fittrackee/tests/federation/workouts/__init__.py b/fittrackee/tests/federation/workouts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/workouts/test_stats_api.py b/fittrackee/tests/federation/workouts/test_stats_api.py new file mode 100644 index 000000000..0d888f303 --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_stats_api.py @@ -0,0 +1,26 @@ +import json + +from flask import Flask + +from fittrackee.users.models import User + +from ...test_case_mixins import ApiTestCaseMixin + + +class TestGetAllStats(ApiTestCaseMixin): + def test_it_returns_local_users_count( + self, app_with_federation: Flask, user_1_admin: User, remote_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1_admin.email + ) + + response = client.get( + '/api/stats/all', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['users'] == 1 diff --git a/fittrackee/workouts/stats.py b/fittrackee/workouts/stats.py index 9c3da1484..7dd4e23e2 100644 --- a/fittrackee/workouts/stats.py +++ b/fittrackee/workouts/stats.py @@ -380,7 +380,8 @@ def get_workouts_by_sport( @authenticate_as_admin def get_application_stats(auth_user: User) -> Dict: """ - Get all application statistics + Get all application statistics. + Users count is local users count when federation is enabled. **Example requests**: @@ -417,7 +418,7 @@ def get_application_stats(auth_user: User) -> Dict: """ nb_workouts = Workout.query.filter().count() - nb_users = User.query.filter().count() + nb_users = User.query.filter(User.is_remote == False).count() # noqa nb_sports = ( db.session.query(func.count(Workout.sport_id)) .group_by(Workout.sport_id) From fdeb79c611819e9cd2b176995de559f0c69017b5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 12 Dec 2021 16:27:10 +0100 Subject: [PATCH 065/238] API - fix rollback migration --- .../migrations/versions/23_8842c351a2d8_init_federation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index b922b818f..a982bbdc6 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -199,6 +199,7 @@ def downgrade(): ) op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') op.drop_constraint('users_actor_id_key', 'users', type_='unique') + op.drop_column('users', 'is_remote') op.drop_column('users', 'manually_approves_followers') op.drop_column('users', 'actor_id') From 16a8e686e6a75fe4f1d549cf9197731e73ae43a4 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 18:03:14 +0100 Subject: [PATCH 066/238] API - init privacy levels on workouts related data (WIP) Add different visibility levels (private, followers_only, private) on: - on some user infos - workouts - workout map --- .../23_8842c351a2d8_init_federation.py | 33 +- fittrackee/tests/users/test_auth_api.py | 126 ++---- fittrackee/tests/users/test_users_api.py | 363 +++++++++--------- fittrackee/tests/users/test_users_model.py | 33 +- fittrackee/tests/utils.py | 7 +- fittrackee/users/auth.py | 21 +- fittrackee/users/decorators.py | 32 +- fittrackee/users/models.py | 47 ++- fittrackee/users/privacy_levels.py | 7 + fittrackee/users/users.py | 79 +++- 10 files changed, 423 insertions(+), 325 deletions(-) create mode 100644 fittrackee/users/privacy_levels.py diff --git a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py index a982bbdc6..412d2f33d 100644 --- a/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py +++ b/fittrackee/migrations/versions/23_8842c351a2d8_init_federation.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects.postgresql import ENUM from fittrackee.federation.utils import generate_keys, get_ap_url, remove_url_scheme @@ -20,6 +21,10 @@ branch_labels = None depends_on = None +privacy_levels = ENUM( + 'PUBLIC', 'FOLLOWERS', 'PRIVATE', name='privacy_levels' +) + def upgrade(): op.add_column( @@ -110,7 +115,28 @@ def upgrade(): 'users', 'password', existing_type=sa.VARCHAR(length=255), nullable=True ) + # privacy levels + privacy_levels.create(op.get_bind()) + op.add_column( + 'users', + sa.Column( + 'workouts_visibility', + privacy_levels, + server_default='PRIVATE', + nullable=True, + ), + ) + op.add_column( + 'users', + sa.Column( + 'map_visibility', + privacy_levels, + server_default='PRIVATE', + nullable=True, + ), + ) # create local actors with keys (even if federation is not enabled) + # and update users user_helper = sa.Table( 'users', sa.MetaData(), @@ -123,7 +149,9 @@ def upgrade(): op.execute( "UPDATE users " "SET manually_approves_followers = True, " - " is_remote = False " + " is_remote = False, " + " workouts_visibility = 'PRIVATE', " + " map_visibility = 'PRIVATE' " f"WHERE users.id = {user.id}" ) created_at = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') @@ -199,6 +227,9 @@ def downgrade(): ) op.drop_constraint('users_actor_id_fkey', 'users', type_='foreignkey') op.drop_constraint('users_actor_id_key', 'users', type_='unique') + op.drop_column('users', 'map_visibility') + op.drop_column('users', 'workouts_visibility') + privacy_levels.drop(op.get_bind()) op.drop_column('users', 'is_remote') op.drop_column('users', 'manually_approves_followers') op.drop_column('users', 'actor_id') diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index e06f23ada..3cb2f4a42 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -8,11 +8,13 @@ from freezegun import freeze_time from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.roles import UserRole from fittrackee.users.utils.token import get_user_token from fittrackee.workouts.models import Sport, Workout from ..federation.users.test_auth_api import assert_actor_is_created from ..test_case_mixins import ApiTestCaseMixin +from ..utils import jsonify_dict class TestUserRegistration: @@ -487,24 +489,12 @@ def test_it_returns_user_profile_when_no_workouts_and_no_preferences( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' - assert data['data'] is not None - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert data['data']['created_at'] - assert not data['data']['admin'] - assert data['data']['timezone'] is None - assert data['data']['weekm'] is False - assert data['data']['imperial_units'] is False - assert data['data']['language'] is None - assert data['data']['nb_sports'] == 0 - assert data['data']['nb_workouts'] == 0 - assert data['data']['records'] == [] - assert data['data']['sports_list'] == [] - assert data['data']['total_distance'] == 0 - assert data['data']['total_duration'] == '0:00:00' - assert response.status_code == 200 + assert data['data'] == jsonify_dict( + user_1.serialize(role=UserRole.AUTH_USER) + ) def test_it_returns_user_profile_with_updated_fields( self, app: Flask, user_1_full: User @@ -519,28 +509,11 @@ def test_it_returns_user_profile_with_updated_fields( ) data = json.loads(response.data.decode()) - assert data['status'] == 'success' - assert data['data'] is not None - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert data['data']['created_at'] - assert not data['data']['admin'] - assert data['data']['first_name'] == 'John' - assert data['data']['last_name'] == 'Doe' - assert data['data']['birth_date'] - assert data['data']['bio'] == 'just a random guy' - assert data['data']['imperial_units'] is False - assert data['data']['location'] == 'somewhere' - assert data['data']['timezone'] == 'America/New_York' - assert data['data']['weekm'] is False - assert data['data']['language'] == 'en' - assert data['data']['nb_sports'] == 0 - assert data['data']['nb_workouts'] == 0 - assert data['data']['records'] == [] - assert data['data']['sports_list'] == [] - assert data['data']['total_distance'] == 0 - assert data['data']['total_duration'] == '0:00:00' assert response.status_code == 200 + assert data['status'] == 'success' + assert data['data'] == jsonify_dict( + user_1_full.serialize(role=UserRole.AUTH_USER) + ) def test_it_returns_user_profile_with_workouts( self, @@ -560,22 +533,12 @@ def test_it_returns_user_profile_with_workouts( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' - assert data['data'] is not None - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert data['data']['created_at'] - assert not data['data']['admin'] - assert data['data']['timezone'] is None - assert data['data']['imperial_units'] is False - assert data['data']['nb_sports'] == 2 - assert data['data']['nb_workouts'] == 2 - assert len(data['data']['records']) == 8 - assert data['data']['sports_list'] == [1, 2] - assert data['data']['total_distance'] == 22 - assert data['data']['total_duration'] == '2:40:00' - assert response.status_code == 200 + assert data['data'] == jsonify_dict( + user_1.serialize(role=UserRole.AUTH_USER) + ) def test_it_returns_error_if_headers_are_invalid(self, app: Flask) -> None: client = app.test_client() @@ -611,29 +574,15 @@ def test_it_updates_user_profile(self, app: Flask, user_1: User) -> None: headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' assert data['message'] == 'user profile updated' - assert response.status_code == 200 - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert not data['data']['admin'] - assert data['data']['created_at'] - assert data['data']['first_name'] == 'John' - assert data['data']['last_name'] == 'Doe' assert data['data']['birth_date'] assert data['data']['bio'] == 'Nothing to tell' - assert data['data']['imperial_units'] is False + assert data['data']['first_name'] == 'John' assert data['data']['location'] == 'Somewhere' - assert data['data']['timezone'] is None - assert data['data']['weekm'] is False - assert data['data']['language'] is None - assert data['data']['nb_sports'] == 0 - assert data['data']['nb_workouts'] == 0 - assert data['data']['records'] == [] - assert data['data']['sports_list'] == [] - assert data['data']['total_distance'] == 0 - assert data['data']['total_duration'] == '0:00:00' + assert data['data']['last_name'] == 'Doe' def test_it_updates_user_profile_without_password( self, app: Flask, user_1: User @@ -657,29 +606,15 @@ def test_it_updates_user_profile_without_password( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' assert data['message'] == 'user profile updated' - assert response.status_code == 200 - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert not data['data']['admin'] - assert data['data']['created_at'] - assert data['data']['first_name'] == 'John' - assert data['data']['last_name'] == 'Doe' assert data['data']['birth_date'] assert data['data']['bio'] == 'Nothing to tell' - assert data['data']['imperial_units'] is False + assert data['data']['first_name'] == 'John' assert data['data']['location'] == 'Somewhere' - assert data['data']['timezone'] is None - assert data['data']['weekm'] is False - assert data['data']['language'] is None - assert data['data']['nb_sports'] == 0 - assert data['data']['nb_workouts'] == 0 - assert data['data']['records'] == [] - assert data['data']['sports_list'] == [] - assert data['data']['total_distance'] == 0 - assert data['data']['total_duration'] == '0:00:00' + assert data['data']['last_name'] == 'Doe' def test_it_returns_error_if_fields_are_missing( self, app: Flask, user_1: User @@ -800,34 +735,23 @@ def test_it_updates_user_preferences( weekm=True, language='fr', imperial_units=True, + map_visibility='followers_only', + workouts_visibility='public', ) ), headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' assert data['message'] == 'user preferences updated' - assert response.status_code == 200 - assert data['data']['username'] == 'test' - assert data['data']['email'] == 'test@test.com' - assert not data['data']['admin'] - assert data['data']['created_at'] - assert data['data']['first_name'] is None - assert data['data']['last_name'] is None - assert data['data']['birth_date'] is None - assert data['data']['bio'] is None assert data['data']['imperial_units'] - assert data['data']['location'] is None assert data['data']['timezone'] == 'America/New_York' assert data['data']['weekm'] is True assert data['data']['language'] == 'fr' - assert data['data']['nb_sports'] == 0 - assert data['data']['nb_workouts'] == 0 - assert data['data']['records'] == [] - assert data['data']['sports_list'] == [] - assert data['data']['total_distance'] == 0 - assert data['data']['total_duration'] == '0:00:00' + assert data['data']['map_visibility'] == 'followers_only' + assert data['data']['workouts_visibility'] == 'public' def test_it_returns_error_if_fields_are_missing( self, app: Flask, user_1: User diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 1efc3144c..790a13f10 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -1,59 +1,20 @@ import json from datetime import datetime, timedelta from io import BytesIO -from typing import Dict from unittest.mock import patch from flask import Flask from fittrackee.federation.models import Actor from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.roles import UserRole from fittrackee.workouts.models import Sport, Workout from ..test_case_mixins import ApiTestCaseMixin +from ..utils import jsonify_dict -class GetUserTestCase(ApiTestCaseMixin): - @staticmethod - def assert_user(user_dict: Dict, user: User) -> None: - assert user_dict['username'] == user.username - assert user_dict['created_at'] == user.created_at.strftime( - '%a, %d %b %Y %H:%M:%S GMT' - ) - assert user_dict['admin'] == user.admin - assert user_dict['bio'] is None - assert user_dict['birth_date'] is None - assert user_dict['first_name'] is None - assert user_dict['followers'] == 0 - assert user_dict['following'] == 0 - assert user_dict['is_remote'] is False - assert user_dict['last_name'] is None - assert user_dict['location'] is None - assert 'imperial_units' not in user_dict - assert 'language' not in user_dict - assert 'timezone' not in user_dict - assert 'weekm' not in user_dict - - @staticmethod - def assert_user_2_without_workouts(user_dict: Dict) -> None: - assert user_dict['nb_sports'] == 0 - assert user_dict['nb_workouts'] == 0 - assert user_dict['records'] == [] - assert user_dict['sports_list'] == [] - assert user_dict['total_distance'] == 0 - assert user_dict['total_duration'] == '0:00:00' - - @staticmethod - def assert_user_1_with_workouts(user_dict: Dict) -> None: - assert user_dict['nb_sports'] == 2 - assert user_dict['nb_workouts'] == 2 - assert len(user_dict['records']) == 8 - assert user_dict['sports_list'] == [1, 2] - assert user_dict['total_distance'] == 22 - assert user_dict['total_duration'] == '2:40:00' - - -class TestGetUserAsAdmin(GetUserTestCase): +class TestGetUserAsAdmin(ApiTestCaseMixin): def test_it_gets_single_user_without_workouts( self, app: Flask, user_1_admin: User, user_2: User ) -> None: @@ -67,16 +28,41 @@ def test_it_gets_single_user_without_workouts( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - self.assert_user(user, user_2) - self.assert_user_2_without_workouts(user) - assert user['email'] == user_2.email + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(role=UserRole.ADMIN) + ) def test_it_gets_single_user_with_workouts( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(role=UserRole.ADMIN) + ) + + def test_it_gets_authenticated_user( self, app: Flask, user_1_admin: User, @@ -95,14 +81,13 @@ def test_it_gets_single_user_with_workouts( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - self.assert_user(user, user_1_admin) - self.assert_user_1_with_workouts(user) - assert user['email'] == user_1_admin.email + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(role=UserRole.ADMIN) + ) def test_it_returns_error_if_user_does_not_exist( self, app: Flask, user_1: User @@ -116,14 +101,14 @@ def test_it_returns_error_if_user_does_not_exist( content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 404 + data = json.loads(response.data.decode()) assert 'not found' in data['status'] assert 'user does not exist' in data['message'] -class TestGetUserAsUser(GetUserTestCase): +class TestGetUserAsUser(ApiTestCaseMixin): def test_it_gets_single_user_without_workouts( self, app: Flask, user_1: User, user_2: User ) -> None: @@ -137,42 +122,62 @@ def test_it_gets_single_user_without_workouts( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - self.assert_user(user, user_2) - self.assert_user_2_without_workouts(user) - assert 'email' not in user + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(role=UserRole.USER) + ) def test_it_gets_single_user_with_workouts( self, app: Flask, user_1: User, + user_2: User, sport_1_cycling: Sport, - sport_2_running: Sport, - workout_cycling_user_1: Workout, - workout_running_user_1: Workout, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) response = client.get( - f'/api/users/{user_1.username}', + f'/api/users/{user_2.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 200 data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(role=UserRole.USER) + ) + + def test_it_gets_authenticated_user( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - self.assert_user(user, user_1) - self.assert_user_1_with_workouts(user) - assert 'email' not in user + assert data['data']['users'][0] == jsonify_dict( + user_1.serialize(role=UserRole.USER) + ) def test_it_returns_error_if_user_does_not_exist( self, app: Flask, user_1: User @@ -186,6 +191,61 @@ def test_it_returns_error_if_user_does_not_exist( content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] + + +class TestGetUserAsUnauthenticatedUser(ApiTestCaseMixin): + def test_it_gets_single_user_without_workouts( + self, app: Flask, user_2: User + ) -> None: + client = app.test_client() + + response = client.get( + f'/api/users/{user_2.username}', + content_type='application/json', + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict(user_2.serialize()) + + def test_it_gets_single_user_with_workouts( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.get( + f'/api/users/{user_1.username}', + content_type='application/json', + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict(user_1.serialize()) + + def test_it_returns_error_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + '/api/users/not_existing', + content_type='application/json', + ) data = json.loads(response.data.decode()) assert response.status_code == 404 @@ -193,7 +253,7 @@ def test_it_returns_error_if_user_does_not_exist( assert 'user does not exist' in data['message'] -class TestGetUsersAsAdmin(GetUserTestCase): +class TestGetUsersAsAdmin(ApiTestCaseMixin): def test_it_gets_users_list( self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: @@ -206,52 +266,19 @@ def test_it_gets_users_list( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'created_at' in data['data']['users'][0] - assert 'created_at' in data['data']['users'][1] - assert 'created_at' in data['data']['users'][2] - assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] - assert 'admin@example.com' in data['data']['users'][0]['email'] - assert 'sam@test.com' in data['data']['users'][1]['email'] - assert 'toto@toto.com' in data['data']['users'][2]['email'] - assert data['data']['users'][0]['is_remote'] is False - assert data['data']['users'][0]['nb_sports'] == 0 - assert data['data']['users'][0]['nb_workouts'] == 0 - assert data['data']['users'][0]['records'] == [] - assert data['data']['users'][0]['sports_list'] == [] - assert data['data']['users'][0]['total_distance'] == 0 - assert data['data']['users'][0]['total_duration'] == '0:00:00' - assert data['data']['users'][1]['is_remote'] is False - assert data['data']['users'][1]['records'] == [] - assert data['data']['users'][1]['nb_sports'] == 0 - assert data['data']['users'][1]['nb_workouts'] == 0 - assert data['data']['users'][1]['sports_list'] == [] - assert data['data']['users'][1]['total_distance'] == 0 - assert data['data']['users'][1]['total_duration'] == '0:00:00' - assert data['data']['users'][2]['is_remote'] is False - assert data['data']['users'][2]['nb_sports'] == 0 - assert data['data']['users'][2]['nb_workouts'] == 0 - assert data['data']['users'][2]['records'] == [] - assert data['data']['users'][2]['sports_list'] == [] - assert data['data']['users'][2]['total_distance'] == 0 - assert data['data']['users'][2]['total_duration'] == '0:00:00' - assert 'imperial_units' not in data['data']['users'][0] - assert 'imperial_units' not in data['data']['users'][1] - assert 'imperial_units' not in data['data']['users'][2] - assert 'language' not in data['data']['users'][0] - assert 'language' not in data['data']['users'][1] - assert 'language' not in data['data']['users'][2] - assert 'timezone' not in data['data']['users'][0] - assert 'timezone' not in data['data']['users'][1] - assert 'timezone' not in data['data']['users'][2] - assert 'weekm' not in data['data']['users'][0] - assert 'weekm' not in data['data']['users'][1] - assert 'weekm' not in data['data']['users'][2] + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(role=UserRole.ADMIN) + ) + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(role=UserRole.ADMIN) + ) + assert data['data']['users'][2] == jsonify_dict( + user_2.serialize(role=UserRole.ADMIN) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -281,52 +308,20 @@ def test_it_gets_users_list_with_workouts( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'created_at' in data['data']['users'][0] - assert 'created_at' in data['data']['users'][1] - assert 'created_at' in data['data']['users'][2] - assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] - assert 'admin@example.com' in data['data']['users'][0]['email'] - assert 'sam@test.com' in data['data']['users'][1]['email'] - assert 'toto@toto.com' in data['data']['users'][2]['email'] - assert data['data']['users'][0]['is_remote'] is False - assert data['data']['users'][0]['nb_sports'] == 2 - assert data['data']['users'][0]['nb_workouts'] == 2 - assert len(data['data']['users'][0]['records']) == 8 - assert data['data']['users'][0]['sports_list'] == [1, 2] - assert data['data']['users'][0]['total_distance'] == 22.0 - assert data['data']['users'][0]['total_duration'] == '2:40:00' - assert data['data']['users'][1]['is_remote'] is False - assert data['data']['users'][1]['nb_sports'] == 0 - assert data['data']['users'][1]['nb_workouts'] == 0 - assert len(data['data']['users'][1]['records']) == 0 - assert data['data']['users'][1]['sports_list'] == [] - assert data['data']['users'][1]['total_distance'] == 0 - assert data['data']['users'][1]['total_duration'] == '0:00:00' - assert data['data']['users'][2]['is_remote'] is False - assert data['data']['users'][2]['nb_sports'] == 1 - assert data['data']['users'][2]['nb_workouts'] == 1 - assert len(data['data']['users'][2]['records']) == 4 - assert data['data']['users'][2]['sports_list'] == [1] - assert data['data']['users'][2]['total_distance'] == 15 - assert data['data']['users'][2]['total_duration'] == '1:00:00' - assert 'imperial_units' not in data['data']['users'][0] - assert 'imperial_units' not in data['data']['users'][1] - assert 'imperial_units' not in data['data']['users'][2] - assert 'language' not in data['data']['users'][0] - assert 'language' not in data['data']['users'][1] - assert 'language' not in data['data']['users'][2] - assert 'timezone' not in data['data']['users'][0] - assert 'timezone' not in data['data']['users'][1] - assert 'timezone' not in data['data']['users'][2] - assert 'weekm' not in data['data']['users'][0] - assert 'weekm' not in data['data']['users'][1] - assert 'weekm' not in data['data']['users'][2] + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(role=UserRole.ADMIN) + ) + + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(role=UserRole.ADMIN) + ) + assert data['data']['users'][2] == jsonify_dict( + user_2.serialize(role=UserRole.ADMIN) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -352,8 +347,8 @@ def test_it_gets_first_page_on_users_list( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 2 assert data['pagination'] == { @@ -381,8 +376,8 @@ def test_it_gets_next_page_on_users_list( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 assert data['pagination'] == { @@ -409,8 +404,8 @@ def test_it_gets_empty_next_page_on_users_list( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 0 assert data['pagination'] == { @@ -437,8 +432,8 @@ def test_it_gets_user_list_with_2_per_page( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 2 assert data['pagination'] == { @@ -465,8 +460,8 @@ def test_it_gets_next_page_on_user_list_with_2_per_page( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 assert data['pagination'] == { @@ -489,8 +484,8 @@ def test_it_gets_users_list_ordered_by_username( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -516,8 +511,8 @@ def test_it_gets_users_list_ordered_by_username_ascending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -543,8 +538,8 @@ def test_it_gets_users_list_ordered_by_username_descending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -573,8 +568,8 @@ def test_it_gets_users_list_ordered_by_creation_date( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -603,8 +598,8 @@ def test_it_gets_users_list_ordered_by_creation_date_ascending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -633,8 +628,8 @@ def test_it_gets_users_list_ordered_by_creation_date_descending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -660,8 +655,8 @@ def test_it_gets_users_list_ordered_by_admin_rights( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -687,8 +682,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_ascending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -714,8 +709,8 @@ def test_it_gets_users_list_ordered_by_admin_rights_descending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -747,8 +742,8 @@ def test_it_gets_users_list_ordered_by_workouts_count( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -783,8 +778,8 @@ def test_it_gets_users_list_ordered_by_workouts_count_ascending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] @@ -819,8 +814,8 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] @@ -849,8 +844,8 @@ def test_it_gets_users_list_filtering_on_username( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 assert 'toto' in data['data']['users'][0]['username'] @@ -874,8 +869,8 @@ def test_it_returns_empty_users_list_filtering_on_username( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 0 assert data['pagination'] == { @@ -898,8 +893,8 @@ def test_it_users_list_with_complex_query( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 assert 'admin' in data['data']['users'][0]['username'] @@ -945,8 +940,8 @@ def test_it_return_error_if_user_has_no_picture( response = client.get(f'/api/users/{user_1.username}/picture') - data = json.loads(response.data.decode()) assert response.status_code == 404 + data = json.loads(response.data.decode()) assert 'not found' in data['status'] assert 'No picture.' in data['message'] @@ -957,8 +952,8 @@ def test_it_returns_error_if_user_does_not_exist( response = client.get('/api/users/not_existing/picture') - data = json.loads(response.data.decode()) assert response.status_code == 404 + data = json.loads(response.data.decode()) assert 'not found' in data['status'] assert 'user does not exist' in data['message'] @@ -978,8 +973,8 @@ def test_it_adds_admin_rights_to_a_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 user = data['data']['users'][0] @@ -1000,8 +995,8 @@ def test_it_removes_admin_rights_to_a_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 @@ -1023,8 +1018,8 @@ def test_it_returns_error_if_payload_for_admin_rights_is_empty( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 400 + data = json.loads(response.data.decode()) assert 'error' in data['status'] assert 'invalid payload' in data['message'] @@ -1042,8 +1037,8 @@ def test_it_returns_error_if_payload_for_admin_rights_is_invalid( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 500 + data = json.loads(response.data.decode()) assert 'error' in data['status'] assert ( 'error, please try again or contact the administrator' @@ -1064,8 +1059,8 @@ def test_it_returns_error_if_user_can_not_change_admin_rights( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 403 + data = json.loads(response.data.decode()) assert 'error' in data['status'] assert 'you do not have permissions' in data['message'] @@ -1162,8 +1157,8 @@ def test_user_can_not_delete_another_user_account( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 403 + data = json.loads(response.data.decode()) assert 'error' in data['status'] assert 'you do not have permissions' in data['message'] @@ -1179,8 +1174,8 @@ def test_it_returns_error_when_deleting_non_existing_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 404 + data = json.loads(response.data.decode()) assert 'not found' in data['status'] assert 'user does not exist' in data['message'] @@ -1224,8 +1219,8 @@ def test_admin_can_not_delete_its_own_account_if_no_other_admin( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 403 + data = json.loads(response.data.decode()) assert 'error' in data['status'] assert ( 'you can not delete your account, no other user has admin rights' diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 872ee3a3a..356c4aa34 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -29,19 +29,22 @@ def assert_serialized_used(serialized_user: Dict) -> None: assert serialized_user['location'] is None assert serialized_user['birth_date'] is None assert serialized_user['picture'] is False - assert serialized_user['nb_sports'] == 0 + assert serialized_user['map_visibility'] == 'private' assert serialized_user['nb_workouts'] == 0 - assert serialized_user['records'] == [] - assert serialized_user['sports_list'] == [] - assert serialized_user['total_distance'] == 0 - assert serialized_user['total_duration'] == '0:00:00' + assert serialized_user['workouts_visibility'] == 'private' def test_user_model_as_auth_user(self, app: Flask, user_1: User) -> None: assert '' == str(user_1) serialized_user = user_1.serialize(role=UserRole.AUTH_USER) + self.assert_serialized_used(serialized_user) assert 'test@test.com' == serialized_user['email'] + assert serialized_user['nb_sports'] == 0 + assert serialized_user['records'] == [] + assert serialized_user['sports_list'] == [] + assert serialized_user['total_distance'] == 0 + assert serialized_user['total_duration'] == '0:00:00' assert serialized_user['imperial_units'] is False assert serialized_user['language'] is None assert serialized_user['timezone'] is None @@ -51,8 +54,14 @@ def test_user_model_as_admin(self, app: Flask, user_1: User) -> None: assert '' == str(user_1) serialized_user = user_1.serialize(role=UserRole.ADMIN) + self.assert_serialized_used(serialized_user) assert 'test@test.com' == serialized_user['email'] + assert serialized_user['nb_sports'] == 0 + assert serialized_user['records'] == [] + assert serialized_user['sports_list'] == [] + assert serialized_user['total_distance'] == 0 + assert serialized_user['total_duration'] == '0:00:00' assert 'imperial_units' not in serialized_user assert 'language' not in serialized_user assert 'timezone' not in serialized_user @@ -64,7 +73,13 @@ def test_user_model_as_regular_user( assert '' == str(user_1) serialized_user = user_1.serialize(role=UserRole.USER) + self.assert_serialized_used(serialized_user) + assert serialized_user['nb_sports'] == 0 + assert serialized_user['records'] == [] + assert serialized_user['sports_list'] == [] + assert serialized_user['total_distance'] == 0 + assert serialized_user['total_duration'] == '0:00:00' assert 'email' not in serialized_user assert 'imperial_units' not in serialized_user assert 'language' not in serialized_user @@ -77,8 +92,14 @@ def test_user_model_when_no_role_provided( assert '' == str(user_1) serialized_user = user_1.serialize() + self.assert_serialized_used(serialized_user) assert 'email' not in serialized_user + assert 'nb_sports' not in serialized_user + assert 'sports_list' not in serialized_user + assert 'records' not in serialized_user + assert 'total_distance' not in serialized_user + assert 'total_duration' not in serialized_user assert 'imperial_units' not in serialized_user assert 'language' not in serialized_user assert 'timezone' not in serialized_user @@ -104,7 +125,7 @@ def test_it_returns_user_records( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - serialized_user = user_1.serialize() + serialized_user = user_1.serialize(role=UserRole.USER) assert len(serialized_user['records']) == 4 assert serialized_user['records'][0]['record_type'] == 'AS' assert serialized_user['records'][0]['sport_id'] == sport_1_cycling.id diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index c81e7d174..e551a81bd 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -2,9 +2,10 @@ import string from dataclasses import dataclass from datetime import datetime -from json import dumps +from json import dumps, loads from typing import Dict, Optional, Union +from flask import json as flask_json from requests import Response from fittrackee import db @@ -142,3 +143,7 @@ def generate_follow_request(follower: User, followed: User) -> FollowRequest: db.session.add(follow_request) db.session.commit() return follow_request + + +def jsonify_dict(data: Dict) -> Dict: + return loads(flask_json.dumps(data)) diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index eab5aea0a..1e98c9362 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -27,6 +27,7 @@ from .decorators import authenticate from .models import User, UserSportPreference +from .privacy_levels import PrivacyLevel from .roles import UserRole from .utils.controls import check_passwords, register_controls from .utils.token import decode_user_token @@ -324,6 +325,7 @@ def get_authenticated_user_profile( "language": "en", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -374,7 +376,8 @@ def get_authenticated_user_profile( "total_distance": 67.895, "total_duration": "6:50:27", "username": "sam", - "weekm": false + "weekm": false, + "workouts_visibility": "private" }, "status": "success" } @@ -429,6 +432,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "language": "en", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -480,6 +484,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "total_duration": "6:50:27", "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user profile updated", "status": "success" @@ -594,6 +599,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "language": "en", "last_name": null, "location": null, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -643,8 +649,9 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", - "username": "sam" + "username": "sam", "weekm": true, + "workouts_visibility": "public" }, "message": "user preferences updated", "status": "success" @@ -653,6 +660,10 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: : Union[Dict, HttpResponse]: 'language', 'timezone', 'weekm', + 'map_visibility', + 'workouts_visibility', } if not post_data or not post_data.keys() >= user_mandatory_data: return InvalidPayloadErrorResponse() @@ -682,12 +695,16 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: language = post_data.get('language') timezone = post_data.get('timezone') weekm = post_data.get('weekm') + map_visibility = post_data.get('map_visibility') + workouts_visibility = post_data.get('workouts_visibility') try: auth_user.imperial_units = imperial_units auth_user.language = language auth_user.timezone = timezone auth_user.weekm = weekm + auth_user.map_visibility = PrivacyLevel(map_visibility) + auth_user.workouts_visibility = PrivacyLevel(workouts_visibility) db.session.commit() return { diff --git a/fittrackee/users/decorators.py b/fittrackee/users/decorators.py index e3215b6c0..1e68e5987 100644 --- a/fittrackee/users/decorators.py +++ b/fittrackee/users/decorators.py @@ -1,9 +1,10 @@ from functools import wraps -from typing import Any, Callable, Union +from typing import Any, Callable, Optional, Union -from flask import request +from flask import Request, request from fittrackee.responses import HttpResponse +from fittrackee.users.models import User from .utils.controls import verify_user @@ -37,3 +38,30 @@ def decorated_function( return verify_auth_user(f, verify_admin, *args, **kwargs) return decorated_function + + +def get_auth_user( + current_request: Request, +) -> Optional[User]: + """ + Return user if a user is authenticated + """ + user = None + auth_header = current_request.headers.get('Authorization') + if auth_header: + auth_token = auth_header.split(' ')[1] + resp = User.decode_auth_token(auth_token) + if isinstance(resp, int): + user = User.query.filter_by(id=resp).first() + return user + + +def get_auth_user_if_authenticated(f: Callable) -> Callable: + @wraps(f) + def decorated_function( + *args: Any, **kwargs: Any + ) -> Union[Callable, HttpResponse]: + user = get_auth_user(request) + return f(user, *args, **kwargs) + + return decorated_function diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 1fd467066..8d02dfb20 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -6,6 +6,7 @@ from sqlalchemy import and_, func from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.sql.expression import select +from sqlalchemy.types import Enum from fittrackee import BaseModel, bcrypt, db from fittrackee.federation.constants import AP_CTX @@ -20,6 +21,7 @@ FollowRequestAlreadyRejectedError, NotExistingFollowRequestError, ) +from .privacy_levels import PrivacyLevel from .roles import UserRole from .utils.token import decode_user_token, get_user_token @@ -134,6 +136,12 @@ class User(BaseModel): db.Boolean, default=True, nullable=False ) is_remote = db.Column(db.Boolean, default=False, nullable=False) + workouts_visibility = db.Column( + Enum(PrivacyLevel, name='privacy_levels'), server_default='PRIVATE' + ) + map_visibility = db.Column( + Enum(PrivacyLevel, name='privacy_levels'), server_default='PRIVATE' + ) workouts = db.relationship( 'Workout', @@ -347,7 +355,6 @@ def create_actor(self) -> None: def serialize(self, role: Optional[UserRole] = None) -> Dict: sports = [] - total = (0, '0:00:00') if self.workouts_count > 0: # type: ignore sports = ( db.session.query(Workout.sport_id) @@ -356,13 +363,7 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: .order_by(Workout.sport_id) .all() ) - total = ( - db.session.query( - func.sum(Workout.distance), func.sum(Workout.duration) - ) - .filter(Workout.user_id == self.id) - .first() - ) + serialized_user = { 'admin': self.admin, 'bio': self.bio, @@ -374,18 +375,34 @@ def serialize(self, role: Optional[UserRole] = None) -> Dict: 'is_remote': self.is_remote, 'last_name': self.last_name, 'location': self.location, - 'nb_sports': len(sports), + 'map_visibility': self.map_visibility.value, 'nb_workouts': self.workouts_count, 'picture': self.picture is not None, - 'records': [record.serialize() for record in self.records], - 'sports_list': [ - sport for sportslist in sports for sport in sportslist - ], - 'total_distance': float(total[0]), - 'total_duration': str(total[1]), + 'workouts_visibility': self.workouts_visibility.value, 'username': self.username, } + if role is not None: + total = (0, '0:00:00') + if self.workouts_count > 0: # type: ignore + total = ( + db.session.query( + func.sum(Workout.distance), func.sum(Workout.duration) + ) + .filter(Workout.user_id == self.id) + .first() + ) + + serialized_user['nb_sports'] = len(sports) + serialized_user['records'] = [ + record.serialize() for record in self.records + ] + serialized_user['sports_list'] = [ + sport for sportslist in sports for sport in sportslist + ] + serialized_user['total_distance'] = float(total[0]) + serialized_user['total_duration'] = str(total[1]) + if role in [UserRole.AUTH_USER, UserRole.ADMIN]: serialized_user['email'] = self.email diff --git a/fittrackee/users/privacy_levels.py b/fittrackee/users/privacy_levels.py new file mode 100644 index 000000000..744c866c3 --- /dev/null +++ b/fittrackee/users/privacy_levels.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class PrivacyLevel(Enum): + PUBLIC = 'public' + FOLLOWERS = 'followers_only' + PRIVATE = 'private' diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 70609bcfc..9596bdca7 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -1,6 +1,6 @@ import os import shutil -from typing import Any, Dict, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union import click from flask import Blueprint, request, send_file @@ -25,7 +25,11 @@ ) from fittrackee.workouts.models import Record, Workout, WorkoutSegment -from .decorators import authenticate, authenticate_as_admin +from .decorators import ( + authenticate, + authenticate_as_admin, + get_auth_user_if_authenticated, +) from .exceptions import ( FollowRequestAlreadyRejectedError, UserNotFoundException, @@ -152,6 +156,7 @@ def get_users(auth_user: User) -> Dict: "is_remote": false, "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -200,7 +205,8 @@ def get_users(auth_user: User) -> Dict: ], "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" }, { "admin": false, @@ -214,6 +220,7 @@ def get_users(auth_user: User) -> Dict: "is_remote": false, "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 0, "nb_workouts": 0, "picture": false, @@ -221,7 +228,8 @@ def get_users(auth_user: User) -> Dict: "sports_list": [], "total_distance": 0, "total_duration": "0:00:00", - "username": "sam" + "username": "sam", + "workouts_visibility": "private" } ] }, @@ -295,6 +303,7 @@ def get_remote_users( "is_remote": true, "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 0, "nb_workouts": 0, "picture": false, @@ -302,7 +311,8 @@ def get_remote_users( "sports_list": [], "total_distance": 0, "total_duration": "0:00:00", - "username": "sam" + "username": "sam", + "workouts_visibility": "private" } ] }, @@ -331,9 +341,9 @@ def get_remote_users( @users_blueprint.route('/users/', methods=['GET']) -@authenticate +@get_auth_user_if_authenticated def get_single_user( - auth_user: User, user_name: str + auth_user: Optional[User], user_name: str ) -> Union[Dict, HttpResponse]: """ Get single user details. @@ -348,6 +358,8 @@ def get_single_user( **Example response**: + - when a user is authenticated: + .. sourcecode:: http HTTP/1.1 200 OK @@ -367,6 +379,7 @@ def get_single_user( "is_remote": false, "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -415,7 +428,39 @@ def get_single_user( ], "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" + } + ], + "status": "success" + } + + - when no authentication: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": [ + { + "admin": true, + "bio": null, + "birth_date": null, + "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", + "email": "admin@example.com", + "first_name": null, + "followers": 0, + "following": 0, + "is_remote": false, + "last_name": null, + "location": null, + "map_visibility": "private", + "nb_workouts": 6, + "picture": false, + "username": "admin", + "workouts_visibility": "private" } ], "status": "success" @@ -423,7 +468,7 @@ def get_single_user( :param integer user_name: user name - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token if user is authenticated :statuscode 200: success :statuscode 401: @@ -433,9 +478,11 @@ def get_single_user( :statuscode 404: - user does not exist """ + role = None + if auth_user is not None: + role = UserRole.ADMIN if auth_user.admin else UserRole.USER try: user = User.query.filter_by(username=user_name).first() - role = UserRole.ADMIN if auth_user.admin else UserRole.USER if user: return { 'status': 'success', @@ -520,6 +567,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: "is_remote": false, "last_name": null, "location": null, + "map_visibility": "private", "nb_workouts": 6, "nb_sports": 3, "picture": false, @@ -568,7 +616,8 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: ], "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" } ], "status": "success" @@ -867,6 +916,7 @@ def get_followers( "is_remote": false, "last_name": null, "location": null, + "map_visibility": "followers_only", "nb_sports": 0, "nb_workouts": 0, "picture": false, @@ -874,7 +924,8 @@ def get_followers( "sports_list": [], "total_distance": 0.0, "total_duration": "0:00:00", - "username": "JohnDoe" + "username": "JohnDoe", + "workouts_visibility": "followers_only" } ] }, @@ -955,6 +1006,7 @@ def get_following( "is_remote": false, "last_name": null, "location": null, + "map_visibility": "followers_only", "nb_sports": 0, "nb_workouts": 0, "picture": false, @@ -962,7 +1014,8 @@ def get_following( "sports_list": [], "total_distance": 0.0, "total_duration": "0:00:00", - "username": "JohnDoe" + "username": "JohnDoe", + "workouts_visibility": "followers_only" } ] }, From d811d6fbc951fe06552f5273914744d0c80f05f8 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Feb 2022 12:25:52 +0100 Subject: [PATCH 067/238] Client - add federation (de-)activation in application config --- .../Administration/AdminApplication.vue | 45 +++++++++++++++++++ .../components/Administration/AdminMenu.vue | 22 +++++++-- .../src/locales/en/administration.json | 3 ++ .../src/locales/fr/administration.json | 3 ++ fittrackee_client/src/types/application.ts | 3 +- fittrackee_client/src/views/AdminView.vue | 3 +- 6 files changed, 73 insertions(+), 6 deletions(-) diff --git a/fittrackee_client/src/components/Administration/AdminApplication.vue b/fittrackee_client/src/components/Administration/AdminApplication.vue index 36c6a43b7..a0217ee10 100644 --- a/fittrackee_client/src/components/Administration/AdminApplication.vue +++ b/fittrackee_client/src/components/Administration/AdminApplication.vue @@ -4,6 +4,27 @@ diff --git a/fittrackee_client/src/components/Users/UsersList.vue b/fittrackee_client/src/components/Users/UsersList.vue index 940c89c5e..dfae121f8 100644 --- a/fittrackee_client/src/components/Users/UsersList.vue +++ b/fittrackee_client/src/components/Users/UsersList.vue @@ -2,7 +2,12 @@
- +
import { ComputedRef, + Ref, computed, onBeforeMount, onUnmounted, reactive, + ref, toRefs, watch, } from 'vue' @@ -54,12 +61,16 @@ const pagination: ComputedRef = computed( () => store.getters[USERS_STORE.GETTERS.USERS_PAGINATION] ) + const updatedUser: Ref = ref(null) onBeforeMount(() => loadUsers(query)) function loadUsers(queryParams: TPaginationPayload) { store.dispatch(USERS_STORE.ACTIONS.GET_USERS, queryParams) } + function storeUser(username: string) { + updatedUser.value = username + } onUnmounted(() => { store.dispatch(USERS_STORE.ACTIONS.EMPTY_USERS) diff --git a/fittrackee_client/src/locales/en/user.json b/fittrackee_client/src/locales/en/user.json index 0cb0297be..8fa6eaf54 100644 --- a/fittrackee_client/src/locales/en/user.json +++ b/fittrackee_client/src/locales/en/user.json @@ -1,13 +1,16 @@ { "ADMIN": "Admin", "ALREADY_HAVE_ACCOUNT": "Already have an account?", + "CANCEL_FOLLOW_REQUEST": "Cancel follow request", "CONFIRM_ACCOUNT_DELETION": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone", "EMAIL": "Email", "ENTER_EMAIL": "Enter an email address", "ENTER_PASSWORD": "Enter a password", "ENTER_PASSWORD_CONFIRMATION": "Confirm the password", + "FOLLOW": "follow", "FOLLOWER": "follower | followers", "FOLLOWING": "following | following", + "FOLLOWS_YOU": "follows you", "INVALID_TOKEN": "Invalid token, please request a new password reset.", "LANGUAGE": "Language", "LOGIN": "Login", @@ -73,8 +76,10 @@ }, "REGISTER": "Register", "REGISTER_DISABLED": "Sorry, registration is disabled.", + "UNFOLLOW": "unfollow", "RESET_PASSWORD": "Reset your password", "USER_PICTURE": "user picture", "USER": "user | users", - "USERNAME": "Username" + "USERNAME": "Username", + "YOU": "you" } diff --git a/fittrackee_client/src/locales/fr/user.json b/fittrackee_client/src/locales/fr/user.json index 8f74956ad..eee9c0c92 100644 --- a/fittrackee_client/src/locales/fr/user.json +++ b/fittrackee_client/src/locales/fr/user.json @@ -1,13 +1,16 @@ { "ADMIN": "Admin", "ALREADY_HAVE_ACCOUNT": "Vous avez déjà un compte ?", + "CANCEL_FOLLOW_REQUEST": "Annuler la demande", "CONFIRM_ACCOUNT_DELETION": "Etes-vous sûr de vouloir supprimer votre compte ? Toutes les données seront définitivement effacés.", "EMAIL": "Email", "ENTER_EMAIL": "Saisir une adresse email", "ENTER_PASSWORD": "Saisir un mot de passe", "ENTER_PASSWORD_CONFIRMATION": "Confirmer le mot de passe", + "FOLLOW": "s'abonner", "FOLLOWER": "abonné | abonnés", "FOLLOWING": "abonnement | abonnements", + "FOLLOWS_YOU": "vous suit", "INVALID_TOKEN": "Jeton invalide, veullez demander une nouvelle réinitialisation de mot de passe.", "LANGUAGE": "Langue", "LOGIN": "Se connecter", @@ -74,7 +77,9 @@ "REGISTER": "S'inscrire", "REGISTER_DISABLED": "Désolé, les inscriptions sont désactivées.", "RESET_PASSWORD": "Réinitialiser votre mot de passe", + "UNFOLLOW": "se désabonner", "USER": "utilisateur | utilisateurs", "USER_PICTURE": "photo de l'utilisateur", - "USERNAME": "Nom d'utilisateur" + "USERNAME": "Nom d'utilisateur", + "YOU": "vous" } diff --git a/fittrackee_client/src/scss/base.scss b/fittrackee_client/src/scss/base.scss index d7c2e7f2c..ba7aae9f7 100644 --- a/fittrackee_client/src/scss/base.scss +++ b/fittrackee_client/src/scss/base.scss @@ -343,6 +343,7 @@ button { .profile-buttons { display: flex; + align-items: baseline; gap: $default-padding; } diff --git a/fittrackee_client/src/scss/colors.scss b/fittrackee_client/src/scss/colors.scss index d1349bdb9..d7a0f493b 100644 --- a/fittrackee_client/src/scss/colors.scss +++ b/fittrackee_client/src/scss/colors.scss @@ -66,6 +66,7 @@ --cell-heading-bg-color: #eeeeee; --cell-heading-color: #696969; - --svg-filter: drop-shadow(10px 10px 10px var(--app-shadow-color)) + --svg-filter: drop-shadow(10px 10px 10px var(--app-shadow-color)); + --text-background-color: rgb(114, 114, 114, 0.1); } \ No newline at end of file diff --git a/fittrackee_client/src/store/modules/users/actions.ts b/fittrackee_client/src/store/modules/users/actions.ts index 8a61be437..d0aaba80e 100644 --- a/fittrackee_client/src/store/modules/users/actions.ts +++ b/fittrackee_client/src/store/modules/users/actions.ts @@ -7,7 +7,11 @@ import { IAuthUserState } from '@/store/modules/authUser/types' import { IRootState } from '@/store/modules/root/types' import { IUsersActions, IUsersState } from '@/store/modules/users/types' import { TPaginationPayload } from '@/types/api' -import { IAdminUserPayload, IUserDeletionPayload } from '@/types/user' +import { + IAdminUserPayload, + IUserDeletionPayload, + IUserRelationshipPayload, +} from '@/types/user' import { handleError } from '@/utils' export const deleteUserAccount = ( @@ -121,6 +125,37 @@ export const actions: ActionTree & IUsersActions = { context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, false) ) }, + [USERS_STORE.ACTIONS.UPDATE_RELATIONSHIP]( + context: ActionContext, + payload: IUserRelationshipPayload + ): void { + context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES) + context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, true) + authApi + .post(`users/${payload.username}/${payload.action}`) + .then((res) => { + if (res.data.status === 'success') { + authApi.get(`users/${payload.username}`).then((res) => { + if (res.data.status === 'success') { + context.commit( + payload.fromUserInfos + ? USERS_STORE.MUTATIONS.UPDATE_USER + : USERS_STORE.MUTATIONS.UPDATE_USER_IN_USERS, + res.data.data.users[0] + ) + } else { + handleError(context, null) + } + }) + } else { + handleError(context, null) + } + }) + .catch((error) => handleError(context, error)) + .finally(() => + context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, false) + ) + }, [USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT]( context: ActionContext, payload: IUserDeletionPayload diff --git a/fittrackee_client/src/store/modules/users/enums.ts b/fittrackee_client/src/store/modules/users/enums.ts index 2a870f0a4..f4834b766 100644 --- a/fittrackee_client/src/store/modules/users/enums.ts +++ b/fittrackee_client/src/store/modules/users/enums.ts @@ -5,6 +5,7 @@ export enum UsersActions { GET_USERS = 'GET_USERS', UPDATE_USER = 'UPDATE_USER', DELETE_USER_ACCOUNT = 'DELETE_USER_ACCOUNT', + UPDATE_RELATIONSHIP = 'UPDATE_RELATIONSHIP', } export enum UsersGetters { diff --git a/fittrackee_client/src/store/modules/users/types.ts b/fittrackee_client/src/store/modules/users/types.ts index 79c0088fd..9283d564f 100644 --- a/fittrackee_client/src/store/modules/users/types.ts +++ b/fittrackee_client/src/store/modules/users/types.ts @@ -12,6 +12,7 @@ import { IAdminUserPayload, IUserDeletionPayload, IUserProfile, + IUserRelationshipPayload, } from '@/types/user' export interface IUsersState { @@ -40,6 +41,10 @@ export interface IUsersActions { context: ActionContext, payload: IAdminUserPayload ): void + [USERS_STORE.ACTIONS.UPDATE_RELATIONSHIP]( + context: ActionContext, + payload: IUserRelationshipPayload + ): void [USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT]( context: ActionContext, payload: IUserDeletionPayload diff --git a/fittrackee_client/src/types/user.ts b/fittrackee_client/src/types/user.ts index dff1840e1..b3c2a642d 100644 --- a/fittrackee_client/src/types/user.ts +++ b/fittrackee_client/src/types/user.ts @@ -3,6 +3,7 @@ import { LocationQueryValue } from 'vue-router' import { IRecord } from '@/types/workouts' export type TPrivacyLevels = 'private' | 'followers_only' | 'public' +export type TRelationships = 'follow' | 'unfollow' export interface IUserProfile { admin: boolean @@ -13,6 +14,8 @@ export interface IUserProfile { first_name: string | null followers: IUserProfile[] following: IUserProfile[] + follows: string + is_followed_by: string last_name: string | null location: string | null map_visibility: TPrivacyLevels @@ -55,6 +58,12 @@ export interface IAdminUserPayload { admin: boolean } +export interface IUserRelationshipPayload { + username: string + action: TRelationships + fromUserInfos: boolean +} + export interface IUserPreferencesPayload { imperial_units: boolean language: string From f13599cbed83555b83799cb8f0f87b9fcf09c84f Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 9 Jan 2022 18:41:23 +0100 Subject: [PATCH 081/238] API - filtering on user name is case insensitive --- fittrackee/tests/users/test_users_api.py | 21 +++++++++++++++++++++ fittrackee/users/users.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 99974c010..d343b3a2f 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -856,6 +856,27 @@ def test_it_gets_users_list_filtering_on_username( 'total': 1, } + def test_filtering_on_username_is_case_insensitive( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + '/api/users?q=TOTO', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + assert 'toto' in data['data']['users'][0]['username'] + def test_it_returns_empty_users_list_filtering_on_username( self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 50f51d0dd..cacffb4f3 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -65,7 +65,7 @@ def get_users_list(auth_user: User, remote: bool = False) -> Dict: query = params.get('q') users_pagination = ( User.query.filter( - User.username.like('%' + query + '%') if query else True, + User.username.ilike('%' + query + '%') if query else True, User.is_remote == remote, ) .order_by( From 35ee2d11cf23fe4481d1d563273b9565f091a792 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 9 Jan 2022 19:33:27 +0100 Subject: [PATCH 082/238] Client - search local users --- .../components/Administration/AdminUsers.vue | 4 +- .../src/components/Users/UsersFilters.vue | 96 +++++++++++++++++++ .../src/components/Users/UsersList.vue | 38 ++++++-- .../src/store/modules/users/actions.ts | 4 +- .../src/store/modules/users/types.ts | 5 +- fittrackee_client/src/types/user.ts | 5 + 6 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 fittrackee_client/src/components/Users/UsersFilters.vue diff --git a/fittrackee_client/src/components/Administration/AdminUsers.vue b/fittrackee_client/src/components/Administration/AdminUsers.vue index 4e7d8cc88..4fe3d1ce2 100644 --- a/fittrackee_client/src/components/Administration/AdminUsers.vue +++ b/fittrackee_client/src/components/Administration/AdminUsers.vue @@ -133,7 +133,7 @@ import UserPicture from '@/components/User/UserPicture.vue' import { AUTH_USER_STORE, ROOT_STORE, USERS_STORE } from '@/store/constants' import { IPagination, TPaginationPayload } from '@/types/api' - import { IUserProfile } from '@/types/user' + import { IUserProfile, TUsersPayload } from '@/types/user' import { useStore } from '@/use/useStore' import { getQuery, sortList } from '@/utils/api' import { getDateWithTZ } from '@/utils/dates' @@ -167,7 +167,7 @@ onBeforeMount(() => loadUsers(query)) - function loadUsers(queryParams: TPaginationPayload) { + function loadUsers(queryParams: TUsersPayload) { store.dispatch(USERS_STORE.ACTIONS.GET_USERS, queryParams) } function updateUser(username: string, admin: boolean) { diff --git a/fittrackee_client/src/components/Users/UsersFilters.vue b/fittrackee_client/src/components/Users/UsersFilters.vue new file mode 100644 index 000000000..bf7ffc46c --- /dev/null +++ b/fittrackee_client/src/components/Users/UsersFilters.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/fittrackee_client/src/components/Users/UsersList.vue b/fittrackee_client/src/components/Users/UsersList.vue index dfae121f8..208d553e2 100644 --- a/fittrackee_client/src/components/Users/UsersList.vue +++ b/fittrackee_client/src/components/Users/UsersList.vue @@ -1,5 +1,6 @@
@@ -44,6 +47,7 @@ import WorkoutCardTitle from '@/components/Workout/WorkoutDetail/WorkoutCardTitle.vue' import WorkoutData from '@/components/Workout/WorkoutDetail/WorkoutData.vue' import WorkoutMap from '@/components/Workout/WorkoutDetail/WorkoutMap/index.vue' + import WorkoutVisibility from '@/components/Workout/WorkoutDetail/WorkoutVisibility.vue' import { WORKOUTS_STORE } from '@/store/constants' import { ISport } from '@/types/sports' import { IAuthUserProfile } from '@/types/user' @@ -138,6 +142,7 @@ distance: segment ? segment.distance : workout.distance, descent: segment ? segment.descent : workout.descent, duration: segment ? segment.duration : workout.duration, + mapVisibility: segment ? null : workout.map_visibility, maxAlt: segment ? segment.max_alt : workout.max_alt, maxSpeed: segment ? segment.max_speed : workout.max_speed, minAlt: segment ? segment.min_alt : workout.min_alt, @@ -155,6 +160,7 @@ with_gpx: workout.with_gpx, workoutId: workout.id, workoutTime: workoutDate.workout_time, + workoutVisibility: segment ? null : workout.workout_visibility, } } function updateDisplayModal(value: boolean) { @@ -184,9 +190,16 @@ width: 100%; .card-content { display: flex; - flex-direction: row; + flex-direction: column; + .workout-map-data { + display: flex; + flex-direction: row; + } @media screen and (max-width: $medium-limit) { - flex-direction: column; + .workout-map-data { + display: flex; + flex-direction: column; + } } } } diff --git a/fittrackee_client/src/components/Workout/WorkoutEdition.vue b/fittrackee_client/src/components/Workout/WorkoutEdition.vue index 462635f98..c5ef3f80f 100644 --- a/fittrackee_client/src/components/Workout/WorkoutEdition.vue +++ b/fittrackee_client/src/components/Workout/WorkoutEdition.vue @@ -190,6 +190,39 @@ /> +
+ + +
+
+ + +
+ getMapVisibilityLevels(workoutForm.workoutVisibility) + ) onMounted(() => { if (props.workout.id) { @@ -323,6 +366,8 @@ workoutForm.sport_id = `${workout.sport_id}` workoutForm.title = workout.title workoutForm.notes = workout.notes + workoutForm.workoutVisibility = workout.workout_visibility + workoutForm.mapVisibility = workout.map_visibility if (!workout.with_gpx) { const workoutDateTime = formatWorkoutDate( getDateWithTZ(workout.workout_date, props.authUser.timezone), @@ -351,15 +396,18 @@ +workoutForm.workoutDurationMinutes * 60 + +workoutForm.workoutDurationSeconds payload.workout_date = `${workoutForm.workoutDate} ${workoutForm.workoutTime}` + payload.workout_visibility = workoutForm.workoutVisibility } function updateWorkout() { const payload: IWorkoutForm = { sport_id: +workoutForm.sport_id, notes: workoutForm.notes, + workout_visibility: workoutForm.workoutVisibility, } if (props.workout.id) { if (props.workout.with_gpx) { payload.title = workoutForm.title + payload.map_visibility = workoutForm.mapVisibility } else { formatPayload(payload) } @@ -375,6 +423,7 @@ return } payload.file = gpxFile + payload.map_visibility = workoutForm.mapVisibility store.dispatch(WORKOUTS_STORE.ACTIONS.ADD_WORKOUT, payload) } else { formatPayload(payload) @@ -395,6 +444,12 @@ function invalidateForm() { formErrors.value = true } + function updateMapVisibility() { + workoutForm.mapVisibility = getUpdatedMapVisibility( + workoutForm.mapVisibility, + workoutForm.workoutVisibility + ) + } onUnmounted(() => store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)) @@ -440,6 +495,9 @@ input { height: 20px; } + label { + text-transform: lowercase; + } .workout-date-duration { display: flex; diff --git a/fittrackee_client/src/locales/en/en.ts b/fittrackee_client/src/locales/en/en.ts index fc7c0860f..5d0e96e7f 100644 --- a/fittrackee_client/src/locales/en/en.ts +++ b/fittrackee_client/src/locales/en/en.ts @@ -4,6 +4,7 @@ import ButtonsTranslations from './buttons.json' import CommonTranslations from './common.json' import DashboardTranslations from './dashboard.json' import ErrorTranslations from './error.json' +import PrivacyLevelsTranslations from './privacy.json' import SportsTranslations from './sports.json' import StatisticsTranslations from './statistics.json' import UserTranslations from './user.json' @@ -16,6 +17,7 @@ export default { common: CommonTranslations, dashboard: DashboardTranslations, error: ErrorTranslations, + privacy: PrivacyLevelsTranslations, sports: SportsTranslations, statistics: StatisticsTranslations, user: UserTranslations, diff --git a/fittrackee_client/src/locales/en/privacy.json b/fittrackee_client/src/locales/en/privacy.json new file mode 100644 index 000000000..fc4143b88 --- /dev/null +++ b/fittrackee_client/src/locales/en/privacy.json @@ -0,0 +1,11 @@ +{ + "LEVELS": { + "private": "Private (only me)", + "followers_only": "Followers only", + "public": "Public (everyone)" + }, + "MAP_VISIBILITY": "Map and analysis visibility", + "VISIBILITY": "visibility", + "WORKOUT_VISIBILITY": "Workout visibility", + "WORKOUTS_VISIBILITY": "Workouts related data visibility" +} \ No newline at end of file diff --git a/fittrackee_client/src/locales/en/user.json b/fittrackee_client/src/locales/en/user.json index cd076c023..c819fa6b1 100644 --- a/fittrackee_client/src/locales/en/user.json +++ b/fittrackee_client/src/locales/en/user.json @@ -20,15 +20,6 @@ "PASSWORD_RESET": "Password reset", "PASSWORD_SENT_EMAIL_TEXT": "Check your email. If your address is in our database, you'll received an email with a link to reset your password.", "PASSWORD_UPDATED": "Your password have been updated. Click {0} to log in.", - "PRIVACY": { - "LEVELS": { - "private": "Private (only me)", - "followers_only": "Followers only", - "public": "Public (everyone)" - }, - "MAP_VISIBILITY": "Map and analysis visibility", - "WORKOUTS_VISIBILITY": "Workouts related data visibility" - }, "PROFILE": { "BACK_TO_PROFILE": "Back to profile", "BIO": "Bio", diff --git a/fittrackee_client/src/locales/en/workouts.json b/fittrackee_client/src/locales/en/workouts.json index 2685ea393..492f14a6e 100644 --- a/fittrackee_client/src/locales/en/workouts.json +++ b/fittrackee_client/src/locales/en/workouts.json @@ -18,6 +18,7 @@ "HIDE_FILTERS": "hide filters", "LATEST_WORKOUTS": "Latest workouts", "LOAD_MORE_WORKOUT": "Load more workouts", + "MAP": "map", "MAX_ALTITUDE": "max. altitude", "MAX_FILES": "max files", "MAX_SIZE": "max size", diff --git a/fittrackee_client/src/locales/fr/fr.ts b/fittrackee_client/src/locales/fr/fr.ts index fc7c0860f..5d0e96e7f 100644 --- a/fittrackee_client/src/locales/fr/fr.ts +++ b/fittrackee_client/src/locales/fr/fr.ts @@ -4,6 +4,7 @@ import ButtonsTranslations from './buttons.json' import CommonTranslations from './common.json' import DashboardTranslations from './dashboard.json' import ErrorTranslations from './error.json' +import PrivacyLevelsTranslations from './privacy.json' import SportsTranslations from './sports.json' import StatisticsTranslations from './statistics.json' import UserTranslations from './user.json' @@ -16,6 +17,7 @@ export default { common: CommonTranslations, dashboard: DashboardTranslations, error: ErrorTranslations, + privacy: PrivacyLevelsTranslations, sports: SportsTranslations, statistics: StatisticsTranslations, user: UserTranslations, diff --git a/fittrackee_client/src/locales/fr/privacy.json b/fittrackee_client/src/locales/fr/privacy.json new file mode 100644 index 000000000..185a7d8c8 --- /dev/null +++ b/fittrackee_client/src/locales/fr/privacy.json @@ -0,0 +1,11 @@ +{ + "LEVELS": { + "private": "Privé (seulement moi)", + "followers_only": "Abonnées uniquement", + "public": "Public (tout le monde)" + }, + "MAP_VISIBILITY": "Visibilité de la carte et de l'analyse", + "VISIBILITY": "visibilité", + "WORKOUT_VISIBILITY": "Visibilité de la séance", + "WORKOUTS_VISIBILITY": "Visibilité des données relatives aux séances" +} \ No newline at end of file diff --git a/fittrackee_client/src/locales/fr/user.json b/fittrackee_client/src/locales/fr/user.json index b0a172ddd..a548f3c92 100644 --- a/fittrackee_client/src/locales/fr/user.json +++ b/fittrackee_client/src/locales/fr/user.json @@ -20,15 +20,6 @@ "PASSWORD_RESET": "Réinitialisation du mot de passe", "PASSWORD_SENT_EMAIL_TEXT": "Vérifiez votre boite mail. Si vote adresse est dans notre base de données, vous recevrez un email avec un lien pour réinitialiser votre mot de passe.", "PASSWORD_UPDATED": "Votre mot de passe a été mis à jour. Cliquez {0} pour vous connecter.", - "PRIVACY": { - "LEVELS": { - "private": "Privé (seulement moi)", - "followers_only": "Abonnées uniquement", - "public": "Public (tout le monde)" - }, - "MAP_VISIBILITY": "Visibilité de la carte et de l'analyse", - "WORKOUTS_VISIBILITY": "Visibilité des données relatives aux séances" - }, "PROFILE": { "BACK_TO_PROFILE": "Revenir au profil", "BIO": "Bio", diff --git a/fittrackee_client/src/locales/fr/workouts.json b/fittrackee_client/src/locales/fr/workouts.json index 5d5f38585..790007102 100644 --- a/fittrackee_client/src/locales/fr/workouts.json +++ b/fittrackee_client/src/locales/fr/workouts.json @@ -18,6 +18,7 @@ "HIDE_FILTERS": "masquer les filtres", "LATEST_WORKOUTS": "Séances récentes", "LOAD_MORE_WORKOUT": "Charger les séances suivantes", + "MAP": "carte", "MAX_ALTITUDE": "altitude max", "MAX_FILES": "fichiers max. ", "MAX_SIZE": "taille max. ", diff --git a/fittrackee_client/src/scss/colors.scss b/fittrackee_client/src/scss/colors.scss index d7a0f493b..c39ba09ad 100644 --- a/fittrackee_client/src/scss/colors.scss +++ b/fittrackee_client/src/scss/colors.scss @@ -69,4 +69,5 @@ --svg-filter: drop-shadow(10px 10px 10px var(--app-shadow-color)); --text-background-color: rgb(114, 114, 114, 0.1); + --text-visibilty: rgba(37, 37, 37, 0.65); } \ No newline at end of file diff --git a/fittrackee_client/src/store/modules/workouts/actions.ts b/fittrackee_client/src/store/modules/workouts/actions.ts index 7f643cc40..cff066f7c 100644 --- a/fittrackee_client/src/store/modules/workouts/actions.ts +++ b/fittrackee_client/src/store/modules/workouts/actions.ts @@ -189,7 +189,9 @@ export const actions: ActionTree & form.append('file', payload.file) form.append( 'data', - `{"sport_id": ${payload.sport_id}, "notes": "${payload.notes}"}` + `{"sport_id": ${payload.sport_id}, "notes": "${payload.notes}",` + + ` "workout_visibility": "${payload.workout_visibility}",` + + ` "map_visibility": "${payload.map_visibility}"}` ) authApi .post('workouts', form, { diff --git a/fittrackee_client/src/types/workouts.ts b/fittrackee_client/src/types/workouts.ts index e60ae42e5..8d7261ca3 100644 --- a/fittrackee_client/src/types/workouts.ts +++ b/fittrackee_client/src/types/workouts.ts @@ -1,5 +1,6 @@ import { TPaginationPayload } from '@/types/api' import { IChartDataset } from '@/types/chart' +import { TPrivacyLevels } from '@/types/user' export interface IWorkoutSegment { ascent: number @@ -56,6 +57,7 @@ export interface IWorkout { duration: string id: string map: string | null + map_visibility: TPrivacyLevels max_alt: number | null max_speed: number min_alt: number | null @@ -74,6 +76,7 @@ export interface IWorkout { weather_start: IWeather | null with_gpx: boolean workout_date: string + workout_visibility: TPrivacyLevels } export interface IWorkoutObject { @@ -84,6 +87,7 @@ export interface IWorkoutObject { duration: string maxAlt: number | null maxSpeed: number + mapVisibility: TPrivacyLevels | null minAlt: number | null moving: string nextUrl: string | null @@ -99,6 +103,7 @@ export interface IWorkoutObject { with_gpx: boolean workoutId: string workoutTime: string + workoutVisibility: TPrivacyLevels | null } export interface IWorkoutForm { @@ -109,6 +114,8 @@ export interface IWorkoutForm { distance?: number duration?: number file?: Blob + map_visibility?: TPrivacyLevels + workout_visibility: TPrivacyLevels } export interface IWorkoutPayload { diff --git a/fittrackee_client/src/utils/privacy.ts b/fittrackee_client/src/utils/privacy.ts new file mode 100644 index 000000000..c085b9098 --- /dev/null +++ b/fittrackee_client/src/utils/privacy.ts @@ -0,0 +1,35 @@ +import { TPrivacyLevels } from '@/types/user' + +export const privacyLevels: TPrivacyLevels[] = [ + 'private', + 'followers_only', + 'public', +] + +export const getUpdatedMapVisibility = ( + mapVisibility: TPrivacyLevels, + workoutVisibility: TPrivacyLevels +): TPrivacyLevels => { + // when workout visibility is stricter, it returns workout visibility value + // for map visibility + if ( + workoutVisibility === 'private' || + (workoutVisibility === 'followers_only' && mapVisibility === 'public') + ) { + return workoutVisibility + } + return mapVisibility +} + +export const getMapVisibilityLevels = ( + workoutVisibility: TPrivacyLevels +): TPrivacyLevels[] => { + switch (workoutVisibility) { + case 'public': + return privacyLevels + case 'followers_only': + return ['private', 'followers_only'] + case 'private': + return ['private'] + } +} diff --git a/fittrackee_client/tests/unit/utils/privacy.spec.ts b/fittrackee_client/tests/unit/utils/privacy.spec.ts new file mode 100644 index 000000000..6757cf8c8 --- /dev/null +++ b/fittrackee_client/tests/unit/utils/privacy.spec.ts @@ -0,0 +1,45 @@ +import { assert } from 'chai' + +import { TPrivacyLevels } from '@/types/user' +import { + getUpdatedMapVisibility, + getMapVisibilityLevels, +} from '@/utils/privacy' + +describe('getUpdatedMapVisibility', () => { + const testsParams: [TPrivacyLevels, TPrivacyLevels, TPrivacyLevels][] = [ + // input map visibility, input workout visibility, excepted map visibility + ['private', 'private', 'private'], + ['private', 'followers_only', 'private'], + ['private', 'public', 'private'], + ['followers_only', 'private', 'private'], + ['followers_only', 'followers_only', 'followers_only'], + ['followers_only', 'public', 'followers_only'], + ['public', 'private', 'private'], + ['public', 'followers_only', 'followers_only'], + ['public', 'public', 'public'], + ] + + testsParams.map((testParams) => { + it(`get map visibility (input value: '${testParams[0]}') when workout visibility is '${testParams[1]}'`, () => { + assert.equal( + getUpdatedMapVisibility(testParams[0], testParams[1]), + testParams[2] + ) + }) + }) +}) + +describe('getMapVisibilityLevels', () => { + const testsParams: [TPrivacyLevels, TPrivacyLevels[]][] = [ + ['private', ['private']], + ['followers_only', ['private', 'followers_only']], + ['public', ['private', 'followers_only', 'public']], + ] + + testsParams.map((testParams) => { + it(`get visibility levels depending on workout visibility (input value: '${testParams[0]}')`, () => { + assert.deepEqual(getMapVisibilityLevels(testParams[0]), testParams[1]) + }) + }) +}) From 76a6be22e1493629608b1c84957c1585ea59a1e9 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 2 Feb 2022 18:41:28 +0100 Subject: [PATCH 112/238] API - return visibility preferences only for authenticated user --- fittrackee/tests/users/test_users_model.py | 10 ++++++++-- fittrackee/users/models.py | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 888c1afb9..fe7997b9e 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -27,9 +27,7 @@ def assert_serialized_used(serialized_user: Dict) -> None: assert serialized_user['location'] is None assert serialized_user['birth_date'] is None assert serialized_user['picture'] is False - assert serialized_user['map_visibility'] == 'private' assert serialized_user['nb_workouts'] == 0 - assert serialized_user['workouts_visibility'] == 'private' def test_user_model_as_auth_user(self, app: Flask, user_1: User) -> None: assert '' == str(user_1) @@ -48,6 +46,8 @@ def test_user_model_as_auth_user(self, app: Flask, user_1: User) -> None: assert serialized_user['language'] is None assert serialized_user['timezone'] is None assert serialized_user['weekm'] is False + assert serialized_user['map_visibility'] == 'private' + assert serialized_user['workouts_visibility'] == 'private' assert 'follows' not in serialized_user assert 'is_followed_by' not in serialized_user @@ -72,6 +72,8 @@ def test_user_model_as_admin( assert 'weekm' not in serialized_user assert serialized_user['follows'] == 'false' assert serialized_user['is_followed_by'] == 'false' + assert 'map_visibility' not in serialized_user + assert 'workouts_visibility' not in serialized_user def test_user_model_as_regular_user( self, app: Flask, user_1: User, user_2: User @@ -94,6 +96,8 @@ def test_user_model_as_regular_user( assert 'weekm' not in serialized_user assert serialized_user['follows'] == 'false' assert serialized_user['is_followed_by'] == 'false' + assert 'map_visibility' not in serialized_user + assert 'workouts_visibility' not in serialized_user def test_user_model_when_no_user_provided( self, app: Flask, user_1: User @@ -115,6 +119,8 @@ def test_user_model_when_no_user_provided( assert 'weekm' not in serialized_user assert 'follows' not in serialized_user assert 'is_followed_by' not in serialized_user + assert 'map_visibility' not in serialized_user + assert 'workouts_visibility' not in serialized_user def test_encode_auth_token(self, app: Flask, user_1: User) -> None: auth_token = user_1.encode_auth_token(user_1.id) diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index a47c293b6..5e6abd322 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -447,10 +447,8 @@ def serialize(self, current_user: Optional['User'] = None) -> Dict: 'is_remote': self.is_remote, 'last_name': self.last_name, 'location': self.location, - 'map_visibility': self.map_visibility.value, 'nb_workouts': self.workouts_count, 'picture': self.picture is not None, - 'workouts_visibility': self.workouts_visibility.value, 'username': self.username, } if self.is_remote: @@ -494,6 +492,8 @@ def serialize(self, current_user: Optional['User'] = None) -> Dict: 'language': self.language, 'timezone': self.timezone, 'weekm': self.weekm, + 'map_visibility': self.map_visibility.value, + 'workouts_visibility': self.workouts_visibility.value, }, } From eb5edc9bf422f8b9104171455ece866c7850c9b4 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 19:57:03 +0100 Subject: [PATCH 113/238] API - return user with workout --- .../test_workouts_api_0_get_workout.py | 18 ++++++--- .../test_workouts_api_0_get_workouts.py | 9 ++++- .../workouts/test_workouts_api_1_post.py | 39 ++++++++++++------- .../workouts/test_workouts_api_2_patch.py | 25 ++++++++---- .../tests/workouts/test_workouts_model.py | 2 +- fittrackee/workouts/models.py | 2 +- 6 files changed, 64 insertions(+), 31 deletions(-) diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py index 1313a2e9a..6c94b5f08 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py @@ -10,7 +10,7 @@ from fittrackee.workouts.models import Sport, Workout, WorkoutSegment from ..test_case_mixins import ApiTestCaseMixin -from ..utils import random_string +from ..utils import jsonify_dict, random_string from .utils import get_random_short_id @@ -66,7 +66,9 @@ def test_it_gets_owner_workout( 'Mon, 01 Jan 2018 00:00:00 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert 10.0 == data['data']['workouts'][0]['distance'] assert '1:00:00' == data['data']['workouts'][0]['duration'] @@ -132,7 +134,9 @@ def test_it_returns_followed_user_workout( data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['workouts']) == 1 - assert data['data']['workouts'][0]['user'] == user_2.username + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_2.serialize() + ) assert ( data['data']['workouts'][0]['workout_visibility'] == input_workout_level.value @@ -211,7 +215,9 @@ def test_it_returns_another_user_workout_when_visibility_is_public( data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['workouts']) == 1 - assert data['data']['workouts'][0]['user'] == user_2.username + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_2.serialize() + ) assert data['data']['workouts'][0]['workout_visibility'] == 'public' @@ -276,7 +282,9 @@ def test_it_returns_a_user_workout_when_visibility_is_public( data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['workouts']) == 1 - assert data['data']['workouts'][0]['user'] == user_1.username + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['workout_visibility'] == 'public' diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py b/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py index 64cfa7aea..7b7f48d3d 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py @@ -7,6 +7,7 @@ from fittrackee.workouts.models import Sport, Workout from ..test_case_mixins import ApiTestCaseMixin +from ..utils import jsonify_dict class TestGetWorkouts(ApiTestCaseMixin): @@ -39,7 +40,9 @@ def test_it_gets_all_workouts_for_authenticated_user( 'Sun, 01 Apr 2018 00:00:00 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert 2 == data['data']['workouts'][0]['sport_id'] assert 12.0 == data['data']['workouts'][0]['distance'] assert '1:40:00' == data['data']['workouts'][0]['duration'] @@ -49,7 +52,9 @@ def test_it_gets_all_workouts_for_authenticated_user( 'Mon, 01 Jan 2018 00:00:00 GMT' == data['data']['workouts'][1]['workout_date'] ) - assert 'test' == data['data']['workouts'][1]['user'] + assert data['data']['workouts'][1]['user'] == jsonify_dict( + user_1.serialize() + ) assert 1 == data['data']['workouts'][1]['sport_id'] assert 10.0 == data['data']['workouts'][1]['distance'] assert '1:00:00' == data['data']['workouts'][1]['duration'] diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index f27073d65..90bd8c83a 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -15,15 +15,18 @@ from fittrackee.workouts.utils.short_id import decode_short_id from ..test_case_mixins import ApiTestCaseMixin, CallArgsMixin +from ..utils import jsonify_dict -def assert_workout_data_with_gpx(data: Dict) -> None: +def assert_workout_data_with_gpx(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 @@ -80,13 +83,15 @@ def assert_workout_data_with_gpx(data: Dict) -> None: assert records[3]['value'] == 4.61 -def assert_workout_data_with_gpx_segments(data: Dict) -> None: +def assert_workout_data_with_gpx_segments(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 @@ -154,13 +159,15 @@ def assert_workout_data_with_gpx_segments(data: Dict) -> None: assert records[2]['value'] == 4.59 -def assert_workout_data_wo_gpx(data: Dict) -> None: +def assert_workout_data_wo_gpx(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == 1 assert data['data']['workouts'][0]['duration'] == '1:00:00' assert ( @@ -232,7 +239,7 @@ def test_it_adds_a_workout( assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 assert 'just a workout' == data['data']['workouts'][0]['title'] - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_without_name( self, @@ -265,7 +272,7 @@ def test_it_adds_a_workout_without_name( 'Cycling - 2018-03-13 12:44:45' == data['data']['workouts'][0]['title'] ) - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_when_user_has_specified_timezone( self, @@ -299,7 +306,7 @@ def test_it_adds_a_workout_when_user_has_specified_timezone( 'Cycling - 2018-03-13 13:44:45' == data['data']['workouts'][0]['title'] ) - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) @pytest.mark.parametrize( 'input_description,input_notes', @@ -768,7 +775,7 @@ def test_it_adds_a_workout_without_gpx( data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 - assert_workout_data_wo_gpx(data) + assert_workout_data_wo_gpx(data, user_1) def test_it_returns_400_if_workout_date_is_missing( self, app: Flask, user_1: User, sport_1_cycling: Sport @@ -850,7 +857,9 @@ def test_it_adds_workout_with_zero_value( data['data']['workouts'][0]['workout_date'] == 'Mon, 14 May 2018 14:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == 1 assert data['data']['workouts'][0]['duration'] is None assert data['data']['workouts'][0]['title'] == 'Workout test' @@ -1048,7 +1057,7 @@ def test_it_adds_workouts_with_zip_archive( assert 'created' in data['status'] assert len(data['data']['workouts']) == 3 assert 'just a workout' == data['data']['workouts'][0]['title'] - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_returns_400_if_folder_is_present_in_zip_archive( self, app: Flask, user_1: User, sport_1_cycling: Sport @@ -1362,9 +1371,9 @@ def workout_assertion( assert len(data['data']['workouts']) == 1 assert 'just a workout' == data['data']['workouts'][0]['title'] if with_segments: - assert_workout_data_with_gpx_segments(data) + assert_workout_data_with_gpx_segments(data, user_1) else: - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) map_id = data['data']['workouts'][0]['map'] workout_short_id = data['data']['workouts'][0]['id'] @@ -1530,7 +1539,7 @@ def test_it_add_and_gets_a_workout_wo_gpx( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 1 - assert_workout_data_wo_gpx(data) + assert_workout_data_wo_gpx(data, user_1) def test_it_adds_and_gets_a_workout_wo_gpx_notes( self, app: Flask, user_1: User, sport_1_cycling: Sport diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index 3c2232fd9..f551b3965 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -10,16 +10,21 @@ from fittrackee.workouts.models import Sport, Workout from ..test_case_mixins import ApiTestCaseMixin +from ..utils import jsonify_dict from .utils import get_random_short_id, post_a_workout -def assert_workout_data_with_gpx(data: Dict, sport_id: int) -> None: +def assert_workout_data_with_gpx( + data: Dict, sport_id: int, user: User +) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 assert data['data']['workouts'][0]['ave_speed'] == 4.61 @@ -81,7 +86,7 @@ def test_it_updates_title_for_a_workout_with_gpx( assert len(data['data']['workouts']) == 1 assert sport_2_running.id == data['data']['workouts'][0]['sport_id'] assert data['data']['workouts'][0]['title'] == 'Workout test' - assert_workout_data_with_gpx(data, sport_2_running.id) + assert_workout_data_with_gpx(data, sport_2_running.id, user_1) @pytest.mark.parametrize( 'input_description,input_notes', @@ -199,7 +204,7 @@ def test_it_updates_sport( assert len(data['data']['workouts']) == 1 assert sport_2_running.id == data['data']['workouts'][0]['sport_id'] assert data['data']['workouts'][0]['title'] == 'just a workout' - assert_workout_data_with_gpx(data, sport_2_running.id) + assert_workout_data_with_gpx(data, sport_2_running.id, user_1) def test_it_returns_400_if_payload_is_empty( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str @@ -280,7 +285,9 @@ def test_it_updates_a_workout_wo_gpx( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] == 'Workout test' @@ -452,7 +459,9 @@ def test_it_updates_a_workout_wo_gpx_with_timezone( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1_paris.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] == 'Workout test' @@ -519,7 +528,9 @@ def test_it_updates_only_sport_and_distance_a_workout_wo_gpx( data['data']['workouts'][0]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] is None diff --git a/fittrackee/tests/workouts/test_workouts_model.py b/fittrackee/tests/workouts/test_workouts_model.py index e60219a82..a6c4dde28 100644 --- a/fittrackee/tests/workouts/test_workouts_model.py +++ b/fittrackee/tests/workouts/test_workouts_model.py @@ -58,7 +58,7 @@ def test_workout_model( serialized_workout = workout_cycling_user_1.serialize(self.user_status) assert isinstance(decode_short_id(serialized_workout['id']), UUID) - assert 'test' == serialized_workout['user'] + assert serialized_workout['user'] == user_1.serialize() assert 1 == serialized_workout['sport_id'] assert serialized_workout['title'] == 'Test' assert 'creation_date' in serialized_workout diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index 5d6b0a222..a6521133c 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -320,7 +320,7 @@ def serialize( return { 'id': self.short_id, # WARNING: client use uuid as id - 'user': self.user.username, + 'user': self.user.serialize(), 'sport_id': self.sport_id, 'title': self.title, 'creation_date': self.creation_date, From 9eed417e2d7d793697aaca8f0f29d9c1069d6f43 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 2 Feb 2022 19:24:13 +0100 Subject: [PATCH 114/238] API - return visibility only for workout owner --- .../workouts/test_workouts_api_0_get_workout.py | 12 ++++++------ fittrackee/tests/workouts/test_workouts_model.py | 10 ++++++++++ fittrackee/workouts/models.py | 8 +++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py index 6c94b5f08..f6ecf2e37 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py @@ -137,10 +137,8 @@ def test_it_returns_followed_user_workout( assert data['data']['workouts'][0]['user'] == jsonify_dict( user_2.serialize() ) - assert ( - data['data']['workouts'][0]['workout_visibility'] - == input_workout_level.value - ) + assert 'map_visibility' not in data['data']['workouts'][0] + assert 'workout_visibility' not in data['data']['workouts'][0] class TestGetWorkoutAsUser(GetWorkoutTestCase): @@ -218,7 +216,8 @@ def test_it_returns_another_user_workout_when_visibility_is_public( assert data['data']['workouts'][0]['user'] == jsonify_dict( user_2.serialize() ) - assert data['data']['workouts'][0]['workout_visibility'] == 'public' + assert 'map_visibility' not in data['data']['workouts'][0] + assert 'workout_visibility' not in data['data']['workouts'][0] class TestGetWorkoutAsUnauthenticatedUser(GetWorkoutTestCase): @@ -285,7 +284,8 @@ def test_it_returns_a_user_workout_when_visibility_is_public( assert data['data']['workouts'][0]['user'] == jsonify_dict( user_1.serialize() ) - assert data['data']['workouts'][0]['workout_visibility'] == 'public' + assert 'map_visibility' not in data['data']['workouts'][0] + assert 'workout_visibility' not in data['data']['workouts'][0] class GetWorkoutGpxTestCase(ApiTestCaseMixin): diff --git a/fittrackee/tests/workouts/test_workouts_model.py b/fittrackee/tests/workouts/test_workouts_model.py index a6c4dde28..2cc05c1a7 100644 --- a/fittrackee/tests/workouts/test_workouts_model.py +++ b/fittrackee/tests/workouts/test_workouts_model.py @@ -323,6 +323,8 @@ def test_serializer_returns_map_related_data( assert serialized_workout['map'] == workout.map assert serialized_workout['bounds'] == workout.bounds assert serialized_workout['with_gpx'] is True + assert 'map_visibility' not in serialized_workout + assert 'workout_visibility' not in serialized_workout @pytest.mark.parametrize( 'input_map_visibility,input_workout_visibility', @@ -355,6 +357,8 @@ def test_serializer_does_not_return_map_related_data( assert serialized_workout['map'] is None assert serialized_workout['bounds'] == [] assert serialized_workout['with_gpx'] is False + assert 'map_visibility' not in serialized_workout + assert 'workout_visibility' not in serialized_workout def test_serializer_does_not_return_next_workout( self, @@ -444,6 +448,8 @@ def test_serializer_returns_map_related_data_when_visibility_is_public( assert serialized_workout['map'] == workout.map assert serialized_workout['bounds'] == workout.bounds assert serialized_workout['with_gpx'] is True + assert 'map_visibility' not in serialized_workout + assert 'workout_visibility' not in serialized_workout @pytest.mark.parametrize( 'input_map_visibility,input_workout_visibility', @@ -476,6 +482,8 @@ def test_serializer_does_not_return_map_related_data( assert serialized_workout['map'] is None assert serialized_workout['bounds'] == [] assert serialized_workout['with_gpx'] is False + assert 'map_visibility' not in serialized_workout + assert 'workout_visibility' not in serialized_workout def test_serializer_does_not_return_next_workout( self, @@ -508,3 +516,5 @@ def test_serializer_does_not_return_previous_workout( ) assert serialized_workout['previous_workout'] is None + assert 'map_visibility' not in serialized_workout + assert 'workout_visibility' not in serialized_workout diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index a6521133c..6afc9e3a3 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -318,7 +318,7 @@ def serialize( next_workout = None previous_workout = None - return { + workout = { 'id': self.short_id, # WARNING: client use uuid as id 'user': self.user.serialize(), 'sport_id': self.sport_id, @@ -349,12 +349,14 @@ def serialize( 'segments': [segment.serialize() for segment in self.segments], 'records': [record.serialize() for record in self.records], 'map': self.map_id if self.map and can_see_map_data else None, - 'map_visibility': self.calculated_map_visibility.value, 'weather_start': self.weather_start, 'weather_end': self.weather_end, - 'workout_visibility': self.workout_visibility.value, 'notes': self.notes if user_status == 'owner' else None, } + if user_status == 'owner': + workout['workout_visibility'] = self.workout_visibility.value + workout['map_visibility'] = self.calculated_map_visibility.value + return workout @classmethod def get_user_workout_records( From 2fe09686109202a04500564727b0bf48ebd5b4a4 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 20 Feb 2022 20:04:42 +0100 Subject: [PATCH 115/238] API - only workout owner can edit or delete a workout --- .../workouts/test_workouts_api_2_patch.py | 217 ++++++++++++++++-- .../workouts/test_workouts_api_3_delete.py | 203 ++++++++++++++-- fittrackee/tests/workouts/utils.py | 8 +- fittrackee/workouts/workouts.py | 4 + 4 files changed, 404 insertions(+), 28 deletions(-) diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index f551b3965..35847031a 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -6,7 +6,7 @@ from flask import Flask from fittrackee.privacy_levels import PrivacyLevel -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User from fittrackee.workouts.models import Sport, Workout from ..test_case_mixins import ApiTestCaseMixin @@ -148,20 +148,38 @@ def test_it_empties_workout_notes( assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['notes'] == '' - def test_it_raises_403_when_editing_a_workout_from_different_user( + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, - sport_2_running: Sport, gpx_file: str, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - _, workout_short_id = post_a_workout(app, gpx_file) + user_1.approves_follow_request_from(user_2) + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) client = app.test_client() resp_login = client.post( '/api/auth/login', - data=json.dumps(dict(email='toto@toto.com', password='87654321')), + data=json.dumps(dict(email=user_2.email, password='87654321')), content_type='application/json', ) @@ -175,10 +193,85 @@ def test_it_raises_403_when_editing_a_workout_from_different_user( ), ) - data = json.loads(response.data.decode()) - assert response.status_code == 403 - assert 'error' in data['status'] - assert 'you do not have permissions' in data['message'] + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + resp_login = client.post( + '/api/auth/login', + data=json.dumps(dict(email=user_2.email, password='87654321')), + content_type='application/json', + ) + + response = client.patch( + f'/api/workouts/{workout_short_id}', + content_type='application/json', + data=json.dumps(dict(sport_id=2, title="Workout test")), + headers=dict( + Authorization='Bearer ' + + json.loads(resp_login.data.decode())['auth_token'] + ), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + + response = client.patch( + f'/api/workouts/{workout_short_id}', + content_type='application/json', + data=json.dumps(dict(sport_id=2, title="Workout test")), + ) + + assert response.status_code == 401 def test_it_updates_sport( self, @@ -390,14 +483,77 @@ def test_it_empties_workout_notes( assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['notes'] == '' + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_returns_403_when_editing_a_workout_wo_gpx_from_followed_user( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_2.short_id}', + content_type='application/json', + data=json.dumps( + dict( + sport_id=2, + duration=3600, + workout_date='2018-05-15 15:05', + distance=8, + title='Workout test', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) def test_returns_403_when_editing_a_workout_wo_gpx_from_different_user( self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -417,10 +573,45 @@ def test_returns_403_when_editing_a_workout_wo_gpx_from_different_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 403 - data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert 'you do not have permissions' in data['message'] + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps( + dict( + sport_id=2, + duration=3600, + workout_date='2018-05-15 15:05', + distance=8, + title='Workout test', + ) + ), + ) + + assert response.status_code == 401 def test_it_updates_a_workout_wo_gpx_with_timezone( self, diff --git a/fittrackee/tests/workouts/test_workouts_api_3_delete.py b/fittrackee/tests/workouts/test_workouts_api_3_delete.py index 9b7574ca5..17f3cea1d 100644 --- a/fittrackee/tests/workouts/test_workouts_api_3_delete.py +++ b/fittrackee/tests/workouts/test_workouts_api_3_delete.py @@ -1,10 +1,12 @@ import json import os +import pytest from flask import Flask from fittrackee.files import get_absolute_file_path -from fittrackee.users.models import User +from fittrackee.privacy_levels import PrivacyLevel +from fittrackee.users.models import FollowRequest, User from fittrackee.workouts.models import Sport, Workout from ..test_case_mixins import ApiTestCaseMixin @@ -30,19 +32,39 @@ def test_it_deletes_workout_with_gpx( assert response.status_code == 204 - def test_it_returns_403_when_deleting_workout_from_different_user( + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, gpx_file: str, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - _, workout_short_id = post_a_workout(app, gpx_file) + user_1.approves_follow_request_from(user_2) + + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) client = app.test_client() resp_login = client.post( '/api/auth/login', - data=json.dumps(dict(email='toto@toto.com', password='87654321')), + data=json.dumps(dict(email=user_2.email, password='87654321')), content_type='application/json', ) @@ -54,10 +76,81 @@ def test_it_returns_403_when_deleting_workout_from_different_user( ), ) - assert response.status_code == 403 - data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert 'you do not have permissions' in data['message'] + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + resp_login = client.post( + '/api/auth/login', + data=json.dumps(dict(email=user_2.email, password='87654321')), + content_type='application/json', + ) + + response = client.delete( + f'/api/workouts/{workout_short_id}', + headers=dict( + Authorization='Bearer ' + + json.loads(resp_login.data.decode())['auth_token'] + ), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_short_id}', + ) + + assert response.status_code == 401 def test_it_returns_404_if_workout_does_not_exist( self, app: Flask, user_1: User @@ -114,18 +207,36 @@ def test_it_deletes_a_workout_wo_gpx( ) assert response.status_code == 204 - def test_it_returns_404_when_deleting_workout_from_different_user( + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = input_workout_visibility client = app.test_client() resp_login = client.post( '/api/auth/login', - data=json.dumps(dict(email='toto@toto.com', password='87654321')), + data=json.dumps(dict(email=user_2.email, password='87654321')), content_type='application/json', ) response = client.delete( @@ -136,8 +247,72 @@ def test_it_returns_404_when_deleting_workout_from_different_user( ), ) - data = json.loads(response.data.decode()) + assert response.status_code == expected_status_code - assert response.status_code == 403 - assert 'error' in data['status'] - assert 'you do not have permissions' in data['message'] + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + client = app.test_client() + resp_login = client.post( + '/api/auth/login', + data=json.dumps(dict(email=user_2.email, password='87654321')), + content_type='application/json', + ) + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + headers=dict( + Authorization='Bearer ' + + json.loads(resp_login.data.decode())['auth_token'] + ), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', PrivacyLevel.PRIVATE), + ( + 'workout visibility: followers_only', + PrivacyLevel.FOLLOWERS, + ), + ('workout visibility: public', PrivacyLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: PrivacyLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + ) + + assert response.status_code == 401 diff --git a/fittrackee/tests/workouts/utils.py b/fittrackee/tests/workouts/utils.py index 43bc65a93..af165189e 100644 --- a/fittrackee/tests/workouts/utils.py +++ b/fittrackee/tests/workouts/utils.py @@ -5,6 +5,7 @@ from flask import Flask +from fittrackee.privacy_levels import PrivacyLevel from fittrackee.workouts.utils.short_id import encode_uuid @@ -13,7 +14,10 @@ def get_random_short_id() -> str: def post_a_workout( - app: Flask, gpx_file: str, notes: Optional[str] = None + app: Flask, + gpx_file: str, + notes: Optional[str] = None, + workout_visibility: Optional[PrivacyLevel] = None, ) -> Tuple[str, str]: client = app.test_client() resp_login = client.post( @@ -25,6 +29,8 @@ def post_a_workout( workout_data = '{"sport_id": 1' if notes is not None: workout_data += f', "notes": "{notes}"' + if workout_visibility is not None: + workout_data += f', "workout_visibility": "{workout_visibility.value}"' workout_data += '}' response = client.post( '/api/workouts', diff --git a/fittrackee/workouts/workouts.py b/fittrackee/workouts/workouts.py index 4dd6ef2c6..cdf1cb04f 100644 --- a/fittrackee/workouts/workouts.py +++ b/fittrackee/workouts/workouts.py @@ -1310,6 +1310,8 @@ def update_workout( workout, 'workout_visibility', auth_user ) if not can_view: + return DataNotFoundErrorResponse('workouts') + if auth_user.id != workout.user.id: return ForbiddenErrorResponse() workout = edit_workout(workout, workout_data, auth_user) @@ -1371,6 +1373,8 @@ def delete_workout( workout, 'workout_visibility', auth_user ) if not can_view: + return DataNotFoundErrorResponse('workouts') + if auth_user.id != workout.user.id: return ForbiddenErrorResponse() db.session.delete(workout) From e24eee023ef86d0de2e4031fef204b947b9194bc Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 3 Feb 2022 11:33:55 +0100 Subject: [PATCH 116/238] Client - display workout depending on visibility (WIP) --- .../WorkoutDetail/WorkoutCardTitle.vue | 7 +- .../Workout/WorkoutDetail/WorkoutUser.vue | 93 +++++++++++++++++++ .../WorkoutDetail/WorkoutVisibility.vue | 2 +- .../Workout/WorkoutDetail/index.vue | 10 +- fittrackee_client/src/types/user.ts | 4 +- fittrackee_client/src/types/workouts.ts | 10 +- .../src/views/workouts/Workout.vue | 27 +++++- 7 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 fittrackee_client/src/components/Workout/WorkoutDetail/WorkoutUser.vue diff --git a/fittrackee_client/src/components/Workout/WorkoutDetail/WorkoutCardTitle.vue b/fittrackee_client/src/components/Workout/WorkoutDetail/WorkoutCardTitle.vue index 2089778ff..692d760f1 100644 --- a/fittrackee_client/src/components/Workout/WorkoutDetail/WorkoutCardTitle.vue +++ b/fittrackee_client/src/components/Workout/WorkoutDetail/WorkoutCardTitle.vue @@ -24,6 +24,7 @@