diff --git a/.env.docker.dev b/.env.docker.dev index c2cb296e1..c9b8780fe 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -53,4 +53,7 @@ export SENDER_EMAIL=fittrackee@example.com # Weather # available weather API providers: visualcrossing # export WEATHER_API_PROVIDER= -# export WEATHER_API_KEY= \ No newline at end of file +# export WEATHER_API_KEY= + +# Federation +# export FEDERATION_ENABLED=False \ No newline at end of file diff --git a/.env.example b/.env.example index 1b70c6f26..7c741b704 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,6 @@ export SENDER_EMAIL= # available weather API providers: visualcrossing # export WEATHER_API_PROVIDER= # export WEATHER_API_KEY= + +# Federation +# export FEDERATION_ENABLED=False diff --git a/fittrackee/__init__.py b/fittrackee/__init__.py index 835de4023..9efa33c67 100644 --- a/fittrackee/__init__.py +++ b/fittrackee/__init__.py @@ -212,6 +212,15 @@ def create_app(init_email: bool = True) -> Flask: app.register_blueprint(feeds_blueprint, url_prefix="") app.register_blueprint(geocode_blueprint, url_prefix="/api") + # ActivityPub federation + from .federation.federation import ap_federation_blueprint + from .federation.nodeinfo import ap_nodeinfo_blueprint + from .federation.webfinger import ap_webfinger_blueprint + + app.register_blueprint(ap_federation_blueprint, url_prefix="/federation") + 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) logging.getLogger("sqlalchemy").handlers = logging.getLogger( diff --git a/fittrackee/application/app_config.py b/fittrackee/application/app_config.py index 62d2cdc3c..f616abadf 100644 --- a/fittrackee/application/app_config.py +++ b/fittrackee/application/app_config.py @@ -52,6 +52,7 @@ def get_application_config() -> Union[Dict, HttpResponse]: "open_elevation": false, "valhalla": false }, + "federation_enabled": false, "file_sync_limit_import": 10, "file_limit_import": 10, "global_map_workouts_limit": 10000, @@ -115,6 +116,7 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]: "open_elevation": false, "valhalla": false }, + "federation_enabled": true, "file_sync_limit_import": 10, "file_limit_import": 10, "global_map_workouts_limit": 10000, diff --git a/fittrackee/application/models.py b/fittrackee/application/models.py index babb62f19..1ae93d93e 100644 --- a/fittrackee/application/models.py +++ b/fittrackee/application/models.py @@ -48,7 +48,9 @@ class AppConfig(BaseModel): @property def is_registration_enabled(self) -> bool: - result = db.session.execute(text("SELECT COUNT(*) FROM users;")) + result = db.session.execute( + text("SELECT COUNT(*) FROM users WHERE users.is_remote IS FALSE;") + ) nb_users = result.one()[0] return self.max_users == 0 or nb_users < self.max_users @@ -71,6 +73,7 @@ def serialize(self) -> Dict: "about": self.about, "admin_contact": self.admin_contact, "elevation_services": self.elevation_services, + "federation_enabled": current_app.config["FEDERATION_ENABLED"], "file_limit_import": self.file_limit_import, "file_sync_limit_import": self.file_sync_limit_import, "is_email_sending_enabled": current_app.config["CAN_SEND_EMAILS"], diff --git a/fittrackee/comments/comments.py b/fittrackee/comments/comments.py index 6873c8fcb..cb632e69b 100644 --- a/fittrackee/comments/comments.py +++ b/fittrackee/comments/comments.py @@ -1,10 +1,12 @@ from datetime import datetime, timezone -from typing import Dict, Optional, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union -from flask import Blueprint, request +from flask import Blueprint, current_app, request from sqlalchemy import exc from fittrackee import db +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.tasks.inbox import send_to_remote_inbox from fittrackee.oauth2.server import require_auth from fittrackee.reports.models import ReportActionAppeal from fittrackee.responses import ( @@ -14,8 +16,8 @@ handle_error_and_return_response, ) from fittrackee.users.models import User -from fittrackee.utils import clean_input -from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.utils import clean_input, decode_short_id +from fittrackee.visibility_levels import VisibilityLevel, can_view from fittrackee.workouts.decorators import check_workout from fittrackee.workouts.models import Workout @@ -27,6 +29,39 @@ DEFAULT_COMMENT_LIKES_PER_PAGE = 10 +def get_all_recipients( + user: User, + comment: Comment, + deleted_mentioned_users: Optional[Set] = None, +) -> List[str]: + recipients = user.get_followers_shared_inboxes_as_list() + mentions = [ + user.actor.shared_inbox_url for user in comment.remote_mentions.all() + ] + if deleted_mentioned_users is None: + deleted_mentioned_users = set() + deleted_mentions = [ + user.actor.shared_inbox_url for user in deleted_mentioned_users + ] + return list(set(recipients + mentions + deleted_mentions)) + + +def sending_comment_activities_allowed( + comment: Comment, deleted_mentioned_users: Optional[Set] = None +) -> bool: + if deleted_mentioned_users is None: + deleted_mentioned_users = set() + return current_app.config["FEDERATION_ENABLED"] and ( + comment.has_remote_mentions + or len(deleted_mentioned_users) > 0 + or comment.text_visibility + in ( + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ) + + @comments_blueprint.route( "/workouts//comments", methods=["POST"] ) @@ -62,6 +97,8 @@ def post_workout_comment( "likes_count": 0, "mentions": [], "modification_date": null, + "replies": [], + "reply_to": null, "suspended_at": null, "text": "Great!", "text_html": "Great!", @@ -86,12 +123,15 @@ def post_workout_comment( : List["Comment"]: +def get_comments( + workout_id: int, user: Optional["User"], reply_to: Optional[int] = None +) -> List["Comment"]: if user: params = {"workout_id": workout_id, "user_id": user.id} sql = """ @@ -48,15 +57,36 @@ def get_comments(workout_id: int, user: Optional["User"]) -> List["Comment"]: OR ( mentions.user_id = :user_id OR comments.text_visibility = 'PUBLIC' - OR (comments.text_visibility = 'FOLLOWERS' AND :user_id IN ( + OR (comments.text_visibility IN ( + 'FOLLOWERS', 'FOLLOWERS_AND_REMOTE' + ) AND :user_id IN ( SELECT follower_user_id FROM follow_requests WHERE follower_user_id = :user_id AND followed_user_id = comments.user_id AND is_approved IS TRUE )) + OR (comments.text_visibility = 'FOLLOWERS_AND_REMOTE' + AND :user_id IN ( + SELECT follower_user_id + FROM follow_requests + JOIN users ON follow_requests.followed_user_id = users.id + WHERE follower_user_id = :user_id + AND followed_user_id = comments.user_id + AND is_approved IS TRUE + AND users.is_remote IS TRUE + )) ) - ) + )""" + + if reply_to: + sql += """ + AND comments.reply_to = :reply_to """ + params["reply_to"] = reply_to + else: + sql += """ + AND comments.reply_to IS NULL""" + sql += """ ORDER BY comments.created_at;""" return ( @@ -74,6 +104,7 @@ def get_comments(workout_id: int, user: Optional["User"]) -> List["Comment"]: return ( Comment.query.filter( Comment.workout_id == workout_id, + Comment.reply_to == reply_to, Comment.text_visibility == VisibilityLevel.PUBLIC, ) .order_by(Comment.created_at.asc()) @@ -100,6 +131,11 @@ class Comment(BaseModel): index=True, nullable=True, ) + reply_to: Mapped[Optional[int]] = mapped_column( + db.ForeignKey("comments.id", ondelete="SET NULL"), + index=True, + nullable=True, + ) created_at: Mapped[datetime] = mapped_column( TZDateTime, default=aware_utc_now ) @@ -115,6 +151,8 @@ class Comment(BaseModel): suspended_at: Mapped[Optional[datetime]] = mapped_column( TZDateTime, nullable=True ) + ap_id: Mapped[Optional[str]] = mapped_column(db.Text(), nullable=True) + remote_url: Mapped[Optional[str]] = mapped_column(db.Text(), nullable=True) user: Mapped["User"] = relationship( "User", lazy="select", single_parent=True @@ -122,6 +160,9 @@ class Comment(BaseModel): workout: Mapped["Workout"] = relationship( "Workout", lazy="select", single_parent=True ) + parent_comment = db.relationship( + "Comment", remote_side=[id], lazy="joined" + ) mentions: Mapped[List["Mention"]] = relationship( "Mention", lazy=True, @@ -151,11 +192,20 @@ def __repr__(self) -> str: def __init__( self, user_id: int, - workout_id: int, + workout_id: Union[int, None], text: str, text_visibility: VisibilityLevel, created_at: Optional[datetime] = None, + reply_to: Optional[int] = None, ) -> None: + if ( + text_visibility == VisibilityLevel.FOLLOWERS_AND_REMOTE + and not current_app.config["FEDERATION_ENABLED"] + ): + raise InvalidVisibilityException( + "invalid visibility: followers_and_remote_only, " + "federation is disabled." + ) self.user_id = user_id self.workout_id = workout_id self.text = text @@ -163,11 +213,26 @@ def __init__( self.created_at = ( datetime.now(timezone.utc) if created_at is None else created_at ) + self.reply_to = reply_to @property def short_id(self) -> str: return encode_uuid(self.uuid) + def get_ap_id(self) -> str: + return ( + f"{self.user.actor.activitypub_id}/" + f"workouts/{self.workout.short_id}/" + f"comments/{self.short_id}" + ) + + def get_remote_url(self) -> str: + return ( + f"https://{self.user.actor.domain.name}/" + f"workouts/{self.workout.short_id}/" + f"comments/{self.short_id}" + ) + @property def suspension_action(self) -> Optional["ReportAction"]: if self.suspended_at is None: @@ -184,20 +249,35 @@ def suspension_action(self) -> Optional["ReportAction"]: .first() ) - def handle_mentions(self) -> Tuple[str, Set["User"]]: + @hybrid_property + def remote_mentions(self) -> Query: + from fittrackee.users.models import User + + return ( + db.session.query(User) + .join(Mention, User.id == Mention.user_id) + .filter(Mention.comment_id == self.id) + .filter(User.is_remote == True) # noqa + ) + + @hybrid_property + def has_remote_mentions(self) -> bool: + return self.remote_mentions.count() > 0 + + def handle_mentions(self) -> Tuple[str, Dict[str, Set["User"]]]: from .utils import handle_mentions return handle_mentions(self.text) - def create_mentions(self) -> Tuple[str, Set["User"]]: + def create_mentions(self) -> Tuple[str, Dict[str, Set["User"]]]: linkified_text, mentioned_users = self.handle_mentions() - for user in mentioned_users: + for user in mentioned_users["local"].union(mentioned_users["remote"]): mention = Mention(comment_id=self.id, user_id=user.id) db.session.add(mention) db.session.flush() return linkified_text, mentioned_users - def update_mentions(self) -> None: + def update_mentions(self) -> Set["User"]: from fittrackee.users.models import Notification, User existing_mentioned_users = set( @@ -205,13 +285,16 @@ def update_mentions(self) -> None: .join(Mention, User.id == Mention.user_id) .all() ) - _, updated_mentioned_users = self.handle_mentions() - unchanged_mentions = updated_mentioned_users.intersection( + _, mentioned_users = self.handle_mentions() + updated_mentioned_users = mentioned_users["local"].union( + mentioned_users["remote"] + ) + intersection = updated_mentioned_users.intersection( existing_mentioned_users ) # delete removed mentions - deleted_mentioned_users = existing_mentioned_users - unchanged_mentions + deleted_mentioned_users = existing_mentioned_users - intersection mentions_to_delete = {user.id for user in deleted_mentioned_users} Mention.query.filter( Mention.comment_id == self.id, @@ -225,11 +308,14 @@ def update_mentions(self) -> None: db.session.flush() # create new mentions - for user in updated_mentioned_users - unchanged_mentions: + for user in updated_mentioned_users - intersection: mention = Mention(comment_id=self.id, user_id=user.id) db.session.add(mention) db.session.flush() + # return users associated to deleted mention to send delete + return deleted_mentioned_users + def liked_by(self, user: "User") -> bool: return user in self.likes.all() @@ -242,11 +328,26 @@ def reports(self) -> List["Report"]: def serialize( self, user: Optional["User"] = None, + with_replies: bool = True, + get_parent_comment: bool = False, for_report: bool = False, ) -> Dict: if not can_view(self, "text_visibility", user, for_report): raise CommentForbiddenException + try: + reply_to = ( + None + if self.reply_to is None + else ( + self.parent_comment.serialize(user, with_replies=False) + if get_parent_comment + else self.parent_comment.short_id + ) + ) + except CommentForbiddenException: + reply_to = None + # suspended comment content is only visible to its owner or # to admin in report only suspension: Dict[str, Any] = {} @@ -302,11 +403,35 @@ def serialize( if display_content else [] ), + "reply_to": reply_to, + "replies": ( + [ + reply.serialize(user) + for reply in get_comments( + workout_id=self.workout_id, + user=user, + reply_to=self.id, + ) + ] + if with_replies and self.workout_id and not for_report + else [] + ), "likes_count": self.likes.count() if display_content else 0, "liked": self.liked_by(user) if user else False, **suspension, } + def get_activity(self, activity_type: str) -> Dict: + if activity_type in ["Create", "Update"]: + return CommentObject( + self, activity_type=activity_type + ).get_activity() + if activity_type == "Delete": + tombstone_object = TombstoneObject(self) + delete_activity = tombstone_object.get_activity() + return delete_activity + return {} + class Mention(BaseModel): __tablename__ = "mentions" @@ -364,7 +489,11 @@ def receive_after_flush(session: Session, context: Connection) -> None: if not workout: return - to_user_id = workout.user_id + if new_comment.reply_to is None: + to_user_id = workout.user_id + else: + comment = Comment.query.filter_by(id=new_comment.reply_to).one() + to_user_id = comment.user_id if new_comment.user_id == to_user_id: return @@ -395,7 +524,11 @@ def receive_after_flush(session: Session, context: Connection) -> None: from_user_id=new_comment.user_id, to_user_id=to_user_id, created_at=new_comment.created_at, - event_type="workout_comment", + event_type=( + "workout_comment" + if new_comment.reply_to is None + else "comment_reply" + ), event_object_id=new_comment.id, ) session.add(notification) @@ -444,21 +577,39 @@ def receive_after_flush(session: Session, context: Connection) -> None: if not to_user or not to_user.is_notification_enabled("mention"): return - # - mentioned user is workout owner and 'workout_comment' - # notification does not exist - notification = ( - Notification.query.join( - Comment, Comment.id == Notification.event_object_id + # - when mentioned user is workout owner and `workout_comment' + # notification does not exist) + if not comment.reply_to: + notification = ( + Notification.query.join( + Comment, Comment.id == Notification.event_object_id + ) + .filter( + Comment.id == comment.id, + Notification.event_type == "workout_comment", + Notification.to_user_id == new_mention.user_id, + ) + .first() ) - .filter( - Comment.id == comment.id, - Notification.event_type == "workout_comment", - Notification.to_user_id == new_mention.user_id, + if notification: + return + + # - when mentioned user is parent comment owner and + # `comment_reply' notification already exists + else: + parent_comment_notification = ( + Notification.query.join( + Comment, + Comment.id == Notification.event_object_id, + ) + .filter( + Notification.to_user_id == new_mention.user_id, + Notification.event_type == "comment_reply", + ) + .first() ) - .first() - ) - if notification: - return + if parent_comment_notification: + return notification = Notification( from_user_id=comment.user_id, @@ -519,6 +670,14 @@ def __init__( datetime.now(timezone.utc) if created_at is None else created_at ) + def get_activity(self, is_undo: bool = False) -> Dict: + return LikeObject( + actor_ap_id=self.user.actor.activitypub_id, + target_object_ap_id=self.comment.ap_id, + like_id=self.id, + is_undo=is_undo, + ).get_activity() + @listens_for(CommentLike, "after_insert") def on_comment_like_insert( diff --git a/fittrackee/comments/utils.py b/fittrackee/comments/utils.py index a34967023..d88057c40 100644 --- a/fittrackee/comments/utils.py +++ b/fittrackee/comments/utils.py @@ -1,38 +1,54 @@ import re -from typing import TYPE_CHECKING, Optional, Set, Tuple +from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple -from sqlalchemy import func +from flask import current_app -from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee import appLog +from fittrackee.federation.utils.user import get_user_from_username from fittrackee.utils import decode_short_id from fittrackee.visibility_levels import can_view +from .exceptions import CommentForbiddenException from .models import Comment if TYPE_CHECKING: from fittrackee.users.models import User -MENTION_REGEX = r"(?)?([\w_\-\.]+))(<\/span>)?" +MENTION_REGEX = r"(?)?([\w_\-\.]+))(@([\w_\-\.]+\.[a-z]{2,}))?(<\/span>)?" # noqa LINK_TEMPLATE = ( '' "@{username}" ) -def handle_mentions(text: str) -> Tuple[str, Set["User"]]: - from fittrackee.users.models import User - - mentioned_users: Set["User"] = set() - for _, _, username, _ in re.findall(re.compile(MENTION_REGEX), text): - user = User.query.filter( - func.lower(User.username) == func.lower(username), - ).first() +def handle_mentions(text: str) -> Tuple[str, Dict[str, Set["User"]]]: + mentioned_users: Dict[str, Set["User"]] = {"local": set(), "remote": set()} + for _, _, username, _, domain, _ in re.findall( + re.compile(MENTION_REGEX), text + ): + mention = f"{username}{f'@{domain}' if domain else ''}" + remote_domain = ( + f"@{domain}" + if domain and domain != current_app.config["AP_DOMAIN"] + else "" + ) + try: + user = get_user_from_username( + user_name=f"{username}{remote_domain}", + with_action="creation", + ) + except Exception as e: + appLog.error(f"Error when getting mentioned user: {e!s}") + user = None if user: - mentioned_users.add(user) + if user.is_remote: + mentioned_users["remote"].add(user) + else: + mentioned_users["local"].add(user) text = text.replace( - f"@{username}", + f"@{mention}", LINK_TEMPLATE.format( - url=user.get_user_url(), username=username + url=user.actor.profile_url, username=mention ), ) return text, mentioned_users diff --git a/fittrackee/config.py b/fittrackee/config.py index f84db22d8..a81569de8 100644 --- a/fittrackee/config.py +++ b/fittrackee/config.py @@ -6,6 +6,7 @@ from flask import current_app from fittrackee import DEFAULT_PRIVACY_POLICY_DATA, VERSION +from fittrackee.federation.utils import remove_url_scheme from fittrackee.languages import SUPPORTED_LANGUAGES from fittrackee.workouts.constants import WORKOUT_ALLOWED_EXTENSIONS @@ -81,6 +82,11 @@ class BaseConfig: DATA_EXPORT_EXPIRATION = 24 # hours VERSION = VERSION DEFAULT_PRIVACY_POLICY_DATA = DEFAULT_PRIVACY_POLICY_DATA + # ActivityPub + FEDERATION_ENABLED = ( + os.environ.get("FEDERATION_ENABLED", "false").lower() == "true" + ) + AP_DOMAIN = remove_url_scheme(UI_URL) class DevelopmentConfig(BaseConfig): @@ -112,11 +118,13 @@ class TestingConfig(BaseConfig): "authorization_code": 60, "refresh_token": 60, } + AP_DOMAIN = "example.com" class End2EndTestingConfig(TestingConfig): DRAMATIQ_BROKER_URL = os.getenv("REDIS_URL", "redis://") UI_URL = "http://0.0.0.0:5000" + AP_DOMAIN = "0.0.0.0:5000" class ProductionConfig(BaseConfig): diff --git a/fittrackee/exceptions.py b/fittrackee/exceptions.py index 6d2065276..bf24b33d5 100644 --- a/fittrackee/exceptions.py +++ b/fittrackee/exceptions.py @@ -13,3 +13,7 @@ def __init__( class TaskException(Exception): pass + + +class InvalidVisibilityException(Exception): + pass diff --git a/fittrackee/federation/__init__.py b/fittrackee/federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/federation/activities/__init__.py b/fittrackee/federation/activities/__init__.py new file mode 100644 index 000000000..09853b6fd --- /dev/null +++ b/fittrackee/federation/activities/__init__.py @@ -0,0 +1,21 @@ +from .activities import ( + AcceptActivity, + CreateActivity, + DeleteActivity, + FollowActivity, + LikeActivity, + RejectActivity, + UndoActivity, + UpdateActivity, +) + +__all__ = [ + "AcceptActivity", + "CreateActivity", + "DeleteActivity", + "FollowActivity", + "LikeActivity", + "RejectActivity", + "UndoActivity", + "UpdateActivity", +] diff --git a/fittrackee/federation/activities/activities.py b/fittrackee/federation/activities/activities.py new file mode 100644 index 000000000..6c5bfcd58 --- /dev/null +++ b/fittrackee/federation/activities/activities.py @@ -0,0 +1,424 @@ +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from typing import Dict, Tuple, Union + +from fittrackee import appLog, db +from fittrackee.comments.models import Comment, CommentLike +from fittrackee.federation.constants import PUBLIC_STREAM +from fittrackee.federation.exceptions import ( + ActivityException, + ActorNotFoundException, + ObjectNotFoundException, +) +from fittrackee.federation.models import Actor +from fittrackee.federation.objects.workout import ( + convert_duration_string_to_seconds, +) +from fittrackee.federation.utils.user import ( + create_remote_user, + get_or_create_remote_domain_from_url, +) +from fittrackee.users.exceptions import ( + FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, + NotExistingFollowRequestError, +) +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.constants import WORKOUT_DATE_FORMAT +from fittrackee.workouts.exceptions import SportNotFoundException +from fittrackee.workouts.models import Sport, Workout, WorkoutLike +from fittrackee.workouts.services.workout_creation_service import ( + WorkoutCreationService, +) + +from ..objects.workout import convert_workout_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 + + def get_actor(self, create_remote_actor: bool = False) -> Actor: + """ + return actor from activity + """ + actor = Actor.query.filter_by( + activitypub_id=self.activity["actor"] + ).first() + if not actor: + if create_remote_actor: + remote_domain = get_or_create_remote_domain_from_url( + self.activity["actor"] + ) + user = create_remote_user( + remote_domain, self.activity["actor"] + ) + actor = user.actor + else: + raise ActorNotFoundException( + f"actor not found for {self.activity_name()}" + ) + return actor + + def get_actors( + self, create_remote_actor: bool = False + ) -> Tuple[Actor, Actor]: + """ + return actors from activity 'actor' and 'object' + """ + actor = self.get_actor(create_remote_actor) + + if isinstance(self.activity["object"], str): + object_actor_activitypub_id = self.activity["object"] + else: + object_actor_activitypub_id = ( + self.activity["object"]["object"] + if self.activity["type"] == "Undo" + else self.activity["object"]["actor"] + ) + object_actor = Actor.query.filter_by( + activitypub_id=object_actor_activitypub_id + ).first() + if not object_actor: + raise ActorNotFoundException( + message=f"object actor not found for {self.activity_name()}" + ) + return actor, object_actor + + @staticmethod + def _get_visibility( + activity_object: Dict, actor: Actor + ) -> VisibilityLevel: + recipients = activity_object.get("cc", []) + activity_object.get( + "to", [] + ) + if PUBLIC_STREAM in recipients: + return VisibilityLevel.PUBLIC + elif actor.followers_url in recipients: + return VisibilityLevel.FOLLOWERS_AND_REMOTE + # TODO: + # For comments only (only visible to mentioned users) + return VisibilityLevel.PRIVATE + + +class FollowBaseActivity(AbstractActivity): + @abstractmethod + def process_activity(self) -> None: + pass + + +class FollowActivity(FollowBaseActivity): + def process_activity(self) -> None: + 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: + 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 + + +class UndoActivity(AbstractActivity): + def process_activity(self) -> None: + if self.activity["object"]["type"] == "Follow": + self.undoes_follow() + if self.activity["object"]["type"] == "Like": + self.undoes_like() + + def undoes_follow(self) -> None: + follower_actor, followed_actor = self.get_actors() + try: + followed_actor.user.undoes_follow(follower_actor.user) + except NotExistingFollowRequestError as e: + appLog.error( + f"{self.activity_name()}: follow request does not exist." + ) + raise e + + def undoes_like(self) -> None: + like = None + actor = self.get_actor(create_remote_actor=True) + object_ap_id = self.activity["object"]["object"] + + # check if like is related to a workout + target_object = Workout.query.filter_by(ap_id=object_ap_id).first() + if target_object: + like = WorkoutLike.query.filter_by( + user_id=actor.user.id, + workout_id=target_object.id, + ).first() + + # check if like is related to a comment + if not target_object: + target_object = Comment.query.filter_by(ap_id=object_ap_id).first() + if not target_object: + raise ObjectNotFoundException("object", self.activity_name()) + + like = CommentLike.query.filter_by( + user_id=actor.user.id, + comment_id=target_object.id, + ).first() + + if like: + db.session.delete(like) + db.session.commit() + + +def create_comment( + note_data: Dict, actor: Actor, visibility: VisibilityLevel +) -> Comment: + reply_to_object_api_id = note_data.get("inReplyTo") + workout = None + parent_comment = None + if reply_to_object_api_id: + # comment to a workout + workout = Workout.query.filter_by(ap_id=reply_to_object_api_id).first() + if not workout: + # reply to a comment + parent_comment = Comment.query.filter_by( + ap_id=reply_to_object_api_id + ).first() + if parent_comment: + # get workout from parent comment + workout = parent_comment.workout if parent_comment else None + + new_comment = Comment( + user_id=actor.user.id, + workout_id=workout.id if workout else None, + text=note_data["content"], + text_visibility=visibility, + reply_to=parent_comment.id if parent_comment else None, + ) + new_comment.ap_id = note_data["id"] + new_comment.remote_url = note_data["url"] + db.session.add(new_comment) + db.session.flush() + return new_comment + + +class CreateActivity(AbstractActivity): + def create_remote_workout(self, actor: Actor) -> None: + workout_data = self.activity["object"] + sport_id = workout_data["sport_id"] + if not sport_id: + raise SportNotFoundException() + sport = Sport.query.filter_by(id=sport_id).first() + if not sport: + raise SportNotFoundException() + workout_data["workout_visibility"] = self._get_visibility( + workout_data, actor + ) + service = WorkoutCreationService( + actor.user, convert_workout_activity(workout_data) + ) + service.process() + db.session.commit() + + def create_remote_note(self, actor: Actor) -> None: + note_data = self.activity["object"] + new_comment = create_comment( + note_data, actor, self._get_visibility(note_data, actor) + ) + new_comment.create_mentions() + db.session.commit() + + def process_activity(self) -> None: + object_type = self.activity["object"]["type"] + actor = self.get_actor(create_remote_actor=object_type == "Note") + if object_type == "Workout": + self.create_remote_workout(actor=actor) + if object_type == "Note": + self.create_remote_note(actor=actor) + + +class DeleteActivity(AbstractActivity): + def process_activity(self) -> None: + actor = self.get_actor() + object_ap_id = self.activity["object"]["id"] + + # check if related object is a comment + object_to_delete = Comment.query.filter_by(ap_id=object_ap_id).first() + + # if not, check if related object is a workout + if not object_to_delete: + object_to_delete = Workout.query.filter_by( + ap_id=object_ap_id + ).first() + + if not object_to_delete: + raise ObjectNotFoundException("object", self.activity_name()) + + if object_to_delete.user.actor.id != actor.id: + raise ActivityException( + f"{self.activity_name()}: activity actor does not " + f"match workout actor." + ) + + db.session.delete(object_to_delete) + db.session.commit() + + +class UpdateActivity(AbstractActivity): + @staticmethod + def convert_duration_string_to_timedelta( + duration_string: str, + ) -> timedelta: + return timedelta( + seconds=convert_duration_string_to_seconds(duration_string) + ) + + def update_remote_workout(self, actor: Actor) -> None: + workout_data = self.activity["object"] + workout_to_update = Workout.query.filter_by( + ap_id=workout_data["id"] + ).first() + if not workout_to_update: + raise ObjectNotFoundException("workout", self.activity_name()) + + if workout_to_update.user.actor.id != actor.id: + raise ActivityException( + f"{self.activity_name()}: activity actor does not " + f"match workout actor." + ) + + try: + workout_to_update.ave_speed = workout_data["ave_speed"] + workout_to_update.distance = workout_data["distance"] + workout_to_update.duration = ( + self.convert_duration_string_to_timedelta( + workout_data["duration"] + ) + ) + workout_to_update.max_speed = workout_data["max_speed"] + workout_to_update.moving = ( + self.convert_duration_string_to_timedelta( + workout_data["moving"] + ) + ) + workout_to_update.sport_id = workout_data["sport_id"] + workout_to_update.title = workout_data["title"] + # workout date must be in GMT+00:00 + workout_to_update.workout_date = datetime.strptime( + workout_data["workout_date"], WORKOUT_DATE_FORMAT + ).replace(tzinfo=timezone.utc) + workout_to_update.workout_visibility = self._get_visibility( + workout_data, actor + ) + db.session.commit() + except Exception as e: + raise ActivityException( + f"{self.activity_name()}: invalid Workout activity " + f"({e.__class__.__name__}: {e})." + ) from e + + def update_remote_workout_comment(self, actor: Actor) -> None: + note_data = self.activity["object"] + comment_to_update = Comment.query.filter_by( + ap_id=note_data["id"] + ).first() + if not comment_to_update: + comment_to_update = create_comment( + note_data, actor, self._get_visibility(note_data, actor) + ) + + if comment_to_update.user.actor.id != actor.id: + raise ActivityException( + f"{self.activity_name()}: activity actor does not " + f"match Note actor." + ) + + try: + comment_to_update.text = note_data["content"] + comment_to_update.text_visibility = self._get_visibility( + note_data, actor + ) + comment_to_update.modification_date = datetime.now(timezone.utc) + comment_to_update.update_mentions() + db.session.commit() + except Exception as e: + raise ActivityException( + f"{self.activity_name()}: invalid Note activity " + f"({e.__class__.__name__}: {e})." + ) from e + + def process_activity(self) -> None: + object_type = self.activity["object"]["type"] + actor = self.get_actor(create_remote_actor=object_type == "Note") + if object_type == "Workout": + self.update_remote_workout(actor) + if object_type == "Note": + self.update_remote_workout_comment(actor) + + +class LikeActivity(AbstractActivity): + def process_activity(self) -> None: + like: Union[None, WorkoutLike, CommentLike] = None + actor = self.get_actor(create_remote_actor=True) + object_ap_id = self.activity["object"] + + # check if like is related to a workout + target_object = Workout.query.filter_by(ap_id=object_ap_id).first() + if target_object: + like = WorkoutLike( + user_id=actor.user.id, workout_id=target_object.id + ) + + # check if like is related to a comment + else: + target_object = Comment.query.filter_by(ap_id=object_ap_id).first() + if not target_object: + raise ObjectNotFoundException("object", self.activity_name()) + + like = CommentLike( + user_id=actor.user.id, comment_id=target_object.id + ) + + if like: + db.session.add(like) + db.session.commit() diff --git a/fittrackee/federation/constants.py b/fittrackee/federation/constants.py new file mode 100644 index 000000000..3fa967811 --- /dev/null +++ b/fittrackee/federation/constants.py @@ -0,0 +1,7 @@ +AP_CTX = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", +] +CONTEXT = "https://www.w3.org/ns/activitystreams" +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +PUBLIC_STREAM = "https://www.w3.org/ns/activitystreams#Public" diff --git a/fittrackee/federation/decorators.py b/fittrackee/federation/decorators.py new file mode 100644 index 000000000..85e403f1c --- /dev/null +++ b/fittrackee/federation/decorators.py @@ -0,0 +1,58 @@ +from functools import wraps +from typing import Any, Callable + +from flask import current_app + +from fittrackee import appLog +from fittrackee.federation.exceptions import FederationDisabledException +from fittrackee.responses import ( + DisabledFederationErrorResponse, + InternalServerErrorResponse, + UserNotFoundErrorResponse, +) + +from .models import Actor, Domain + + +def federation_required(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Callable: + if not current_app.config["FEDERATION_ENABLED"]: + raise FederationDisabledException() + return f(*args, **kwargs) + + return decorated_function + + +def federation_required_for_route(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 + + +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/enums.py b/fittrackee/federation/enums.py new file mode 100644 index 000000000..81dae3d21 --- /dev/null +++ b/fittrackee/federation/enums.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class ActivityType(Enum): + ACCEPT = "Accept" + CREATE = "Create" + DELETE = "Delete" + FOLLOW = "Follow" + LIKE = "Like" + REJECT = "Reject" + UNDO = "Undo" + UPDATE = "Update" + + +class ActorType(Enum): + APPLICATION = "Application" + GROUP = "Group" + PERSON = "Person" diff --git a/fittrackee/federation/exceptions.py b/fittrackee/federation/exceptions.py new file mode 100644 index 000000000..2a8b3daed --- /dev/null +++ b/fittrackee/federation/exceptions.py @@ -0,0 +1,89 @@ +from typing import Optional + +from fittrackee.exceptions import GenericException + + +class ActivityException(GenericException): + def __init__(self, message: str) -> None: + super().__init__(status="error", message=message) + + +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 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__(status="error", message="Federation is disabled.") + + +class InvalidSignatureException(GenericException): + def __init__(self, message: Optional[str] = None) -> None: + super().__init__( + status="error", + message=f"Invalid signature{f': {message}' if message else ''}.", + ) + + +class InvalidWorkoutException(GenericException): + def __init__(self, message: Optional[str] = None) -> None: + super().__init__( + status="error", + message=( + f"Invalid workout data{f': {message}' if message else ''}." + ), + ) + + +class ObjectNotFoundException(GenericException): + def __init__(self, object_type: str, activity_type: str) -> None: + super().__init__( + status="error", + message=f"{object_type} not found for {activity_type}.", + ) + + +class SenderNotFoundException(GenericException): + def __init__(self) -> None: + super().__init__( + status="error", + message="Sender not found.", + ) + + +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 RemoteServerException(GenericException): + def __init__(self, message: Optional[str] = None) -> None: + super().__init__( + status="error", + message=(message if message else "Invalid remote server"), + ) + + +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/federation.py b/fittrackee/federation/federation.py new file mode 100644 index 000000000..115a8dda5 --- /dev/null +++ b/fittrackee/federation/federation.py @@ -0,0 +1,334 @@ +from typing import Dict, Union + +from flask import Blueprint, request + +from fittrackee.responses import HttpResponse, handle_error_and_return_response +from fittrackee.users.models import FollowRequest + +from .decorators import ( + federation_required_for_route, + get_local_actor_from_username, +) +from .inbox import inbox +from .models import Actor, Domain +from .ordered_collections import OrderedCollection, OrderedCollectionPage + +ap_federation_blueprint = Blueprint("ap_federation", __name__) + + +USERS_PER_PAGE = 10 + + +@ap_federation_blueprint.route( + "/user/", methods=["GET"] +) +@federation_required_for_route +@get_local_actor_from_username +def get_actor( + local_actor: 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", + "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", + "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 + + """ + return HttpResponse( + response=local_actor.serialize(), + content_type="application/jrd+json; charset=utf-8", + ) + + +@ap_federation_blueprint.route( + "/user//inbox", methods=["POST"] +) +@federation_required_for_route +@get_local_actor_from_username +def user_inbox( + local_actor: Actor, app_domain: Domain, preferred_username: str +) -> 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 + + : Union[Dict, HttpResponse]: + """ + Post an activity to shared inbox + + **Example request**: + + .. sourcecode:: http + + POST /federation/inbox HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + : 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(page=int(page), per_page=USERS_PER_PAGE, error_out=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_for_route +@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_for_route +@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/federation/inbox.py b/fittrackee/federation/inbox.py new file mode 100644 index 000000000..57da36b85 --- /dev/null +++ b/fittrackee/federation/inbox.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone +from json import dumps +from typing import Dict, Union +from urllib.parse import urlparse + +import requests +from flask import Request + +from fittrackee import appLog +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + UnauthorizedErrorResponse, +) + +from .exceptions import InvalidSignatureException +from .models import Actor +from .signature import ( + VALID_SIG_DATE_FORMAT, + SignatureVerification, + generate_digest, + generate_signature_header, +) +from .tasks.activity import handle_activity +from .utils import is_invalid_activity_data + + +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() + + try: + signature_verification = SignatureVerification.get_signature(request) + signature_verification.verify() + except InvalidSignatureException: + return UnauthorizedErrorResponse(message="Invalid signature.") + + handle_activity.send(activity=activity_data) + + return {"status": "success"} + + +def send_to_inbox(sender: Actor, activity: Dict, inbox_url: str) -> None: + now_str = datetime.now(timezone.utc).strftime(VALID_SIG_DATE_FORMAT) + parsed_inbox_url = urlparse(inbox_url) + digest = generate_digest(activity) + signed_header = generate_signature_header( + host=parsed_inbox_url.netloc, + path=parsed_inbox_url.path, + date_str=now_str, + actor=sender, + digest=digest, + ) + response = requests.post( + inbox_url, + data=dumps(activity), + headers={ + "Host": parsed_inbox_url.netloc, + "Date": now_str, + "Signature": signed_header, + "Digest": digest, + "Content-Type": "application/ld+json", + }, + timeout=30, + ) + if response.status_code >= 400: + appLog.error( + f"Error when send to inbox '{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 new file mode 100644 index 000000000..489d15b6e --- /dev/null +++ b/fittrackee/federation/models.py @@ -0,0 +1,272 @@ +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +from flask import current_app +from sqlalchemy.engine.base import Connection +from sqlalchemy.event import listens_for +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm.mapper import Mapper +from sqlalchemy.orm.session import Session +from sqlalchemy.types import Enum + +from fittrackee import VERSION, BaseModel, db +from fittrackee.database import TZDateTime + +from .constants import AP_CTX +from .enums import ActorType +from .utils import generate_keys, get_ap_url + +if TYPE_CHECKING: + from fittrackee.users.models import User + +MEDIA_TYPES = { + "gif": "image/gif", + "jpg": "image/jpeg", + "png": "image/png", +} + + +class Domain(BaseModel): + """ActivityPub Domain""" + + __tablename__ = "domains" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column( + db.String(1000), unique=True, nullable=False + ) + created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False) + is_allowed: Mapped[bool] = mapped_column(default=True, nullable=False) + software_name: Mapped[Optional[str]] = mapped_column( + db.String(255), nullable=True + ) + software_version: Mapped[Optional[str]] = mapped_column( + db.String(255), nullable=True + ) + + actors = db.relationship("Actor", back_populates="domain") + + def __str__(self) -> str: + return f"" + + def __init__( + self, + name: str, + created_at: Optional[datetime] = None, + software_name: Optional[str] = None, + software_version: Optional[str] = None, + ) -> None: + self.name = name + self.created_at = ( + datetime.now(timezone.utc) if created_at is None else created_at + ) + self.software_name = software_name + self.software_version = software_version + + @property + def is_remote(self) -> bool: + return self.name != current_app.config["AP_DOMAIN"] + + @property + def software_current_version(self) -> Union[str, None]: + return self.software_version if self.is_remote else VERSION + + 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, + "software_name": self.software_name, + "software_version": self.software_current_version, + } + + +class Actor(BaseModel): + """ActivityPub Actor""" + + __tablename__ = "actors" + __table_args__ = ( + db.UniqueConstraint( + "domain_id", "preferred_username", name="domain_username_unique" + ), + ) + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + activitypub_id: Mapped[str] = mapped_column( + db.String(255), unique=True, nullable=False + ) + domain_id: Mapped[int] = mapped_column( + db.ForeignKey("domains.id"), nullable=False + ) + type: Mapped[ActorType] = mapped_column( + Enum(ActorType, name="actor_types"), server_default="PERSON" + ) + preferred_username: Mapped[str] = mapped_column( + db.String(255), nullable=False + ) + public_key: Mapped[Optional[str]] = mapped_column( + db.String(5000), nullable=True + ) + private_key: Mapped[Optional[str]] = mapped_column( + db.String(5000), nullable=True + ) + profile_url: Mapped[str] = mapped_column(db.String(255), nullable=False) + inbox_url: Mapped[str] = mapped_column(db.String(255), nullable=False) + outbox_url: Mapped[str] = mapped_column(db.String(255), nullable=False) + followers_url: Mapped[str] = mapped_column(db.String(255), nullable=False) + following_url: Mapped[str] = mapped_column(db.String(255), nullable=False) + shared_inbox_url: Mapped[str] = mapped_column( + db.String(255), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False) + last_fetch_date: Mapped[datetime] = mapped_column( + TZDateTime, nullable=True + ) + + domain: Mapped["Domain"] = relationship("Domain", back_populates="actors") + user: Mapped["User"] = relationship( + "User", uselist=False, back_populates="actor" + ) + stats: Mapped["RemoteActorStats"] = relationship( + "RemoteActorStats", cascade="all, delete", uselist=False + ) + + def __str__(self) -> str: + return f"" + + def __init__( + self, + preferred_username: str, + domain_id: int, + created_at: Optional[datetime] = None, + remote_user_data: Optional[Dict] = None, + ) -> None: + self.created_at = ( + datetime.now(timezone.utc) if created_at is None else created_at + ) + self.domain_id = domain_id + self.preferred_username = preferred_username + if remote_user_data: + self.update_remote_data(remote_user_data) + else: + 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( + preferred_username, "shared_inbox" + ) + self.generate_keys() + + @classmethod + def generate_stats_if_remote( + cls, actor_id: int, domain_id: int, session: Session + ) -> None: + domain = Domain.query.filter_by(id=domain_id).one() + if domain.name != current_app.config["AP_DOMAIN"]: + stats = RemoteActorStats(actor_id=actor_id) + session.add(stats) + + def generate_keys(self) -> None: + self.public_key, self.private_key = generate_keys() + + @property + 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 fullname(self) -> Optional[str]: + if self.type == ActorType.PERSON: + 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.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( + "sharedInbox" + ) + self.public_key = remote_user_data["publicKey"]["publicKeyPem"] + self.last_fetch_date = datetime.now(timezone.utc) + + def serialize(self) -> Dict: + actor_dict = { + "@context": AP_CTX, + "id": self.activitypub_id, + "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, + "following": self.following_url, + "manuallyApprovesFollowers": self.manually_approves_followers, + "publicKey": { + "id": f"{self.activitypub_id}#main-key", + "owner": self.activitypub_id, + "publicKeyPem": self.public_key, + }, + "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_TYPES[extension], + "url": ( + f"https://{current_app.config['AP_DOMAIN']}" + f"/api/users/{self.user.username}/picture" + ), + } + return actor_dict + + +@listens_for(Actor, "after_insert") +def on_actor_insert( + mapper: Mapper, connection: Connection, actor: Actor +) -> None: + @listens_for(db.Session, "after_flush", once=True) + def receive_after_flush(session: Session, context: Any) -> None: + Actor.generate_stats_if_remote( + actor_id=actor.id, domain_id=actor.domain_id, session=session + ) + + +class RemoteActorStats(BaseModel): + """ActivityPub Remote Actor statistics""" + + __tablename__ = "remote_actors_stats" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + actor_id: Mapped[int] = mapped_column( + db.ForeignKey("actors.id"), + nullable=False, + index=True, + unique=True, + ) + items: Mapped[int] = mapped_column(default=0, nullable=False) + followers: Mapped[int] = mapped_column(default=0, nullable=False) + following: Mapped[int] = mapped_column(default=0, nullable=False) + + def __init__(self, actor_id: int) -> None: + self.actor_id = actor_id diff --git a/fittrackee/federation/nodeinfo.py b/fittrackee/federation/nodeinfo.py new file mode 100644 index 000000000..c8a1a29ca --- /dev/null +++ b/fittrackee/federation/nodeinfo.py @@ -0,0 +1,128 @@ +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_for_route +from .models import Domain + +ap_nodeinfo_blueprint = Blueprint("ap_nodeinfo", __name__) + + +@ap_nodeinfo_blueprint.route("/.well-known/nodeinfo", methods=["GET"]) +@federation_required_for_route +def get_nodeinfo_url(app_domain: Domain) -> 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": [ + { + "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_for_route +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.5.7" + }, + "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() + actor_count = ( + Actor.query.join(User, User.actor_id == Actor.id) + .filter( + Actor.domain_id == app_domain.id, + User.is_active == True, # noqa + ) + .count() + ) + response = { + "version": "2.0", + "software": { + "name": "fittrackee", + "version": current_app.config["VERSION"], + }, + "protocols": ["activitypub"], + "usage": { + "users": {"total": actor_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/objects/__init__.py b/fittrackee/federation/objects/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/federation/objects/base_object.py b/fittrackee/federation/objects/base_object.py new file mode 100644 index 000000000..8978c7427 --- /dev/null +++ b/fittrackee/federation/objects/base_object.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Optional, Union + +from fittrackee.visibility_levels import VisibilityLevel + +from ..constants import AP_CTX, DATE_FORMAT, PUBLIC_STREAM + +if TYPE_CHECKING: + from fittrackee.comments.models import Comment + from fittrackee.workouts.models import Workout + + from ..enums import ActivityType + from ..models import Actor + + +class BaseObject(ABC): + id: str + type: "ActivityType" + actor: "Actor" + visibility: "VisibilityLevel" + activity_id: str + object_url: str + published: str + + def _init_activity_dict(self) -> Dict: + activity: Dict = { + "@context": AP_CTX, + "type": self.type.value, + "id": f"{self.activity_id}/{self.type.value.lower()}", + "actor": self.actor.activitypub_id, + "published": self.published, + "object": { + "id": self.activity_id, + "published": self.published, + "url": self.object_url, + "attributedTo": self.actor.activitypub_id, + }, + } + if self.visibility == VisibilityLevel.PUBLIC: + activity["to"] = [PUBLIC_STREAM] + activity["cc"] = [self.actor.followers_url] + activity["object"]["to"] = [PUBLIC_STREAM] + activity["object"]["cc"] = [self.actor.followers_url] + else: # for followers + activity["to"] = [self.actor.followers_url] + activity["cc"] = [] + activity["object"]["to"] = [self.actor.followers_url] + activity["object"]["cc"] = [] + return activity + + @staticmethod + def _get_published_date(object_date: datetime) -> str: + return object_date.strftime(DATE_FORMAT) + + @staticmethod + def _get_modification_date( + activity_object: Union["Workout", "Comment"], + ) -> Optional[str]: + return ( + activity_object.modification_date.strftime(DATE_FORMAT) + if activity_object.modification_date + else None + ) + + @abstractmethod + def get_activity(self) -> Dict: + pass diff --git a/fittrackee/federation/objects/comment.py b/fittrackee/federation/objects/comment.py new file mode 100644 index 000000000..136e1ae98 --- /dev/null +++ b/fittrackee/federation/objects/comment.py @@ -0,0 +1,89 @@ +from typing import TYPE_CHECKING, Dict + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.visibility_levels import VisibilityLevel + +from ..enums import ActivityType +from .base_object import BaseObject +from .exceptions import InvalidObjectException + +if TYPE_CHECKING: + from fittrackee.comments.models import Comment + from fittrackee.workouts.models import Workout + + +class CommentObject(BaseObject): + workout: "Workout" + comment: "Comment" + + def __init__(self, comment: "Comment", activity_type: str) -> None: + """ + Note: No visibility check on instantiation if activity is not a + creation. + It should be possible, for instance, to send an Update activity for a + comment with remote mentions removed. + """ + self._check_visibility(comment, activity_type) + self.comment = comment + if not self.comment.ap_id or not self.comment.remote_url: + raise InvalidObjectException( + "Invalid comment, missing 'ap_id' or 'remote_url'" + ) + + self.visibility = comment.text_visibility + self.workout = comment.workout + self.type = ActivityType(activity_type) + self.actor = self.comment.user.actor + self.activity_id = self.comment.ap_id + self.published = self._get_published_date(self.comment.created_at) + self.object_url = self.comment.remote_url + self.activity_dict = self._init_activity_dict() + + @staticmethod + def _check_visibility(comment: "Comment", activity_type: str) -> None: + if ( + activity_type == "Create" + and comment.text_visibility + in [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS] + and not comment.mentioned_users.all() + ): + raise InvalidVisibilityException( + f"object visibility is: '{comment.text_visibility.value}'" + ) + + def get_activity(self) -> Dict: + ( + text_with_mention, + mentioned_users, + ) = self.comment.handle_mentions() + self.activity_dict["object"]["type"] = "Note" + self.activity_dict["object"]["content"] = text_with_mention + self.activity_dict["object"]["inReplyTo"] = ( + self.comment.parent_comment.ap_id + if self.comment.reply_to + else self.workout.ap_id + ) + if self.type == ActivityType.UPDATE: + self.activity_dict["object"] = { + **self.activity_dict["object"], + "updated": self._get_modification_date(self.comment), + } + # existing mentions (local and remote) + mentions = [ + user.actor.activitypub_id + for user in mentioned_users["local"].union( + mentioned_users["remote"] + ) + ] + if self.visibility in [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ]: + self.activity_dict["to"] = mentions + self.activity_dict["cc"] = [] + self.activity_dict["object"]["to"] = mentions + self.activity_dict["object"]["cc"] = [] + else: + self.activity_dict["cc"].extend(mentions) + self.activity_dict["object"]["cc"].extend(mentions) + return self.activity_dict diff --git a/fittrackee/federation/objects/exceptions.py b/fittrackee/federation/objects/exceptions.py new file mode 100644 index 000000000..b0c63bf9e --- /dev/null +++ b/fittrackee/federation/objects/exceptions.py @@ -0,0 +1,2 @@ +class InvalidObjectException(Exception): + pass diff --git a/fittrackee/federation/objects/follow_request.py b/fittrackee/federation/objects/follow_request.py new file mode 100644 index 000000000..e85831299 --- /dev/null +++ b/fittrackee/federation/objects/follow_request.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING, Dict, Tuple + +from ..constants import AP_CTX +from ..enums import ActivityType +from .base_object import BaseObject + +if TYPE_CHECKING: + from fittrackee.federation.models import Actor + + +class FollowRequestObject(BaseObject): + from_actor: "Actor" + to_actor: "Actor" + + def __init__( + self, + from_actor: "Actor", + to_actor: "Actor", + activity_type: ActivityType, + ): + self.from_actor = from_actor + self.to_actor = to_actor + self.type = activity_type + self.actor, self.id = self._get_actor_and_id() + + def _get_actor_and_id(self) -> Tuple["Actor", str]: + if self.type in [ActivityType.FOLLOW, ActivityType.UNDO]: + return ( + self.from_actor, + ( + f"{self.from_actor.activitypub_id}#" + f"{'follow' if self.type == ActivityType.FOLLOW else 'undoe'}s/" # noqa + f"{self.to_actor.fullname}" + ), + ) + return self.to_actor, ( + f"{self.to_actor.activitypub_id}#" + f"{'accept' if self.type == ActivityType.ACCEPT else 'reject'}s/" + f"follow/{self.from_actor.fullname}" + ) + + def _get_follow_activity(self) -> Dict: + return { + "id": ( + f"{self.from_actor.activitypub_id}#follows/" + f"{self.to_actor.fullname}" + ), + "type": ActivityType.FOLLOW.value, + "actor": self.from_actor.activitypub_id, + "object": self.to_actor.activitypub_id, + } + + def get_activity(self) -> Dict: + if self.type == ActivityType.FOLLOW: + activity = self._get_follow_activity() + else: + activity = { + "id": self.id, + "type": self.type.value, + "actor": self.actor.activitypub_id, + "object": self._get_follow_activity(), + } + activity["@context"] = AP_CTX + return activity diff --git a/fittrackee/federation/objects/like.py b/fittrackee/federation/objects/like.py new file mode 100644 index 000000000..c31491bf2 --- /dev/null +++ b/fittrackee/federation/objects/like.py @@ -0,0 +1,41 @@ +from typing import Dict, List, Optional, Union + +from ..constants import AP_CTX +from ..enums import ActivityType +from .base_object import BaseObject +from .exceptions import InvalidObjectException + + +class LikeObject(BaseObject): + def __init__( + self, + target_object_ap_id: Optional[str], + like_id: int, + actor_ap_id: str, + is_undo: bool = False, + ) -> None: + if not target_object_ap_id: + raise InvalidObjectException("Invalid object, missing 'ap_id'") + self.is_undo = is_undo + self.target_object_ap_id = target_object_ap_id + self.like_id = like_id + self.actor_ap_id = actor_ap_id + + def get_activity(self) -> Dict: + activity_id = f"{self.actor_ap_id}#likes/{self.like_id}" + like_object: Dict[str, Union[str, List]] = { + "id": activity_id, + "type": ActivityType.LIKE.value, + "actor": self.actor_ap_id, + "object": self.target_object_ap_id, + } + if self.is_undo: + return { + "@context": AP_CTX, + "id": f"{activity_id}/undo", + "type": ActivityType.UNDO.value, + "actor": self.actor_ap_id, + "object": like_object, + } + like_object["@context"] = AP_CTX + return like_object diff --git a/fittrackee/federation/objects/templates/__init__.py b/fittrackee/federation/objects/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/federation/objects/templates/workout_note.py b/fittrackee/federation/objects/templates/workout_note.py new file mode 100644 index 000000000..8ed692f04 --- /dev/null +++ b/fittrackee/federation/objects/templates/workout_note.py @@ -0,0 +1,5 @@ +WORKOUT_NOTE = """

New workout: {workout_title} ({sport_label}) + +Distance: {workout_distance:.2f}km +Duration: {workout_duration}

+""" diff --git a/fittrackee/federation/objects/tombstone.py b/fittrackee/federation/objects/tombstone.py new file mode 100644 index 000000000..e18ea38fd --- /dev/null +++ b/fittrackee/federation/objects/tombstone.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING, Dict, Union + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.visibility_levels import VisibilityLevel + +from ..constants import AP_CTX, PUBLIC_STREAM +from ..enums import ActivityType +from .base_object import BaseObject + +if TYPE_CHECKING: + from fittrackee.comments.models import Comment + from fittrackee.workouts.models import Workout + + +class TombstoneObject(BaseObject): + # WIP + object_to_delete: Union["Workout", "Comment"] + + def __init__(self, object_to_delete: Union["Workout", "Comment"]) -> None: + self.object_to_delete = object_to_delete + self.object_to_delete_type = object_to_delete.__class__.__name__ + self.type = ActivityType.DELETE + self.actor = self.object_to_delete.user.actor + self.visibility = self._get_object_visibility() + if self.visibility in [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ] and ( + self.object_to_delete_type != "Comment" + or ( + self.object_to_delete_type == "Comment" + and not self.object_to_delete.has_remote_mentions # type: ignore + ) + ): + raise InvalidVisibilityException( + f"object visibility is: '{self.visibility.value}'" + ) + + def _get_object_visibility(self) -> VisibilityLevel: + if self.object_to_delete_type == "Comment": + return self.object_to_delete.text_visibility # type: ignore + return self.object_to_delete.workout_visibility # type: ignore + + def get_activity(self) -> Dict: + delete_activity = { + "@context": AP_CTX, + "id": f"{self.object_to_delete.ap_id}/delete", + "type": "Delete", + "actor": self.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": self.object_to_delete.ap_id, + }, + } + # TODO: handle comments with mentions + if self.visibility == VisibilityLevel.PUBLIC: + delete_activity["to"] = [PUBLIC_STREAM] + delete_activity["cc"] = [self.actor.followers_url] + elif self.visibility in [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ]: + # for comments w/ mentions + if hasattr(self.object_to_delete, "handle_mentions"): + _, mentioned_users = self.object_to_delete.handle_mentions() + mentions = [ + user.actor.activitypub_id + for user in mentioned_users["local"].union( + mentioned_users["remote"] + ) + ] + delete_activity["to"] = mentions + else: + delete_activity["to"] = [] + delete_activity["cc"] = [] + else: + delete_activity["to"] = [self.actor.followers_url] + delete_activity["cc"] = [] + return delete_activity diff --git a/fittrackee/federation/objects/workout.py b/fittrackee/federation/objects/workout.py new file mode 100644 index 000000000..d1f856948 --- /dev/null +++ b/fittrackee/federation/objects/workout.py @@ -0,0 +1,109 @@ +from typing import TYPE_CHECKING, Dict + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.constants import WORKOUT_DATE_FORMAT + +from ..enums import ActivityType +from ..exceptions import InvalidWorkoutException +from .base_object import BaseObject +from .exceptions import InvalidObjectException +from .templates.workout_note import WORKOUT_NOTE + +if TYPE_CHECKING: + from fittrackee.workouts.models import Workout + + +class WorkoutObject(BaseObject): + workout: "Workout" + + def __init__(self, workout: "Workout", activity_type: str) -> None: + self._check_visibility(workout.workout_visibility) + self.workout = workout + if not self.workout.ap_id or not self.workout.remote_url: + raise InvalidObjectException( + "Invalid workout, missing 'ap_id' or 'remote_url'" + ) + self.visibility = workout.workout_visibility + self.type = ActivityType(activity_type) + self.actor = self.workout.user.actor + self.activity_id = self.workout.ap_id + self.published = self._get_published_date(self.workout.creation_date) + self.object_url = self.workout.remote_url + self.activity_dict = self._init_activity_dict() + + @staticmethod + def _check_visibility(visibility: VisibilityLevel) -> None: + if visibility in [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ]: + raise InvalidVisibilityException( + f"object visibility is: '{visibility.value}'" + ) + + def _get_note_content(self) -> str: + # TODO: + # handle translation and imperial units depending on user preferences + return WORKOUT_NOTE.format( + sport_label=self.workout.sport.label, + workout_title=self.workout.title, + workout_distance=self.workout.distance, + workout_duration=self.workout.duration, + workout_url=self.object_url, + ) + + def get_activity(self, is_note: bool = False) -> Dict: + activity = self.activity_dict.copy() + # for non-FitTrackee instances (like Mastodon) + if is_note: + activity["id"] = ( + f"{self.activity_id}/note/{self.type.value.lower()}" + ) + activity["object"]["type"] = "Note" + activity["object"]["content"] = self._get_note_content() + # for FitTrackee instances + else: + activity["object"] = { + **activity["object"], + **{ + "type": "Workout", + "ave_speed": self.workout.ave_speed, + "distance": self.workout.distance, + "duration": str(self.workout.duration), + "max_speed": self.workout.max_speed, + "moving": str(self.workout.moving), + "sport_id": self.workout.sport_id, + "title": self.workout.title, + "workout_date": self.workout.workout_date.strftime( + WORKOUT_DATE_FORMAT + ), + }, + } + if self.type == ActivityType.UPDATE: + activity["object"] = { + **activity["object"], + "updated": self._get_modification_date(self.workout), + } + return activity + + +def convert_duration_string_to_seconds(duration_str: str) -> int: + try: + hour, minutes, seconds = duration_str.split(":") + duration = int(hour) * 3600 + int(minutes) * 60 + int(seconds) + except Exception as e: + raise InvalidWorkoutException( + f"duration or moving format is invalid ({e})" + ) from e + return duration + + +def convert_workout_activity(workout_data: Dict) -> Dict: + return { + **workout_data, + "duration": convert_duration_string_to_seconds( + workout_data["duration"] + ), + "moving": convert_duration_string_to_seconds(workout_data["moving"]), + } diff --git a/fittrackee/federation/ordered_collections.py b/fittrackee/federation/ordered_collections.py new file mode 100644 index 000000000..cf1b23849 --- /dev/null +++ b/fittrackee/federation/ordered_collections.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING, Dict + +from fittrackee.federation.constants import CONTEXT + +if TYPE_CHECKING: + from flask_sqlalchemy import Pagination, Query + + +class OrderedCollection: + def __init__(self, url: str, base_query: "Query") -> 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/signature.py b/fittrackee/federation/signature.py new file mode 100644 index 000000000..96b22929a --- /dev/null +++ b/fittrackee/federation/signature.py @@ -0,0 +1,195 @@ +""" +inspired by bookwyrm signatures.py +https://github.com/bookwyrm-social/bookwyrm +""" + +import base64 +import hashlib +import json +from datetime import datetime, timezone +from typing import Dict, Optional + +import requests +from Crypto.Hash import SHA256 # nosec B413 +from Crypto.PublicKey import RSA # nosec B413 +from Crypto.Signature import pkcs1_15 # nosec B413 +from flask import Request + +from fittrackee import appLog + +from .exceptions import InvalidSignatureException +from .models import Actor + +VALID_DATE_DELTA = 30 # in seconds +VALID_SIG_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" +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}, +} +DEFAULT_ALGORITHM = "rsa-sha256" + + +def generate_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 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: + if actor.private_key is None: + raise InvalidSignatureException("Invalid private key for actor") + signed_string = ( + f"(request-target): post {path}\nhost: {host}\ndate: {date_str}\n" + f"digest: {digest}" + ) + signature = generate_signature(actor.private_key, signed_string) + return ( + f'keyId="{actor.activitypub_id}#main-key",' + f"algorithm={DEFAULT_ALGORITHM}," + 'headers="(request-target) host date digest",' + f'signature="' + signature.decode() + '"' + ) + + +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"]) + self.algorithm = signature_dict.get("algorithm", DEFAULT_ALGORITHM) + self.digest = request.headers.get("Digest") + + @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() from e + + keys_list = list(signature_dict.keys()) + if not all(key in keys_list for key in VALID_SIG_KEYS): + appLog.error( + "Invalid signature headers: missing keys, expected: " + f"{VALID_SIG_KEYS}, got: {keys_list} " + f"(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"}, + timeout=30, + ) + 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_SIG_DATE_FORMAT + ).replace(tzinfo=timezone.utc) + except ValueError: + return True + delta = datetime.now(timezone.utc) - date + return delta.total_seconds() > VALID_DATE_DELTA + + 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.log_and_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.log_and_raise_error("invalid HTTP digest") + + def header_actor_is_payload_actor(self) -> bool: + activity = json.loads(self.request.data.decode()) + return self.key_id.replace("#main-key", "") == activity.get("actor") + + def verify(self) -> None: + if not self.header_actor_is_payload_actor(): + self.log_and_raise_error("invalid actor") + + public_key = self.get_actor_public_key() + if not public_key: + self.log_and_raise_error("invalid public key") + + if self.is_date_invalid(): + self.log_and_raise_error("invalid date header") + + comparison = [] + for headers_part in self.headers.split(" "): + if headers_part == "(request-target)": + comparison.append( + "(request-target): post %s" % self.request.path + ) + else: + if headers_part == "digest": + self.verify_digest() + 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)) # type: ignore + digest = SHA256.new() + digest.update(comparison_string.encode()) + try: + signer.verify(digest, self.signature) + except ValueError: + self.log_and_raise_error("verification failed") 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..9f6b61765 --- /dev/null +++ b/fittrackee/federation/tasks/activity.py @@ -0,0 +1,26 @@ +from importlib import import_module +from typing import Callable, Dict + +from fittrackee import dramatiq + +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 as e: + raise UnsupportedActivityException(activity_type) from e + return Activity + + +@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/inbox.py b/fittrackee/federation/tasks/inbox.py new file mode 100644 index 000000000..88515cdf3 --- /dev/null +++ b/fittrackee/federation/tasks/inbox.py @@ -0,0 +1,18 @@ +from typing import Dict, List + +from fittrackee import appLog, dramatiq +from fittrackee.federation.exceptions import SenderNotFoundException +from fittrackee.federation.inbox import send_to_inbox +from fittrackee.federation.models import Actor + + +@dramatiq.actor(queue_name="fittrackee_send_to_remote_inbox") +def send_to_remote_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 inbox.") + raise SenderNotFoundException() + for inbox_url in recipients: + send_to_inbox(sender=sender, activity=activity, inbox_url=inbox_url) diff --git a/fittrackee/federation/tasks/remote_server.py b/fittrackee/federation/tasks/remote_server.py new file mode 100644 index 000000000..2f0aefb6f --- /dev/null +++ b/fittrackee/federation/tasks/remote_server.py @@ -0,0 +1,23 @@ +from fittrackee import appLog, db, dramatiq +from fittrackee.federation.models import Domain +from fittrackee.federation.utils.remote_domain import ( + get_remote_server_node_info_data, + get_remote_server_node_info_url, +) + + +@dramatiq.actor(queue_name="fittrackee_remote_server") +def update_remote_server(domain_name: str) -> None: + try: + node_info_url = get_remote_server_node_info_url(domain_name) + node_info_data = get_remote_server_node_info_data(node_info_url) + + domain = Domain.query.filter_by(name=domain_name).one() + domain.software_name = node_info_data.get("software", {}).get("name") + domain.software_version = node_info_data.get("software", {}).get( + "version" + ) + + db.session.commit() + except Exception as e: + appLog.error(f"Error when updating remote server '{domain_name}': {e}") diff --git a/fittrackee/federation/utils/__init__.py b/fittrackee/federation/utils/__init__.py new file mode 100644 index 000000000..d86c9bcde --- /dev/null +++ b/fittrackee/federation/utils/__init__.py @@ -0,0 +1,68 @@ +import re +from typing import Dict, Tuple + +# B413:blacklist error on bandit scan +# related pyCrypto and not pycryptodome +# https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b413-import-pycrypto # noqa +# see issue https://github.com/PyCQA/bandit/issues/614 +from Crypto.PublicKey import RSA # nosec B413 +from flask import current_app + +from fittrackee.visibility_levels import VisibilityLevel + +from ..enums import ActivityType + + +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 for local actor. + + Supported URL types: + - 'user_url' + - 'inbox' + - 'outbox' + - 'following' + - 'followers' + - 'shared_inbox' + - 'profile_url' + """ + 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 + 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" + if url_type == "profile_url": + return f"{current_app.config['UI_URL']}/users/{username}" + raise Exception("Invalid 'url_type'.") + + +def remove_url_scheme(url: str) -> str: + return re.sub(r"https?://", "", url) + + +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 sending_activities_allowed(visibility: VisibilityLevel) -> bool: + return current_app.config["FEDERATION_ENABLED"] and visibility in ( + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) diff --git a/fittrackee/federation/utils/remote_actor.py b/fittrackee/federation/utils/remote_actor.py new file mode 100644 index 000000000..5cf2f53a7 --- /dev/null +++ b/fittrackee/federation/utils/remote_actor.py @@ -0,0 +1,28 @@ +from typing import Dict + +import requests + +from fittrackee.federation.exceptions import ActorNotFoundException + + +def get_remote_actor_url(actor_url: str) -> Dict: + response = requests.get( + actor_url, headers={"Accept": "application/activity+json"}, timeout=30 + ) + if response.status_code >= 400: + raise ActorNotFoundException() + + return response.json() + + +def fetch_account_from_webfinger(username: str, domain: str) -> Dict: + response = requests.get( + f"https://{domain}/.well-known/webfinger?" + f"resource=acct:{username}@{domain}", + headers={"Accept": "application/activity+json"}, + timeout=30, + ) + if response.status_code >= 400: + raise ActorNotFoundException() + + return response.json() diff --git a/fittrackee/federation/utils/remote_domain.py b/fittrackee/federation/utils/remote_domain.py new file mode 100644 index 000000000..bb44234b4 --- /dev/null +++ b/fittrackee/federation/utils/remote_domain.py @@ -0,0 +1,32 @@ +from typing import Dict + +import requests + +from fittrackee.federation.exceptions import RemoteServerException + + +def get_remote_server_node_info_url(domain_name: str) -> str: + response = requests.get( + f"https://{domain_name}/.well-known/nodeinfo", timeout=30 + ) + if response.status_code >= 400: + raise RemoteServerException( + f"Error when getting node_info url for server '{domain_name}'" + ) + + node_info_url = response.json().get("links", [{}])[0].get("href") + if not node_info_url: + raise RemoteServerException( + f"Invalid node_info url for server '{domain_name}'" + ) + + return node_info_url + + +def get_remote_server_node_info_data(node_info_url: str) -> Dict: + response = requests.get(node_info_url, timeout=30) + if response.status_code >= 400: + raise RemoteServerException( + f"Error when getting node_info data from '{node_info_url}'" + ) + return response.json() diff --git a/fittrackee/federation/utils/user.py b/fittrackee/federation/utils/user.py new file mode 100644 index 000000000..d388782a8 --- /dev/null +++ b/fittrackee/federation/utils/user.py @@ -0,0 +1,230 @@ +import os +import re +from typing import Dict, Optional, Tuple +from urllib.parse import urlparse + +import requests +from flask import current_app + +from fittrackee import appLog, db +from fittrackee.federation.exceptions import RemoteActorException +from fittrackee.federation.models import MEDIA_TYPES, Actor, Domain +from fittrackee.federation.tasks.remote_server import update_remote_server +from fittrackee.federation.utils.remote_actor import ( + fetch_account_from_webfinger, + get_remote_actor_url, +) +from fittrackee.files import get_absolute_file_path +from fittrackee.users.exceptions import UserNotFoundException +from fittrackee.users.models import User + +from ..exceptions import ActorNotFoundException + +FULL_NAME_REGEX = r"^@?([\w_\-\.]+)@([\w_\-\.]+\.[a-z]{2,})$" +MEDIA_EXTENSIONS = {value: key for (key, value) in MEDIA_TYPES.items()} +ACTOR_URL_TYPES = ["followers", "following"] + + +def get_username_and_domain(full_name: str) -> Tuple: + result = re.match(FULL_NAME_REGEX, full_name) + if result is None: + return None, None + return result.groups() # type: ignore + + +def store_or_delete_user_picture( + remote_actor_object: Dict, user: User +) -> None: + if remote_actor_object.get("icon", {}).get("type") == "Image": + media_type = remote_actor_object["icon"].get("mediaType", "") + file_extension = MEDIA_EXTENSIONS.get(media_type) + if not file_extension: + return + + picture_url = remote_actor_object["icon"]["url"] + response = requests.get(picture_url, timeout=30) + if response.status_code >= 400: + return + + dirpath = os.path.join( + current_app.config["UPLOAD_FOLDER"], "pictures", str(user.id) + ) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + filename = f"{user.username}.{file_extension}" + absolute_picture_path = os.path.join(dirpath, filename) + relative_picture_path = os.path.join( + "pictures", str(user.id), filename + ) + with open(absolute_picture_path, "wb") as file: + file.write(response.content) + user.picture = relative_picture_path + + elif user.picture: + picture_path = get_absolute_file_path(user.picture) + if os.path.isfile(picture_path): + os.remove(picture_path) + user.picture = None + + +def update_remote_actor_stats(actor: Actor) -> None: + # TODO: handle stats.items (after implementing outbox) + if not actor.is_remote: + return + + for url_type in ACTOR_URL_TYPES: + try: + data = get_remote_actor_url(getattr(actor, f"{url_type}_url")) + except ActorNotFoundException: + return + setattr(actor.stats, url_type, data.get("totalItems", 0)) + + +def update_actor_data(actor: Actor, remote_actor_object: Dict) -> None: + actor.user.manually_approves_followers = remote_actor_object[ + "manuallyApprovesFollowers" + ] + store_or_delete_user_picture(remote_actor_object, actor.user) + update_remote_actor_stats(actor) + + +def get_or_create_remote_domain(domain_name: str) -> Domain: + if domain_name == current_app.config["AP_DOMAIN"]: + raise RemoteActorException( + "the provided account is not a remote account" + ) + + remote_domain = Domain.query.filter_by(name=domain_name).first() + if not remote_domain: + remote_domain = Domain(name=domain_name) + db.session.add(remote_domain) + db.session.commit() + update_remote_server.send(domain_name=domain_name) + return remote_domain + + +def get_or_create_remote_domain_from_url(actor_url: str) -> Domain: + domain_name = urlparse(actor_url).netloc + if not domain_name: + raise RemoteActorException("invalid actor url") + return get_or_create_remote_domain(domain_name) + + +def create_remote_user(remote_domain: Domain, remote_actor_url: str) -> User: + try: + remote_actor_object = get_remote_actor_url(remote_actor_url) + except ActorNotFoundException as e: + raise RemoteActorException("can not fetch remote actor") from e + + # 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 as e: + raise RemoteActorException("invalid remote actor object") from e + if actor: + raise RemoteActorException("actor already exists") from None + + try: + actor = Actor( + preferred_username=remote_actor_object["preferredUsername"], + domain_id=remote_domain.id, + remote_user_data=remote_actor_object, + ) + except KeyError as e: + raise RemoteActorException("invalid remote actor object") from e + db.session.add(actor) + db.session.flush() + user = User( + username=( + remote_actor_object["name"] + if remote_actor_object["name"] + else remote_actor_object["preferredUsername"] + ), + email=None, + password=None, + is_remote=True, + ) + db.session.add(user) + user.actor_id = actor.id + user.is_active = True + update_actor_data(actor, remote_actor_object) + db.session.commit() + return actor.user + + +def create_remote_user_from_username(username: str, domain_name: str) -> User: + remote_domain = get_or_create_remote_domain(domain_name) + + # get account links via Webfinger + try: + webfinger = fetch_account_from_webfinger(username, domain_name) + except ActorNotFoundException as e: + raise RemoteActorException("can not fetch remote actor") from e + remote_actor_url = next( + (item for item in webfinger.get("links", []) if item["rel"] == "self"), + None, + ) + if not remote_actor_url: + raise RemoteActorException( + "invalid data fetched from webfinger endpoint" + ) from None + + return create_remote_user(remote_domain, remote_actor_url["href"]) + + +def update_remote_user(actor: Actor) -> None: + if not actor.is_remote: + return None + + try: + remote_actor_object = get_remote_actor_url(actor.activitypub_id) + except ActorNotFoundException as e: + raise RemoteActorException("can not fetch remote actor") from e + actor.user.username = ( + remote_actor_object["name"] + if remote_actor_object["name"] + else remote_actor_object["preferredUsername"] + ) + update_actor_data(actor, remote_actor_object) + db.session.commit() + + +def get_user_from_username( + user_name: Optional[str], + with_action: Optional[str] = None, # create or refresh remote actor +) -> User: + if not user_name: + raise Exception("Invalid user name") + name, domain_name = get_username_and_domain(user_name) + if domain_name is None: # local actor + user = User.query.filter( + User.username == user_name, + User.is_remote == False, # noqa + ).first() + else: # remote actor + actor = None + domain = Domain.query.filter_by(name=domain_name).first() + if not domain and not with_action: + raise UserNotFoundException() + if domain: + actor = Actor.query.filter_by( + preferred_username=name, domain_id=domain.id + ).first() + if not actor: + if with_action == "creation": + return create_remote_user_from_username(name, domain_name) + else: + raise UserNotFoundException() + + if with_action is not None: # refresh existing actor + try: + update_remote_user(actor) + except (ActorNotFoundException, RemoteActorException) as e: + appLog.error(f"Error when update user {actor.fullname}: {e}") + user = actor.user + if not user: + raise UserNotFoundException() + return user diff --git a/fittrackee/federation/webfinger.py b/fittrackee/federation/webfinger.py new file mode 100644 index 000000000..61179ac84 --- /dev/null +++ b/fittrackee/federation/webfinger.py @@ -0,0 +1,97 @@ +from flask import Blueprint, current_app, request + +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + UserNotFoundErrorResponse, +) + +from .decorators import federation_required_for_route +from .models import Actor, Domain + +ap_webfinger_blueprint = Blueprint("ap_webfinger", __name__) + + +@ap_webfinger_blueprint.route("/webfinger", methods=["GET"]) +@federation_required_for_route +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/user/Sam", + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html" + }, + { + "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.") + + 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_id=app_domain.id + ).first() + if not actor: + return UserNotFoundErrorResponse() + + 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( + response=response, content_type="application/jrd+json; charset=utf-8" + ) diff --git a/fittrackee/migrations/versions/73_8842c351a2d8_init_federation.py b/fittrackee/migrations/versions/73_8842c351a2d8_init_federation.py new file mode 100644 index 000000000..b8edc2d63 --- /dev/null +++ b/fittrackee/migrations/versions/73_8842c351a2d8_init_federation.py @@ -0,0 +1,212 @@ +"""init federation + +Revision ID: 8842c351a2d8 +Revises: aa7802092404 +Create Date: 2021-01-10 16:02:43.811023 + +""" +import os +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + +from fittrackee.federation.utils import ( + generate_keys, + get_ap_url, + remove_url_scheme, +) + + +# revision identifiers, used by Alembic. +revision = '8842c351a2d8' +down_revision = 'ff53544547cd' +branch_labels = None +depends_on = None + + +def upgrade(): + 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), + sa.Column('is_allowed', sa.Boolean(), nullable=False), + sa.Column('software_name', sa.String(length=255), nullable=True), + sa.Column('software_version', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + # create local domain (even if federation is not enabled) + domain = remove_url_scheme(os.environ['UI_URL']) + created_at = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + op.execute( + "INSERT INTO domains (name, created_at, is_allowed, software_name)" + f"VALUES ('{domain}', '{created_at}'::timestamp, True, 'fittrackee')" + ) + + 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('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), + 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('last_fetch_date', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ['domain_id'], + ['domains.id'], + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('activitypub_id'), + sa.UniqueConstraint( + 'domain_id', 'preferred_username', name='domain_username_unique' + ), + ) + op.create_table( + 'remote_actors_stats', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('actor_id', sa.Integer(), nullable=False), + sa.Column('items', sa.Integer(), nullable=False), + sa.Column('followers', sa.Integer(), nullable=False), + sa.Column('following', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['actor_id'], + ['actors.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('remote_actors_stats', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_remote_actors_stats_actor_id'), + ['actor_id'], + unique=True, + ) + + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.add_column(sa.Column('ap_id', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('remote_url', sa.Text(), nullable=True)) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('actor_id', sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column('is_remote', sa.Boolean(), nullable=True) + ) + batch_op.alter_column( + 'email', existing_type=sa.VARCHAR(length=255), nullable=True + ) + batch_op.alter_column( + 'password', existing_type=sa.VARCHAR(length=255), nullable=True + ) + batch_op.drop_constraint('users_username_key', type_='unique') + batch_op.create_unique_constraint( + 'username_actor_id_unique', ['username', 'actor_id'] + ) + batch_op.create_unique_constraint('users_actor_id_key', ['actor_id']) + batch_op.create_foreign_key( + 'users_actor_id_fkey', 'actors', ['actor_id'], ['id'] + ) + + with op.batch_alter_table('workouts', schema=None) as batch_op: + batch_op.add_column(sa.Column('ap_id', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('remote_url', sa.Text(), nullable=True)) + + # create local actors with keys (even if federation is not enabled) + # and update users + 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()): + op.execute( + f"UPDATE users SET is_remote = False WHERE users.id = {user.id}" + ) + created_at = datetime.now(timezone.utc).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) " + "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, 'profile_url')}', " + 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) 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.alter_column('users', 'is_remote', nullable=False) + + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.add_column(sa.Column('reply_to', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_comments_reply_to'), ['reply_to'], unique=False) + batch_op.create_foreign_key('comments_reply_to_fkey', 'comments', ['reply_to'], ['id'], ondelete='SET NULL') + + + +def downgrade(): + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.drop_constraint('comments_reply_to_fkey', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_comments_reply_to')) + batch_op.drop_column('reply_to') + + with op.batch_alter_table('workouts', schema=None) as batch_op: + batch_op.drop_column('remote_url') + batch_op.drop_column('ap_id') + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('users_actor_id_fkey', type_='foreignkey') + batch_op.drop_constraint('users_actor_id_key', type_='unique') + batch_op.drop_constraint('username_actor_id_unique', type_='unique') + batch_op.create_unique_constraint('users_username_key', ['username']) + batch_op.alter_column( + 'password', existing_type=sa.VARCHAR(length=255), nullable=False + ) + batch_op.alter_column( + 'email', existing_type=sa.VARCHAR(length=255), nullable=False + ) + batch_op.drop_column('is_remote') + batch_op.drop_column('actor_id') + + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.drop_column('remote_url') + batch_op.drop_column('ap_id') + + with op.batch_alter_table('remote_actors_stats', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_remote_actors_stats_actor_id')) + + op.drop_table('remote_actors_stats') + op.drop_table('actors') + op.execute('DROP TYPE actor_types') + op.drop_table('domains') diff --git a/fittrackee/responses.py b/fittrackee/responses.py index 7364d3e2a..0e544b039 100644 --- a/fittrackee/responses.py +++ b/fittrackee/responses.py @@ -181,6 +181,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/application/test_app_config_model.py b/fittrackee/tests/application/test_app_config_model.py index 435a95fc2..0f1c1144b 100644 --- a/fittrackee/tests/application/test_app_config_model.py +++ b/fittrackee/tests/application/test_app_config_model.py @@ -25,6 +25,7 @@ def test_application_config( serialized_app_config = config.serialize() assert serialized_app_config["admin_contact"] == config.admin_contact + assert serialized_app_config["federation_enabled"] is False assert ( serialized_app_config["elevation_services"] == config.elevation_services diff --git a/fittrackee/tests/comments/mixins.py b/fittrackee/tests/comments/mixins.py index 7a5d03e45..a6ad8b9ef 100644 --- a/fittrackee/tests/comments/mixins.py +++ b/fittrackee/tests/comments/mixins.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Optional +from unittest.mock import patch from fittrackee import db from fittrackee.comments.models import Comment @@ -19,7 +20,9 @@ def create_comment( text: Optional[str] = None, text_visibility: VisibilityLevel = VisibilityLevel.PRIVATE, created_at: Optional[datetime] = None, + parent_comment: Optional[Comment] = None, with_mentions: bool = True, + with_federation: bool = False, ) -> Comment: text = self.random_string() if text is None else text comment = Comment( @@ -28,10 +31,15 @@ def create_comment( text=text, text_visibility=text_visibility, created_at=created_at, + reply_to=parent_comment.id if parent_comment else None, ) db.session.add(comment) db.session.flush() if with_mentions: - comment.create_mentions() + with patch("fittrackee.federation.utils.user.update_remote_user"): + comment.create_mentions() + if with_federation: + comment.ap_id = comment.get_ap_id() + comment.remote_url = comment.get_remote_url() db.session.commit() return comment diff --git a/fittrackee/tests/comments/test_comments_api.py b/fittrackee/tests/comments/test_comments_api.py index 3bb67c1d8..5c77e8dfc 100644 --- a/fittrackee/tests/comments/test_comments_api.py +++ b/fittrackee/tests/comments/test_comments_api.py @@ -195,6 +195,41 @@ def test_it_returns_404_when_blocked_user_comments_a_workout( f"workout not found (id: {workout_cycling_user_2.short_id})", ) + def test_it_returns_400_when_comment_visibility_is_invalid( + self, + 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 = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400( + response, + "invalid visibility: followers_and_remote_only, " + "federation is disabled.", + ) + def test_it_returns_500_when_data_is_invalid( self, app: Flask, @@ -415,6 +450,304 @@ def test_expected_scope_is_workouts_write( ) +class TestPostWorkoutCommentReply(CommentMixin, ApiTestCaseMixin): + @pytest.mark.parametrize( + "input_comment_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_returns_404_when_user_can_not_access_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_comment_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=input_comment_visibility, + ) + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response, "'reply_to' is invalid") + + def test_it_returns_404_when_user_blocked_by_workout_owner_replies( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_cycling_user_2.short_id})", + ) + + def test_it_returns_404_when_user_blocked_by_comment_author_replies( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_3.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response, "'reply_to' is invalid") + + def test_it_returns_400_when_user_replies_to_not_existing_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=self.random_short_id(), + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response, "'reply_to' is invalid") + + def test_it_returns_201_when_user_replies_to_a_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert data["comment"]["reply_to"] == comment.short_id + + def test_it_creates_reply_with_wider_visibility_than_parent_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert data["comment"]["reply_to"] == comment.short_id + assert ( + data["comment"]["text_visibility"] == VisibilityLevel.PUBLIC.value + ) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_400_when_comment_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "'reply_to' is invalid") + + class TestGetWorkoutCommentAsUser(CommentMixin, ApiTestCaseMixin): def test_it_returns_404_when_workout_comment_does_not_exist( self, @@ -840,8 +1173,260 @@ def test_it_returns_404_when_comment_is_suspended( workout_cycling_user_2, text_visibility=VisibilityLevel.PRIVATE, ) - comment.suspended_at = datetime.now(timezone.utc) - db.session.commit() + comment.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + +class TestGetWorkoutCommentAsOwner(CommentMixin, ApiTestCaseMixin): + @pytest.mark.parametrize( + "input_text_visibility", + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + 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, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_403_when_user_is_suspended( + self, + 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + user_1.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + def test_it_returns_suspended_comment( + self, + 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + comment.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutCommentAsUnauthenticatedUser( + CommentMixin, ApiTestCaseMixin +): + @pytest.mark.parametrize( + "input_text_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + 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, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_suspended_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["comment"] == jsonify_dict(comment.serialize()) + + def test_it_returns_comment_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["comment"] == jsonify_dict(comment.serialize()) + + def test_expected_scope_is_workouts_read( + self, app: Flask, user_1: User + ) -> None: + self.assert_response_scope( + app=app, + user=user_1, + client_method="get", + endpoint=f"/api/comments/{self.random_short_id()}", + invalid_scope="workouts:write", + expected_endpoint_scope="workouts:read", + ) + + +class TestGetWorkoutCommentWithReplies(CommentMixin, ApiTestCaseMixin): + def test_it_gets_comment_with_replies( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + reply = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -849,43 +1434,37 @@ def test_it_returns_404_when_comment_is_suspended( response = client.get( f"/api/comments/{comment.short_id}", content_type="application/json", - headers=dict( - Authorization=f"Bearer {auth_token}", - ), - ) - - self.assert_404_with_message( - response, - f"workout comment not found (id: {comment.short_id})", + headers=dict(Authorization=f"Bearer {auth_token}"), ) + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["comment"]["replies"] == [ + jsonify_dict(reply.serialize(user_1)) + ] -class TestGetWorkoutCommentAsOwner(CommentMixin, ApiTestCaseMixin): - @pytest.mark.parametrize( - "input_text_visibility", - [ - VisibilityLevel.PRIVATE, - VisibilityLevel.FOLLOWERS, - VisibilityLevel.PUBLIC, - ], - ) - def test_it_returns_comment_when_visibility_allows_access( + def test_it_does_not_return_reply_from_blocked_user( self, 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, - input_text_visibility: VisibilityLevel, + workout_cycling_user_1: Workout, ) -> None: - user_2.approves_follow_request_from(user_1) - workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC comment = self.create_comment( user_1, - workout_cycling_user_2, - text_visibility=input_text_visibility, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, ) + user_1.blocks_user(user_2) client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -893,34 +1472,36 @@ def test_it_returns_comment_when_visibility_allows_access( response = client.get( f"/api/comments/{comment.short_id}", content_type="application/json", - headers=dict( - Authorization=f"Bearer {auth_token}", - ), + headers=dict(Authorization=f"Bearer {auth_token}"), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert data["status"] == "success" - assert data["comment"] == jsonify_dict(comment.serialize(user_1)) + assert data["comment"]["replies"] == [] - def test_it_returns_403_when_user_is_suspended( + def test_it_does_not_return_reply_when_user_is_blocked( self, app: Flask, user_1: User, user_2: User, + user_3: 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 = VisibilityLevel.PUBLIC comment = self.create_comment( - user_1, + user_2, workout_cycling_user_2, - text_visibility=VisibilityLevel.PRIVATE, + text_visibility=VisibilityLevel.PUBLIC, ) - user_1.suspended_at = datetime.now(timezone.utc) - db.session.commit() + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + user_3.blocks_user(user_1) client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -928,30 +1509,35 @@ def test_it_returns_403_when_user_is_suspended( response = client.get( f"/api/comments/{comment.short_id}", content_type="application/json", - headers=dict( - Authorization=f"Bearer {auth_token}", - ), + headers=dict(Authorization=f"Bearer {auth_token}"), ) - self.assert_403(response) + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["comment"]["replies"] == [] - def test_it_returns_suspended_comment( + def test_it_returns_suspended_reply( self, 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, + workout_cycling_user_1: Workout, ) -> None: - user_2.approves_follow_request_from(user_1) - workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC comment = self.create_comment( user_1, - workout_cycling_user_2, - text_visibility=VisibilityLevel.PRIVATE, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, ) - comment.suspended_at = datetime.now(timezone.utc) + reply = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + reply.suspended_at = datetime.now(timezone.utc) db.session.commit() client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -960,82 +1546,90 @@ def test_it_returns_suspended_comment( response = client.get( f"/api/comments/{comment.short_id}", content_type="application/json", - headers=dict( - Authorization=f"Bearer {auth_token}", - ), + headers=dict(Authorization=f"Bearer {auth_token}"), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert data["status"] == "success" - assert data["comment"] == jsonify_dict(comment.serialize(user_1)) - + assert data["comment"]["replies"] == [ + jsonify_dict(reply.serialize(user_1)) + ] -class TestGetWorkoutCommentAsUnauthenticatedUser( - CommentMixin, ApiTestCaseMixin -): - @pytest.mark.parametrize( - "input_text_visibility", - [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], - ) - def test_it_returns_404_when_comment_visibility_does_not_allow_access( + def test_it_gets_comment_when_reply_is_not_visible( self, 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, - input_text_visibility: VisibilityLevel, + workout_cycling_user_1: Workout, ) -> None: - user_2.approves_follow_request_from(user_1) - workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC comment = self.create_comment( user_1, - workout_cycling_user_2, - text_visibility=input_text_visibility, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + # not visible reply + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + parent_comment=comment, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email ) - client = app.test_client() response = client.get( f"/api/comments/{comment.short_id}", content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), ) - self.assert_404_with_message( - response, - f"workout comment not found (id: {comment.short_id})", - ) + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["comment"]["replies"] == [] - def test_it_returns_suspended_comment( + def test_it_gets_reply( self, app: Flask, user_1: User, - user_2: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC comment = self.create_comment( - user_2, + user_1, workout_cycling_user_1, - text_visibility=VisibilityLevel.PUBLIC, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + reply = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email ) - comment.suspended_at = datetime.now(timezone.utc) - db.session.commit() - client = app.test_client() response = client.get( - f"/api/comments/{comment.short_id}", + f"/api/comments/{reply.short_id}", 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["comment"] == jsonify_dict(comment.serialize()) + assert data["comment"]["reply_to"] == jsonify_dict( + comment.serialize(user_1, with_replies=False) + ) + assert data["comment"]["replies"] == [] - def test_it_returns_comment_when_visibility_is_public( + def test_it_gets_reply_when_parent_is_not_visible_anymore( self, app: Flask, user_1: User, @@ -1047,31 +1641,30 @@ def test_it_returns_comment_when_visibility_is_public( comment = self.create_comment( user_2, workout_cycling_user_1, - text_visibility=VisibilityLevel.PUBLIC, + text_visibility=VisibilityLevel.FOLLOWERS, ) - client = app.test_client() + reply = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + comment.text_visibility = VisibilityLevel.PRIVATE response = client.get( - f"/api/comments/{comment.short_id}", + f"/api/comments/{reply.short_id}", 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["comment"] == jsonify_dict(comment.serialize()) - - def test_expected_scope_is_workouts_read( - self, app: Flask, user_1: User - ) -> None: - self.assert_response_scope( - app=app, - user=user_1, - client_method="get", - endpoint=f"/api/comments/{self.random_short_id()}", - invalid_scope="workouts:write", - expected_endpoint_scope="workouts:read", - ) + assert data["comment"]["reply_to"] is None + assert data["comment"]["replies"] == [] class GetWorkoutCommentsTestCase(CommentMixin, ApiTestCaseMixin): @@ -1683,6 +2276,76 @@ def test_it_returns_only_comments_user_can_access( ] +class TestGetWorkoutsCommentWithReplies(CommentMixin, ApiTestCaseMixin): + def test_it_gets_replies_a_user_can_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + visible_replies = [ + # owned comment + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + parent_comment=comment, + ), + # public reply + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ), + # reply from following user + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ), + ] + # user_4 blocks user_1 + self.create_comment( + user_4, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + user_4.blocks_user(user_1) + 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}/comments", + 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"]["comments"]) == 1 + assert data["data"]["comments"][0]["id"] == comment.short_id + assert data["data"]["comments"][0]["replies"] == [ + jsonify_dict(reply.serialize(user_1)) for reply in visible_replies + ] + + class TestDeleteWorkoutComment(ApiTestCaseMixin, CommentMixin): def test_it_returns_error_if_user_is_not_authenticated( self, @@ -1840,6 +2503,38 @@ def test_it_deletes_workout_comment( assert response.status_code == 204 assert Comment.query.first() is None + def test_it_deletes_workout_comment_having_reply( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + reply = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 204 + assert reply.reply_to is None + def test_it_deletes_mentions( self, app: Flask, diff --git a/fittrackee/tests/comments/test_comments_models.py b/fittrackee/tests/comments/test_comments_models.py index d721b8336..c3265fb69 100644 --- a/fittrackee/tests/comments/test_comments_models.py +++ b/fittrackee/tests/comments/test_comments_models.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from unittest.mock import Mock, patch import pytest from flask import Flask @@ -7,6 +8,7 @@ from fittrackee import db from fittrackee.comments.exceptions import CommentForbiddenException from fittrackee.comments.models import Comment, CommentLike, Mention +from fittrackee.exceptions import InvalidVisibilityException from fittrackee.users.models import FollowRequest, User from fittrackee.utils import encode_uuid from fittrackee.visibility_levels import VisibilityLevel @@ -54,6 +56,29 @@ def test_created_date_is_initialized_on_creation_when_not_provided( assert comment.created_at == now + def test_it_raises_error_when_privacy_is_invalid( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + text_visibility = VisibilityLevel.FOLLOWERS_AND_REMOTE + with pytest.raises( + InvalidVisibilityException, + match=( + f"invalid visibility: {text_visibility.value}, " + "federation is disabled." + ), + ): + Comment( + user_id=user_1.id, + workout_id=workout_cycling_user_1.id, + text=self.random_string(), + text_visibility=text_visibility, + ) + def test_short_id_returns_encoded_comment_uuid( self, app: Flask, @@ -161,6 +186,44 @@ def test_suspension_action_is_none_when_comment_is_unsuspended( assert comment.suspension_action is None + def test_it_gets_comment_ap_id( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, workout_cycling_user_1, text=self.random_string() + ) + + assert comment.get_ap_id() == ( + f"{user_2.actor.activitypub_id}/" + f"workouts/{workout_cycling_user_1.short_id}/" + f"comments/{comment.short_id}" + ) + + def test_it_gets_comment_remote_url( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, workout_cycling_user_1, text=self.random_string() + ) + + assert comment.get_remote_url() == ( + f"https://{user_2.actor.domain.name}/" + f"workouts/{workout_cycling_user_1.short_id}/" + f"comments/{comment.short_id}" + ) + class TestWorkoutCommentModelSerializeForCommentOwner( ReportMixin, CommentMixin @@ -210,6 +273,8 @@ def test_it_serializes_owner_comment( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, **suspended_at, @@ -240,6 +305,8 @@ def test_it_serializes_owner_comment_when_workout_is_deleted( "mentions": [], "suspended_at": comment.suspended_at, "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -268,6 +335,8 @@ def test_it_serializes_owner_comment_when_workout_is_not_visible( "mentions": [], "suspended_at": comment.suspended_at, "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -302,6 +371,8 @@ def test_it_serializes_owner_comment_when_comment_is_suspended( "suspended_at": comment.suspended_at, "suspension": expected_report_action.serialize(user_1, full=False), "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -380,6 +451,8 @@ def test_it_serializes_comment_for_follower_when_privacy_allows_it( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -414,6 +487,8 @@ def test_it_serializes_comment_when_workout_is_not_visible( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -471,6 +546,8 @@ def test_it_serializes_comment_when_comment_is_public( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -504,6 +581,8 @@ def test_it_serializes_comment_when_workout_is_deleted( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -535,6 +614,8 @@ def test_it_serializes_comment_when_workout_is_not_visible( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -612,6 +693,8 @@ def test_it_serializes_comment_when_report_flag_is_true( "created_at": comment.created_at, "mentions": [user_2.serialize()], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, **suspended_at, @@ -649,6 +732,8 @@ def test_it_does_not_return_content_when_comment_is_suspended( "mentions": [], "suspended": True, "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -705,6 +790,8 @@ def test_it_serializes_comment_when_report_flag_is_true( "created_at": comment.created_at, "mentions": [user_2.serialize()], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, "suspended": True, @@ -762,6 +849,8 @@ def test_it_serializes_comment_when_comment_is_public( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } @@ -792,6 +881,509 @@ def test_it_serializes_comment_when_workout_is_not_visible( "created_at": comment.created_at, "mentions": [], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], + "likes_count": 0, + "liked": False, + } + + +class TestWorkoutCommentModelSerializeForReplies(CommentMixin): + def test_it_serializes_comment_with_reply( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + + serialized_comment = parent_comment.serialize(user_1) + + assert serialized_comment == { + "id": parent_comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": parent_comment.text, + "text_html": parent_comment.text, # no mention + "text_visibility": parent_comment.text_visibility, + "created_at": parent_comment.created_at, + "mentions": [], + "suspended_at": parent_comment.suspended_at, + "modification_date": parent_comment.modification_date, + "reply_to": None, + "replies": [comment.serialize(user_1)], + "likes_count": 0, + "liked": False, + } + + def test_it_serializes_comment_with_suspended_reply( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspended_comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + suspended_comment.suspended_at = datetime.now(timezone.utc) + + serialized_comment = parent_comment.serialize(user_1) + + assert serialized_comment == { + "id": parent_comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": parent_comment.text, + "text_html": parent_comment.text, # no mention + "text_visibility": parent_comment.text_visibility, + "created_at": parent_comment.created_at, + "mentions": [], + "suspended_at": parent_comment.suspended_at, + "modification_date": parent_comment.modification_date, + "reply_to": None, + "replies": [suspended_comment.serialize(user_1)], + "likes_count": 0, + "liked": False, + } + + def test_it_serializes_parent_comment_without_replies( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + + serialized_comment = parent_comment.serialize( + user_1, with_replies=False + ) + + assert serialized_comment == { + "id": parent_comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": parent_comment.text, + "text_html": parent_comment.text, # no mention + "text_visibility": parent_comment.text_visibility, + "created_at": parent_comment.created_at, + "mentions": [], + "suspended_at": parent_comment.suspended_at, + "modification_date": parent_comment.modification_date, + "reply_to": None, + "replies": [], + "likes_count": 0, + "liked": False, + } + + def test_it_serializes_comment_reply( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_2.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "mentions": [], + "modification_date": comment.modification_date, + "reply_to": parent_comment.short_id, + "replies": [], + "likes_count": 0, + "liked": False, + } + + def test_it_serializes_comment_reply_with_serialized_parent( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + + serialized_comment = comment.serialize(user_1, get_parent_comment=True) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_2.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "mentions": [], + "modification_date": comment.modification_date, + "reply_to": parent_comment.serialize(user_1, with_replies=False), + "replies": [], + "likes_count": 0, + "liked": False, + } + + def test_it_serializes_comment_reply_when_workout_is_deleted( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + db.session.delete(workout_cycling_user_1) + db.session.commit() + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_2.serialize(), + "workout_id": None, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "mentions": [], + "modification_date": comment.modification_date, + "reply_to": parent_comment.short_id, + "replies": [], + "likes_count": 0, + "liked": False, + } + + def test_it_returns_only_visible_replies_for_a_user( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_1.approves_follow_request_from(user_2) + user_1.approves_follow_request_from(user_3) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + # replies + self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + parent_comment=comment, + ) + visible_replies = [ + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + ] + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + visible_replies.append( + self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ), + ) + + serialized_comment = comment.serialize(user_3) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "mentions": [], + "modification_date": comment.modification_date, + "reply_to": None, + "replies": [ + visible_reply.serialize(user_3) + for visible_reply in visible_replies + ], + "likes_count": 0, + "liked": False, + } + + def test_it_returns_only_visible_replies_for_unauthenticated_user( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_1.approves_follow_request_from(user_2) + user_1.approves_follow_request_from(user_3) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + # replies + self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + parent_comment=comment, + ) + visible_reply = self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + suspended_reply = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + suspended_reply.suspended_at = datetime.now(timezone.utc) + + serialized_comment = comment.serialize(user_3) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "mentions": [], + "modification_date": comment.modification_date, + "reply_to": None, + "replies": [ + visible_reply.serialize(user_3), + suspended_reply.serialize(user_3), + ], + "likes_count": 0, + "liked": False, + } + + def test_it_returns_all_replies( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + visible_replies = [] + for _ in range(7): + visible_replies.append( + self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ), + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["replies"] == [ + visible_reply.serialize(user_1) + for visible_reply in visible_replies + ] + + +class TestWorkoutCommentModelSerializeForRepliesForAdmin(CommentMixin): + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_raises_error_when_comments_are_not_visible( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_3_to_user_2: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=parent_comment, + ) + + with pytest.raises(CommentForbiddenException): + parent_comment.serialize(user_1_admin) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_serializes_comment_with_reply( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_3_to_user_2: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + # for report only parent comment is returned + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=input_visibility, + ) + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_visibility, + parent_comment=parent_comment, + ) + + serialized_comment = parent_comment.serialize( + user_1_admin, for_report=True + ) + + assert serialized_comment == { + "id": parent_comment.short_id, + "user": user_2.serialize(), + "workout_id": workout_cycling_user_2.short_id, + "text": parent_comment.text, + "text_html": parent_comment.text, # no mention + "text_visibility": parent_comment.text_visibility, + "created_at": parent_comment.created_at, + "mentions": [], + "suspended_at": parent_comment.suspended_at, + "modification_date": parent_comment.modification_date, + "reply_to": None, + "replies": [], "likes_count": 0, "liked": False, } @@ -817,10 +1409,12 @@ def test_it_returns_empty_set_when_no_mentions( _, mentioned_users = comment.create_mentions() - assert mentioned_users == set() + assert mentioned_users == {"local": set(), "remote": set()} + @patch("fittrackee.federation.utils.user.fetch_account_from_webfinger") def test_it_does_not_create_mentions_when_mentioned_user_does_not_exist( self, + fetch_mock: Mock, app: Flask, user_1: User, sport_1_cycling: Sport, @@ -835,6 +1429,7 @@ def test_it_does_not_create_mentions_when_mentioned_user_does_not_exist( text_visibility=VisibilityLevel.PUBLIC, with_mentions=False, ) + fetch_mock.side_effect = Exception() comment.create_mentions() @@ -859,7 +1454,7 @@ def test_it_returns_empty_set_when_mentioned_user_does_not_exist( _, mentioned_users = comment.create_mentions() - assert mentioned_users == set() + assert mentioned_users == {"local": set(), "remote": set()} def test_it_creates_mentions_when_mentioned_user_exists( self, @@ -906,7 +1501,7 @@ def test_it_returns_mentioned_user( _, mentioned_users = comment.create_mentions() - assert mentioned_users == {user_3} + assert mentioned_users == {"local": {user_3}, "remote": set()} class TestWorkoutCommentModelSerializeForMentions(CommentMixin): @@ -997,6 +1592,8 @@ def test_it_serializes_comment( "created_at": comment.created_at, "mentions": [user_2.serialize()], "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], "likes_count": 0, "liked": False, } diff --git a/fittrackee/tests/comments/test_utils.py b/fittrackee/tests/comments/test_utils.py index fdcf74c9e..b2ceea898 100644 --- a/fittrackee/tests/comments/test_utils.py +++ b/fittrackee/tests/comments/test_utils.py @@ -11,7 +11,7 @@ def test_it_returns_empty_dict_when_no_mentions(self, app: Flask) -> None: _, mentioned_users = handle_mentions(text) - assert mentioned_users == set() + assert mentioned_users == {"local": set(), "remote": set()} def test_it_returns_unchanged_text_when_no_mentions( self, app: Flask @@ -29,7 +29,7 @@ def test_it_returns_empty_dict_when_user_not_found_by_username( _, mentioned_users = handle_mentions(text) - assert mentioned_users == set() + assert mentioned_users == {"local": set(), "remote": set()} def test_it_returns_unchanged_text_when_user_not_found_by_username( self, app: Flask @@ -40,6 +40,15 @@ def test_it_returns_unchanged_text_when_user_not_found_by_username( assert linkified_text == text + def test_it_returns_empty_dict_when_user_not_found_by_fullname( + self, app: Flask + ) -> None: + text = f"@{random_string()}@{random_string()} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {"local": set(), "remote": set()} + def test_it_returns_user_when_mentioned_by_username( self, app: Flask, user_1: User ) -> None: @@ -47,7 +56,7 @@ def test_it_returns_user_when_mentioned_by_username( _, mentioned_users = handle_mentions(text) - assert mentioned_users == {user_1} + assert mentioned_users == {"local": {user_1}, "remote": set()} def test_it_returns_text_with_link_when_user_found_by_username( self, app: Flask, user_1: User @@ -62,6 +71,28 @@ def test_it_returns_text_with_link_when_user_found_by_username( f'rel="noopener noreferrer">@{user_1.username}', ) + def test_it_returns_user_when_mentioned_by_actor_fullname( + self, app: Flask, user_1: User + ) -> None: + text = f"@{user_1.fullname} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {"local": {user_1}, "remote": set()} + + def test_it_returns_text_with_link_when_user_found_by_actor_fullname( + self, app: Flask, user_1: User + ) -> None: + text = f"@{user_1.fullname} {random_string()}" + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text.replace( + f"@{user_1.fullname}", + f'@{user_1.fullname}', + ) + def test_it_returns_deduplicated_user_when_mentioned_twice( self, app: Flask, user_1: User ) -> None: @@ -70,7 +101,7 @@ def test_it_returns_deduplicated_user_when_mentioned_twice( _, mentioned_users = handle_mentions(text) - assert mentioned_users == {user_1} + assert mentioned_users == {"local": {user_1}, "remote": set()} def test_it_returns_text_unchanged_when_mentioned_user_is_in_URL( self, app: Flask, user_1: User diff --git a/fittrackee/tests/conftest.py b/fittrackee/tests/conftest.py index f5f7e9fed..749e3ad04 100644 --- a/fittrackee/tests/conftest.py +++ b/fittrackee/tests/conftest.py @@ -1,4 +1,7 @@ import os +from typing import Iterator +from unittest.mock import patch +from uuid import uuid4 import pytest from werkzeug.test import TestResponse @@ -17,6 +20,8 @@ "fittrackee.tests.fixtures.fixtures_app", "fittrackee.tests.fixtures.fixtures_emails", "fittrackee.tests.fixtures.fixtures_equipments", + "fittrackee.tests.fixtures.fixtures_federation", + "fittrackee.tests.fixtures.fixtures_federation_users", "fittrackee.tests.fixtures.fixtures_geometries", "fittrackee.tests.fixtures.fixtures_workouts", "fittrackee.tests.fixtures.fixtures_users", @@ -26,3 +31,17 @@ # Prevent pytest from collecting TestResponse as test TestResponse.__test__ = False # type: ignore + + +@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/equipments/test_equipments_api.py b/fittrackee/tests/equipments/test_equipments_api.py index 9c54aa85f..8ddf6e173 100644 --- a/fittrackee/tests/equipments/test_equipments_api.py +++ b/fittrackee/tests/equipments/test_equipments_api.py @@ -628,7 +628,7 @@ def test_it_adds_an_equipment_with_default_sports( } equipment = Equipment.query.filter_by( uuid=decode_short_id(equipment["id"]) - ).first() + ).one() assert user_1_sport_1_preference.default_equipments.all() == [ equipment ] @@ -739,7 +739,7 @@ def test_it_replaces_existing_default_equipment_on_creation( assert equipment["default_for_sport_ids"] == [sport_2_running.id] equipment = Equipment.query.filter_by( uuid=decode_short_id(equipment["id"]) - ).first() + ).one() assert user_1_sport_2_preference.default_equipments.all() == [ equipment ] 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/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..5b1502560 --- /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: + config = AppConfig.query.one() + config.max_users = 2 + + assert config.is_registration_enabled + + def test_it_returns_registration_is_disabled( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + config = AppConfig.query.one() + config.max_users = 1 + + assert config.is_registration_enabled is False diff --git a/fittrackee/tests/federation/comments/__init__.py b/fittrackee/tests/federation/comments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/federation/comments/test_comments_api.py b/fittrackee/tests/federation/comments/test_comments_api.py new file mode 100644 index 000000000..423b2c317 --- /dev/null +++ b/fittrackee/tests/federation/comments/test_comments_api.py @@ -0,0 +1,1603 @@ +import json +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.comments.models import Comment, Mention +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin +from ...comments.test_comments_api import GetWorkoutCommentsTestCase +from ...mixins import ApiTestCaseMixin, BaseTestMixin +from ...utils import jsonify_dict + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.comments.comments.send_to_remote_inbox") +class TestPostWorkoutComment(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_returns_404_when_user_can_not_access_workout( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + # user_1 does not follow remote_user + remote_cycling_workout.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/workouts/{remote_cycling_workout.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {remote_cycling_workout.short_id})", + ) + + def test_it_returns_404_when_follower_can_not_access_workout( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + # user_1 follows remote_user but the workout is private + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = VisibilityLevel.PRIVATE + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/workouts/{remote_cycling_workout.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {remote_cycling_workout.short_id})", + ) + + def test_it_returns_201_when_comment_is_created( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment_text = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/workouts/{remote_cycling_workout.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=comment_text, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=remote_cycling_workout.id + ).one() + assert data["comment"] == jsonify_dict(new_comment.serialize(user_1)) + assert new_comment.ap_id == ( + f"{user_1.actor.activitypub_id}/" + f"workouts/{remote_cycling_workout.short_id}/" + f"comments/{new_comment.short_id}" + ) + assert new_comment.remote_url == ( + f"https://{user_1.actor.domain.name}/" + f"workouts/{remote_cycling_workout.short_id}/" + f"comments/{new_comment.short_id}" + ) + assert new_comment.reply_to is None + + def test_it_returns_201_when_user_replies_to_a_remote_comment( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + reply_to=comment.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert data["comment"]["reply_to"] == comment.short_id + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_does_not_call_sent_to_inbox_if_privacy_is_private_or_local_followers_only_and_no_mentions( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + follow_request_from_user_2_to_user_1: FollowRequest, + input_workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + user_1.approves_follow_request_from(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=input_workout_visibility, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_user_has_no_remote_followers( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_calls_sent_to_inbox_if_comment_has_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + # remote_user is mentioned but does not follow user_1 + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + note_activity = Comment.query.one().get_activity( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + note_activity = Comment.query.one().get_activity( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + remote_user_2.send_follow_request_to(user_1) + user_1.approves_follow_request_from(remote_user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + note_activity = Comment.query.one().get_activity( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) + + def test_it_creates_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + remote_user.send_follow_request_to(user_1) + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=f"@{remote_user.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).one() + assert ( + Mention.query.filter_by( + comment_id=new_comment.id, user_id=remote_user.id + ).first() + is not None + ) + + +class TestGetWorkoutCommentAsUser( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + +class TestGetWorkoutCommentAsFollower( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_comment_when_visibility_allows_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutCommentAsRemoteFollower( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_comment_when_visibility_allows_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.send_follow_request_to(remote_user) + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutCommentAsOwner( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_comment_when_visibility_allows_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutCommentAsUnauthenticatedUser( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + app_with_federation: 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client = app_with_federation.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + +class TestGetWorkoutCommentWithReplies( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_gets_reply( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + reply = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + parent_comment=comment, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"]["replies"] == [ + jsonify_dict(reply.serialize(user_1)) + ] + + +class TestGetWorkoutCommentsAsUser(GetWorkoutCommentsTestCase): + def test_it_does_not_return_comment_when_for_followers_and_remote( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + +class TestGetWorkoutCommentsAsFollower(GetWorkoutCommentsTestCase): + def test_it_does_not_return_comment_when_visibility_does_not_allow_it( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + @pytest.mark.parametrize( + "input_text_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_text_visibility: VisibilityLevel, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_text_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + +class TestGetWorkoutCommentsAsOwner(GetWorkoutCommentsTestCase): + def test_it_returns_comment_when_for_followers_and_remote( + self, + app_with_federation: 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + +class TestGetWorkoutCommentsAsUnauthenticatedUser(GetWorkoutCommentsTestCase): + def test_it_does_not_return_comment_when_for_followers_and_remote( + self, + app_with_federation: 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 = VisibilityLevel.PUBLIC + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client = app_with_federation.test_client() + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + ) + + self.assert_comments_response(response, expected_comments=[]) + + +class TestGetWorkoutComments(GetWorkoutCommentsTestCase): + def test_it_returns_only_comments_user_can_access( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + # remote user 2 + visible_comments = [ + self.create_comment( + remote_user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + ] + + for privacy_levels in [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ]: + # user_1 is not mentioned + self.create_comment( + remote_user_2, + workout_cycling_user_2, + text_visibility=privacy_levels, + with_federation=True, + ) + + for privacy_levels in [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ]: + # user_1 is mentioned + visible_comments.append( + self.create_comment( + remote_user_2, + workout_cycling_user_2, + text=f"@{user_1.username}", + text_visibility=privacy_levels, + with_federation=True, + ) + ) + + # remote user followed by user 1 + for privacy_levels in [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ]: + visible_comments.append( + self.create_comment( + remote_user, + workout_cycling_user_2, + text_visibility=privacy_levels, + with_federation=True, + ) + ) + self.create_comment( + remote_user, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + with_federation=True, + ) + # user 3 + visible_comments.append( + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + ) + for privacy_levels in [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ]: + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=privacy_levels, + with_federation=True, + ) + # user 2 followed by user 1 + for privacy_levels in [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + ]: + visible_comments.append( + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=privacy_levels, + with_federation=True, + ) + ) + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + with_federation=True, + ) + # user 1 + for privacy_levels in [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ]: + visible_comments.append( + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=privacy_levels, + with_federation=True, + ) + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + data = json.loads(response.data.decode()) + assert data["data"]["comments"] == [ + jsonify_dict(comment.serialize(user_1)) + for comment in visible_comments + ] + + +class TestGetWorkoutCommentWithMention( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_user_can_access_comment_when_mentioned( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_1.username} {self.random_string()}", + text_visibility=input_workout_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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["comment"] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutsCommentsWithReplies( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_gets_reply( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + reply = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + parent_comment=comment, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + 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"]["comments"]) == 1 + assert data["data"]["comments"][0]["id"] == comment.short_id + assert data["data"]["comments"][0]["replies"] == [ + jsonify_dict(reply.serialize(user_1)) + ] + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.comments.comments.send_to_remote_inbox") +class TestDeleteWorkoutComment(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + def test_it_returns_404_if_comment_is_not_visible_to_user( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_does_not_call_sent_to_inbox_if_privacy_is_local_followers_only( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + user_1.approves_follow_request_from(user_2) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_user_has_no_remote_followers( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_calls_sent_to_inbox_if_comment_has_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=input_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + note_activity = comment.get_activity(activity_type="Delete") + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + note_activity = comment.get_activity(activity_type="Delete") + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + remote_user_2.send_follow_request_to(user_1) + user_1.approves_follow_request_from(remote_user_2) + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + note_activity = comment.get_activity(activity_type="Delete") + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) + + def test_it_deletes_mentions( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_1.approves_follow_request_from(remote_user) + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + comment_id = comment.id + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert Mention.query.filter_by(comment_id=comment_id).all() == [] + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.comments.comments.send_to_remote_inbox") +class TestPatchWorkoutComment(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + def test_it_does_not_call_sent_to_inbox_when_comment_is_local_with_no_mentions( # noqa + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "input_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_calls_sent_to_inbox_with_update_when_comment_has_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname} foo", + text_visibility=input_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=f"@{remote_user.fullname} bar")), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + update_comment_activity = comment.get_activity(activity_type="Update") + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=update_comment_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "input_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_calls_sent_to_inbox_with_update_when_mention_is_removed( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname} foo", + text_visibility=input_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + update_comment_activity = comment.get_activity(activity_type="Update") + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=update_comment_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "text_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_instance( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + text_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=text_visibility, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + update_comment_activity = comment.get_activity(activity_type="Update") + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=update_comment_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + def test_it_updates_mentions_to_remove_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_1.approves_follow_request_from(remote_user) + remote_user_2.send_follow_request_to(user_1) + user_1.approves_follow_request_from(remote_user_2) + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname} @{remote_user_2.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=f"@{remote_user.fullname}")), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).one() + mentions = Mention.query.filter_by(comment_id=new_comment.id).all() + assert len(mentions) == 1 + assert mentions[0] == ( + Mention.query.filter_by( + comment_id=new_comment.id, user_id=remote_user.id + ).first() + ) + + def test_it_updates_mentions_to_add_mention( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_1.approves_follow_request_from(remote_user) + remote_user_2.send_follow_request_to(user_1) + user_1.approves_follow_request_from(remote_user_2) + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{remote_user.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps( + dict(text=f"@{remote_user.fullname} @{remote_user_2.fullname}") + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).one() + mentions = Mention.query.filter_by(comment_id=new_comment.id).all() + assert len(mentions) == 2 diff --git a/fittrackee/tests/federation/comments/test_comments_likes_api_post.py b/fittrackee/tests/federation/comments/test_comments_likes_api_post.py new file mode 100644 index 000000000..1256a9118 --- /dev/null +++ b/fittrackee/tests/federation/comments/test_comments_likes_api_post.py @@ -0,0 +1,238 @@ +import json +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee import db +from fittrackee.comments.models import CommentLike +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin +from ...mixins import ApiTestCaseMixin, BaseTestMixin + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.comments.comments.send_to_remote_inbox") +class TestCommentLikePost(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + route = "/api/comments/{comment_uuid}/like" + + def test_it_creates_workout_like( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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["comment"]["id"] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is not None + ) + assert comment.likes.all() == [user_1] + + def test_it_does_not_call_sent_to_inbox_if_comment_is_local( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_calls_sent_to_inbox_if_comment_is_remote( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + like_activity = CommentLike.query.one().get_activity() + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=like_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.comments.comments.send_to_remote_inbox") +class TestCommentUndoLikePost(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + route = "/api/comments/{comment_uuid}/like/undo" + + def test_it_removes_comment_like( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + comment = self.create_comment( + remote_user, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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["comment"]["id"] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is None + ) + assert workout_cycling_user_2.likes.all() == [] + + def test_it_does_not_call_sent_to_inbox_if_comment_is_local( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_calls_sent_to_inbox_if_like_is_remote( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + undo_activity = CommentLike.query.one().get_activity(is_undo=True) + + client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=undo_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) diff --git a/fittrackee/tests/federation/comments/test_comments_models.py b/fittrackee/tests/federation/comments/test_comments_models.py new file mode 100644 index 000000000..71bb4c491 --- /dev/null +++ b/fittrackee/tests/federation/comments/test_comments_models.py @@ -0,0 +1,426 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.comments.models import CommentLike, Mention +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.objects.like import LikeObject +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin +from ...utils import RandomActor + + +class TestWorkoutCommentModelSerializeForCommentOwner(CommentMixin): + def test_it_serializes_owner_comment( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + "id": comment.short_id, + "user": user_1.serialize(), + "workout_id": workout_cycling_user_1.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], + "liked": False, + "likes_count": 0, + "mentions": [], + "suspended_at": None, + } + + +class TestWorkoutCommentModelSerializeForRemoteFollower(CommentMixin): + def test_it_raises_error_when_user_does_not_follow_comment_user( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + user_1: User, + ) -> None: + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_1) + + def test_it_raises_error_when_privacy_does_not_allows_it( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + user_1: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=VisibilityLevel.FOLLOWERS, + with_federation=True, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_1) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_serializes_comment_for_follower_when_privacy_allows_it( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = input_visibility + comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=input_visibility, + with_federation=True, + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + "id": comment.short_id, + "user": remote_user.serialize(), + "workout_id": remote_cycling_workout.short_id, + "text": comment.text, + "text_html": comment.text, # no mention + "text_visibility": comment.text_visibility, + "created_at": comment.created_at, + "modification_date": comment.modification_date, + "reply_to": comment.reply_to, + "replies": [], + "liked": False, + "likes_count": 0, + "mentions": [], + } + + +class TestWorkoutCommentModelSerializeForUser(CommentMixin): + def test_it_raises_error_when_comment_is_visible_to_remote_follower( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_1) + + +class TestWorkoutCommentModelSerializeForUnauthenticatedUser(CommentMixin): + def test_it_raises_error_when_comment_is_visible_to_remote_follower( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + remote_cycling_workout, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize() + + +class TestWorkoutCommentModelGetCreateActivity(CommentMixin): + activity_type = "Create" + expected_object_type = "Note" + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_if_visibility_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_cycling_workout: Workout, + input_visibility: VisibilityLevel, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + remote_cycling_workout, + text_visibility=input_visibility, + with_federation=True, + ) + with pytest.raises(InvalidVisibilityException): + comment.get_activity(activity_type=self.activity_type) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_returns_activities_when_visibility_is_valid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_cycling_workout: Workout, + input_visibility: VisibilityLevel, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + remote_cycling_workout, + text_visibility=input_visibility, + with_federation=True, + ) + + note_activity = comment.get_activity(activity_type=self.activity_type) + + assert note_activity["type"] == self.activity_type + assert note_activity["object"]["type"] == self.expected_object_type + assert note_activity["object"]["id"] == comment.ap_id + + +class TestWorkoutCommentModelGetDeleteActivity( + TestWorkoutCommentModelGetCreateActivity +): + activity_type = "Delete" + expected_object_type = "Tombstone" + + +@patch("fittrackee.federation.utils.user.update_remote_user") +class TestWorkoutCommentModelWithMentions(CommentMixin): + def test_it_creates_mentions_when_mentioned_user_exists( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + with_federation=True, + ) + + comment.create_mentions() + + mention = Mention.query.one() + assert mention.comment_id == comment.id + assert mention.user_id == remote_user.id + + def test_it_creates_mentions_when_mentioned_user_does_not_exist( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + random_actor: RandomActor, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{random_actor.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + with_federation=True, + ) + + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + comment.create_mentions() + + remote_user = User.query.filter_by(username=random_actor.name).one() + mention = Mention.query.one() + assert mention.comment_id == comment.id + assert mention.user_id == remote_user.id + + def test_it_returns_mentioned_user( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + mention = f"@{remote_user.fullname}" + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"{mention} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + with_federation=True, + ) + + _, mentioned_users = comment.create_mentions() + + assert mentioned_users == {"local": set(), "remote": {remote_user}} + + +@patch("fittrackee.federation.utils.user.update_remote_user") +class TestWorkoutCommentModelSerializeForMentions(CommentMixin): + def test_it_serializes_comment_with_mentions_as_link( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["text"] == comment.text + assert serialized_comment["text_html"] == comment.handle_mentions()[0] + + +class TestWorkoutCommentLikeActivities(CommentMixin): + def test_it_returns_like_activity( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + like_activity = like.get_activity() + + assert ( + like_activity + == LikeObject( + target_object_ap_id=comment.ap_id, + like_id=like.id, + actor_ap_id=user_1.actor.activitypub_id, + ).get_activity() + ) + + def test_it_returns_undo_like_activity( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + like_activity = like.get_activity(is_undo=True) + + assert ( + like_activity + == LikeObject( + target_object_ap_id=comment.ap_id, + like_id=like.id, + actor_ap_id=user_1.actor.activitypub_id, + is_undo=True, + ).get_activity() + ) diff --git a/fittrackee/tests/federation/comments/test_mentions_models.py b/fittrackee/tests/federation/comments/test_mentions_models.py new file mode 100644 index 000000000..4bdac9641 --- /dev/null +++ b/fittrackee/tests/federation/comments/test_mentions_models.py @@ -0,0 +1,207 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin + + +class TestCommentWithMentionSerializeVisibility(CommentMixin): + def test_public_comment_is_visible_to_all_users( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_2.username} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + + comment.serialize(user_1) # author + comment.serialize(user_2) # mentioned user + comment.serialize(user_3) # user + comment.serialize() # unauthenticated user + + @pytest.mark.parametrize( + "text_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.FOLLOWERS_AND_REMOTE], + ) + @patch("fittrackee.federation.utils.user.update_remote_user") + def test_comment_for_followers_is_visible_to_followers_and_mentioned_users( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + text_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=( + f"@{user_3.username} {remote_user.fullname} " + f"{self.random_string()}" + ), + text_visibility=text_visibility, + with_federation=True, + ) + + assert comment.serialize(user_1) # author + assert comment.serialize(user_2) # follower + assert comment.serialize(user_3) # mentioned user + with pytest.raises(CommentForbiddenException): + assert comment.serialize(user_4) # user + assert comment.serialize() # unauthenticated user + + def test_private_comment_is_only_visible_to_author_and_mentioned_user( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + remote_user: 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 = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.FOLLOWERS, + with_federation=True, + ) + + assert comment.serialize(user_1) # author + assert comment.serialize(user_3) # mentioned user + with pytest.raises(CommentForbiddenException): + assert comment.serialize(user_2) # follower + assert comment.serialize(user_4) # user + assert comment.serialize() # unauthenticated user + + @patch("fittrackee.federation.utils.user.update_remote_user") + def test_private_comment_with_remote_mention_is_only_visible_to_author( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: 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 = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.PRIVATE, + with_federation=True, + ) + + assert comment.serialize(user_1) # author + with pytest.raises(CommentForbiddenException): + assert comment.serialize(user_2) # follower + assert comment.serialize(user_3) # user + assert comment.serialize() # unauthenticated user + + +class TestWorkoutCommentRemoteMentions(CommentMixin): + def test_it_gets_remote_followers_count( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=( + f"@{user_3.username} {self.random_string()} " + f"@{remote_user.fullname}" + ), + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + + assert comment.remote_mentions.count() == 1 + + def test_has_remote_mentions_returns_false_when_no_remote_mentions( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + + assert comment.has_remote_mentions is False + + def test_has_remote_mentions_returns_true_when_remote_mentions( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{remote_user.fullname} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + + assert comment.has_remote_mentions is True diff --git a/fittrackee/tests/federation/comments/test_utils.py b/fittrackee/tests/federation/comments/test_utils.py new file mode 100644 index 000000000..2e0d45150 --- /dev/null +++ b/fittrackee/tests/federation/comments/test_utils.py @@ -0,0 +1,108 @@ +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee.comments.utils import handle_mentions +from fittrackee.federation.exceptions import RemoteActorException +from fittrackee.federation.models import Domain +from fittrackee.tests.utils import RandomActor, random_string +from fittrackee.users.models import User + + +@patch("fittrackee.federation.utils.user.update_remote_user") +class TestGetMentionedUsers: + def test_it_does_not_raise_exception_when_fetching_actor_raises_exception( + self, + update_mock: Mock, + app_with_federation: Flask, + remote_domain: Domain, + ) -> None: + text = f"@foo@{remote_domain.name} {random_string()}" + + with patch( + "fittrackee.federation.utils.user." + "create_remote_user_from_username", + side_effect=RemoteActorException(), + ): + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {"local": set(), "remote": set()} + + def test_it_does_not_return_remote_user_when_mentioned_by_username( + self, update_mock: Mock, app_with_federation: Flask, remote_user: User + ) -> None: + text = f"@{remote_user.username} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {"local": set(), "remote": set()} + + def test_it_returns_remote_user( + self, update_mock: Mock, app_with_federation: Flask, remote_user: User + ) -> None: + text = f"@{remote_user.fullname} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {"local": set(), "remote": {remote_user}} + + def test_it_creates_remote_user( + self, + update_mock: Mock, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + text = f"@{random_actor.fullname} {random_string()}" + + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + _, mentioned_users = handle_mentions(text) + + remote_user = User.query.filter_by(username=random_actor.name).one() + assert mentioned_users == {"local": set(), "remote": {remote_user}} + + def test_it_returns_text_with_link_when_remote_user_found( + self, update_mock: Mock, app_with_federation: Flask, remote_user: User + ) -> None: + text = f"@{remote_user.fullname} {random_string()}" + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text.replace( + f"@{remote_user.fullname}", + f'@{remote_user.fullname}' + f"", + ) + + def test_it_returns_text_when_multiple_users( + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + local_mention = f"@{user_1.username}" + remote_mention = f"@{remote_user.fullname}" + text = f"{local_mention} {remote_mention} {random_string()}" + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text.replace( + local_mention, + f'@{user_1.username}', + ).replace( + remote_mention, + f'@{remote_user.fullname}' + f"", + ) 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/federation/test_federation_activities_activities.py b/fittrackee/tests/federation/federation/test_federation_activities_activities.py new file mode 100644 index 000000000..436eedb57 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_activities_activities.py @@ -0,0 +1,2229 @@ +import re +from datetime import datetime, timedelta, timezone +from typing import Dict, Optional, Union +from unittest.mock import patch + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.comments.models import Comment, CommentLike, Mention +from fittrackee.federation.constants import AP_CTX, DATE_FORMAT, PUBLIC_STREAM +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.exceptions import ( + ActivityException, + ActorNotFoundException, + ObjectNotFoundException, + UnsupportedActivityException, +) +from fittrackee.federation.models import Actor +from fittrackee.federation.objects.like import LikeObject +from fittrackee.federation.objects.workout import ( + convert_duration_string_to_seconds, +) +from fittrackee.federation.tasks.activity import get_activity_instance +from fittrackee.users.exceptions import ( + FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, + NotExistingFollowRequestError, +) +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.constants import WORKOUT_DATE_FORMAT +from fittrackee.workouts.exceptions import SportNotFoundException +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + +from ...comments.mixins import CommentMixin +from ...mixins import RandomMixin +from ...utils import RandomActor, random_int, random_string + +SUPPORTED_ACTIVITIES = [(f"{a.value} activity", a.value) for a in ActivityType] + + +class TestActivityInstantiation(RandomMixin): + @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": self.random_string()}) + + +class FollowRequestActivitiesTestCase: + @staticmethod + def generate_follow_activity( + follower_actor_id: Optional[str] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + 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"{follower_actor_id}#follows/{followed_actor.fullname}", + "type": ActivityType.FOLLOW.value, + "actor": follower_actor_id, + "object": followed_actor.activitypub_id, + } + + @staticmethod + def generate_activity_from_type( + 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() + activity_action = ( + f"{activity_type.value.lower()}e" + if activity_type == ActivityType.UNDO + else activity_type.value.lower() + ) + return { + "@context": AP_CTX, + "id": ( + f"{followed_actor.activitypub_id}#" + f"{activity_action}s/follow/" + f"{follower_actor.fullname}" + ), + "type": activity_type.value, + "actor": ( + follower_actor.activitypub_id + if activity_type == ActivityType.UNDO + else 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_activity_from_type( + 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_activity_from_type( + ActivityType.REJECT, follower_actor, followed_actor + ) + + def generate_undo_activity( + self, + follower_actor: Optional[Union[Actor, RandomActor]] = None, + followed_actor: Optional[Union[Actor, RandomActor]] = None, + ) -> Dict: + return self.generate_activity_from_type( + ActivityType.UNDO, follower_actor, followed_actor + ) + + +class TestFollowActivity(FollowRequestActivitiesTestCase): + def test_it_raises_error_if_followed_actor_does_not_exist( + self, app_with_federation: Flask, user_1: User + ) -> None: + follow_activity = self.generate_follow_activity( + follower_actor_id=user_1.actor.activitypub_id + ) + activity = get_activity_instance({"type": follow_activity["type"]})( + activity_dict=follow_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="object actor not found for FollowActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.rejects_follow_request_from(remote_user) + follow_activity = self.generate_follow_activity( + 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 + ) + + with pytest.raises(FollowRequestAlreadyRejectedError): + activity.process_activity() + + def test_it_creates_follow_request_with_existing_remote_user( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + follow_activity = self.generate_follow_activity( + 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 + ) + + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=remote_user.id, + followed_user_id=user_1.id, + ).one() + assert follow_request is not None + + def test_it_creates_remote_user_and_follow_request( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + follow_activity = self.generate_follow_activity( + follower_actor_id=random_actor.activitypub_id, + followed_actor=user_1.actor, + ) + activity = get_activity_instance({"type": follow_activity["type"]})( + activity_dict=follow_activity + ) + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + followed_user_id=user_1.id, + ).one() + assert follow_request.from_user.fullname == random_actor.fullname + + def test_it_does_not_raise_error_if_pending_follow_request_already_exist( + self, + app_with_federation: Flask, + 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_user.actor.activitypub_id, + followed_actor=remote_user.actor, + ) + 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_user.id, + followed_user_id=user_1.id, + ).one() + 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_user: User + ) -> None: + accept_activity = self.generate_accept_activity( + followed_actor=remote_user.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, user_1: User + ) -> None: + accept_activity = self.generate_accept_activity( + follower_actor=user_1.actor + ) + 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, + user_1: User, + remote_user: User, + ) -> None: + accept_activity = self.generate_accept_activity( + follower_actor=user_1.actor, followed_actor=remote_user.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, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + 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 + ) + + with pytest.raises(FollowRequestAlreadyProcessedError): + activity.process_activity() + + def test_it_accepts_follow_request( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + accept_activity = self.generate_accept_activity( + follower_actor=user_1.actor, followed_actor=remote_user.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=user_1.id, + followed_user_id=remote_user.id, + ).one() + + 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_user: User + ) -> None: + accept_activity = self.generate_reject_activity( + followed_actor=remote_user.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, user_1: User + ) -> None: + accept_activity = self.generate_reject_activity( + follower_actor=user_1.actor + ) + 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, + user_1: User, + remote_user: User, + ) -> None: + accept_activity = self.generate_reject_activity( + follower_actor=user_1.actor, followed_actor=remote_user.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, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + 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 + ) + + with pytest.raises(FollowRequestAlreadyProcessedError): + activity.process_activity() + + def test_it_rejects_follow_request( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + accept_activity = self.generate_reject_activity( + follower_actor=user_1.actor, followed_actor=remote_user.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=user_1.id, + followed_user_id=remote_user.id, + ).one() + + assert follow_request.is_approved is False + assert follow_request.updated_at is not None + + +class TestUndoActivityForFollowRequest(FollowRequestActivitiesTestCase): + def test_it_raises_error_if_follower_actor_does_not_exist( + self, app_with_federation: Flask, remote_user: User + ) -> None: + undo_activity = self.generate_undo_activity( + followed_actor=remote_user.actor + ) + activity = get_activity_instance({"type": undo_activity["type"]})( + activity_dict=undo_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="actor not found for UndoActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_followed_actor_does_not_exist( + self, app_with_federation: Flask, user_1: User + ) -> None: + undo_activity = self.generate_undo_activity( + follower_actor=user_1.actor + ) + activity = get_activity_instance({"type": undo_activity["type"]})( + activity_dict=undo_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="actor not found for UndoActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_follow_request_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + undo_activity = self.generate_undo_activity( + follower_actor=user_1.actor, + followed_actor=remote_user.actor, + ) + activity = get_activity_instance({"type": undo_activity["type"]})( + activity_dict=undo_activity + ) + + with pytest.raises(NotExistingFollowRequestError): + activity.process_activity() + + def test_it_undoes_follow_request_regardless_its_status( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + undo_activity = self.generate_undo_activity( + follower_actor=user_1.actor, followed_actor=remote_user.actor + ) + activity = get_activity_instance({"type": undo_activity["type"]})( + activity_dict=undo_activity + ) + + activity.process_activity() + + follow_request = FollowRequest.query.filter_by( + follower_user_id=user_1.id, + followed_user_id=remote_user.id, + ).first() + + assert follow_request is None + + +class WorkoutActivitiesTestCase(RandomMixin): + def generate_random_object( + self, + remote_actor: Union[RandomActor, Actor], + sport_id: int, + activity_type: str, + visibility: VisibilityLevel, + ) -> Dict: + remote_domain = ( + remote_actor.domain + if isinstance(remote_actor, RandomActor) + else f"https://{remote_actor.domain.name}" + ) + workout_date = datetime.now(timezone.utc).strftime(WORKOUT_DATE_FORMAT) + workout_distance = self.random_int(max_value=999) + workout_short_id = self.random_short_id() + workout_url = f"{remote_domain}/workouts/{workout_short_id}" + published = datetime.now(timezone.utc).strftime(DATE_FORMAT) + activity: Dict = { + "@context": AP_CTX, + "id": ( + f"{remote_actor.activitypub_id}/workouts/" + f"{workout_short_id}/activity" + ), + "type": activity_type, + "actor": remote_actor.activitypub_id, + "published": published, + "to": [], + "cc": [], + "object": { + "id": ( + f"{remote_actor.activitypub_id}/workouts/" + f"{workout_short_id}" + ), + "type": "Workout", + "published": published, + "url": workout_url, + "attributedTo": remote_actor.activitypub_id, + "to": [], + "cc": [], + "ave_speed": workout_distance, + "distance": workout_distance, + "duration": "01:00:00", + "max_speed": workout_distance, + "moving": "01:00:00", + "sport_id": sport_id, + "title": self.random_string(), + "workout_date": workout_date, + }, + } + if visibility == VisibilityLevel.PUBLIC: + activity["to"] = [PUBLIC_STREAM] + activity["cc"] = [remote_actor.followers_url] + activity["object"]["to"] = [PUBLIC_STREAM] + activity["object"]["cc"] = [remote_actor.followers_url] + else: + activity["to"] = [remote_actor.followers_url] + activity["cc"] = [] + activity["object"]["to"] = [remote_actor.followers_url] + activity["object"]["cc"] = [] + return activity + + def generate_workout_create_activity( + self, + remote_actor: Union[RandomActor, Actor], + sport_id: int, + visibility: VisibilityLevel = VisibilityLevel.PUBLIC, + ) -> Dict: + return self.generate_random_object( + remote_actor, sport_id, "Create", visibility + ) + + def generate_workout_update_activity( + self, + remote_actor: Union[RandomActor, Actor, None] = None, + sport_id: Optional[int] = None, + workout: Optional[Workout] = None, + updates: Optional[Dict] = None, + ) -> Dict: + activity: Dict = {} + if not updates: + updates = {} + if workout: + actor: Actor = workout.user.actor + remote_domain = f"https://{workout.user.actor.domain.name}" + published = datetime.now(timezone.utc).strftime(DATE_FORMAT) + activity = { + "@context": AP_CTX, + "id": ( + f"{actor.activitypub_id}/workouts/" + f"{workout.short_id}/activity" + ), + "type": "Update", + "actor": actor.activitypub_id, + "published": published, + "to": [], + "cc": [], + "object": { + "id": workout.ap_id, + "type": "Workout", + "published": published, + "url": f"{remote_domain}/workouts/{workout.short_id}", + "attributedTo": actor.activitypub_id, + "to": [], + "cc": [], + "ave_speed": workout.ave_speed, + "distance": workout.distance, + "duration": str(workout.duration), + "max_speed": workout.max_speed, + "moving": str(workout.moving), + "sport_id": workout.sport_id, + "title": workout.title, + "workout_date": datetime.now(timezone.utc).strftime( + WORKOUT_DATE_FORMAT + ), + }, + } + if "workout_visibility" in updates: + workout_visibility = updates["workout_visibility"] + del updates["workout_visibility"] + else: + workout_visibility = workout.workout_visibility + if workout_visibility == VisibilityLevel.PUBLIC: + activity["to"] = [PUBLIC_STREAM] + activity["cc"] = [actor.followers_url] + activity["object"]["to"] = [PUBLIC_STREAM] + activity["object"]["cc"] = [actor.followers_url] + else: + activity["to"] = [actor.followers_url] + activity["cc"] = [] + activity["object"]["to"] = [actor.followers_url] + activity["object"]["cc"] = [] + elif remote_actor and remote_actor and sport_id: + activity = self.generate_random_object( + remote_actor, sport_id, "Update", VisibilityLevel.PUBLIC + ) + activity["object"] = {**activity["object"], **updates} + return activity + + def generate_workout_delete_activity( + self, + remote_actor: Union[RandomActor, Actor], + remote_workout: Optional[Workout] = None, + ) -> Dict: + remote_domain = ( + remote_actor.domain + if isinstance(remote_actor, RandomActor) + else f"https://{remote_actor.domain.name}" + ) + workout_short_id = ( + remote_workout.ap_id + if remote_workout + else f"{remote_domain}/workouts/{self.random_short_id()}" + ) + return { + "@context": AP_CTX, + "id": f"{workout_short_id}/delete", + "type": "Delete", + "actor": remote_actor.activitypub_id, + "to": [PUBLIC_STREAM], + "cc": [], + "object": { + "id": workout_short_id, + "type": "Tombstone", + }, + } + + +class TestCreateActivityForWorkout(WorkoutActivitiesTestCase): + def test_it_raises_error_if_workout_actor_does_not_exist( + self, + app_with_federation: Flask, + random_actor: RandomActor, + sport_1_cycling: Sport, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=random_actor, sport_id=sport_1_cycling.id + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="actor not found for CreateActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_sport_does_not_exist( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=remote_user.actor, sport_id=self.random_int() + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + + with pytest.raises(SportNotFoundException): + activity.process_activity() + + def test_it_creates_remote_workout_without_gpx_with_public_visibility( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=remote_user.actor, + sport_id=sport_1_cycling.id, + visibility=VisibilityLevel.PUBLIC, + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + + activity.process_activity() + + remote_workout = Workout.query.filter_by( + user_id=remote_user.id, sport_id=sport_1_cycling.id + ).one() + assert remote_workout.ap_id == workout_activity["object"]["id"] + assert remote_workout.remote_url == workout_activity["object"]["url"] + assert ( + remote_workout.distance == workout_activity["object"]["distance"] + ) + assert remote_workout.workout_visibility == VisibilityLevel.PUBLIC + + def test_it_creates_remote_workout_without_gpx_with_followers_visibility( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=remote_user.actor, + sport_id=sport_1_cycling.id, + visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + + activity.process_activity() + + remote_workout = Workout.query.filter_by( + user_id=remote_user.id, sport_id=sport_1_cycling.id + ).one() + assert ( + remote_workout.workout_visibility + == VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + def test_serializer_returns_remote_url( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=remote_user.actor, + sport_id=sport_1_cycling.id, + visibility=VisibilityLevel.PUBLIC, + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + activity.process_activity() + + remote_workout = Workout.query.filter_by( + user_id=remote_user.id, sport_id=sport_1_cycling.id + ).one() + assert ( + remote_workout.serialize(user=remote_user, light=False)[ + "remote_url" + ] + == remote_workout.remote_url + ) + + def test_it_does_not_create_records_for_remote_workout( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + ) -> None: + workout_activity = self.generate_workout_create_activity( + remote_actor=remote_user.actor, sport_id=sport_1_cycling.id + ) + activity = get_activity_instance({"type": workout_activity["type"]})( + activity_dict=workout_activity + ) + activity.process_activity() + + remote_workout = Workout.query.filter_by( + user_id=remote_user.id, sport_id=sport_1_cycling.id + ).one() + assert remote_workout.records == [] + + +class TestDeleteActivityForWorkout(WorkoutActivitiesTestCase): + def test_it_raises_error_if_remote_workout_does_not_exist( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + delete_activity = self.generate_workout_delete_activity( + remote_actor=remote_user.actor + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + + with pytest.raises( + ObjectNotFoundException, + match="object not found for DeleteActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_workout_actor_does_not_exist( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + delete_activity = self.generate_workout_delete_activity( + remote_actor=random_actor + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="actor not found for DeleteActivity", + ): + activity.process_activity() + + def test_it_raises_error_when_activity_actor_is_not_workout_actor( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + remote_user_2: User, + ) -> None: + delete_activity = self.generate_workout_delete_activity( + remote_actor=remote_user_2.actor, + remote_workout=remote_cycling_workout, + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + + with pytest.raises( + ActivityException, + match=re.escape( + "DeleteActivity: activity actor does not match workout actor." + ), + ): + activity.process_activity() + assert ( + Workout.query.filter_by(id=remote_cycling_workout.id).first() + is not None + ) + + def test_it_deletes_remote_workout( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + delete_activity = self.generate_workout_delete_activity( + remote_actor=remote_user.actor, + remote_workout=remote_cycling_workout, + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + workout_id = remote_cycling_workout.id + + activity.process_activity() + + assert Workout.query.filter_by(id=workout_id).first() is None + + +class TestUpdateActivityForWorkout(WorkoutActivitiesTestCase): + def test_it_raises_error_if_remote_workout_does_not_exist( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + ) -> None: + update_activity = self.generate_workout_update_activity( + remote_actor=remote_user.actor, sport_id=sport_1_cycling.id + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + with pytest.raises( + ObjectNotFoundException, + match="workout not found for UpdateActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_workout_actor_does_not_exist( + self, + app_with_federation: Flask, + random_actor: RandomActor, + sport_1_cycling: Sport, + ) -> None: + update_activity = self.generate_workout_update_activity( + remote_actor=random_actor, sport_id=sport_1_cycling.id + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + with pytest.raises( + ActorNotFoundException, + match="actor not found for UpdateActivity", + ): + activity.process_activity() + + def test_it_raises_error_when_activity_actor_is_not_workout_actor( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + remote_user_2: User, + ) -> None: + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + updates={"title": self.random_string()}, + ) + update_activity = { + **update_activity, + "actor": remote_user_2.actor.activitypub_id, + } + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + serialize_workout = remote_cycling_workout.serialize(user=remote_user) + with pytest.raises( + ActivityException, + match=re.escape( + "UpdateActivity: activity actor does not match workout actor." + ), + ): + activity.process_activity() + + workout = Workout.query.filter_by(id=remote_cycling_workout.id).one() + assert workout.serialize(user=remote_user) == serialize_workout + + @pytest.mark.parametrize( + "input_key, input_new_value", + [ + ("ave_speed", 9.0), + ("distance", 12.0), + ("max_speed", 13.0), + ("sport_id", 2), + ("title", "new_title"), + ("workout_visibility", VisibilityLevel.PUBLIC), + ], + ) + def test_it_updates_remote_workout( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + remote_cycling_workout: Workout, + input_key: str, + input_new_value: Union[str, int, float, VisibilityLevel], + ) -> None: + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + updates={input_key: input_new_value}, + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + + activity.process_activity() + + workout = Workout.query.filter_by(id=remote_cycling_workout.id).one() + assert workout.__getattribute__(input_key) == input_new_value + + @pytest.mark.parametrize( + "input_key, input_new_value", + [ + ("duration", "00:50:00"), + ("moving", "01:20:10"), + ], + ) + def test_it_updates_remote_workout_durations( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + remote_cycling_workout: Workout, + input_key: str, + input_new_value: str, + ) -> None: + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + updates={input_key: input_new_value}, + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + + activity.process_activity() + + workout = Workout.query.filter_by(id=remote_cycling_workout.id).one() + assert workout.__getattribute__(input_key) == timedelta( + seconds=convert_duration_string_to_seconds(input_new_value) + ) + + def test_it_updates_remote_workout_date( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + remote_cycling_workout: Workout, + ) -> None: + new_workout_date = "2022-01-04 08:16" + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + updates={"workout_date": new_workout_date}, + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + + activity.process_activity() + + workout = Workout.query.filter_by(id=remote_cycling_workout.id).one() + assert workout.workout_date == datetime.strptime( + new_workout_date, WORKOUT_DATE_FORMAT + ).replace(tzinfo=timezone.utc) + + def test_it_updates_remote_workout_modification_date( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + remote_cycling_workout: Workout, + ) -> None: + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + updates={"title": self.random_string()}, + ) + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + + activity.process_activity() + + workout = Workout.query.filter_by(id=remote_cycling_workout.id).one() + assert workout.modification_date is not None + + def test_it_raises_exception_when_activity_is_invalid( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + remote_cycling_workout: Workout, + ) -> None: + update_activity = self.generate_workout_update_activity( + workout=remote_cycling_workout, + ) + del update_activity["object"]["title"] + activity = get_activity_instance({"type": update_activity["type"]})( + activity_dict=update_activity + ) + with pytest.raises( + ActivityException, + match=re.escape( + "UpdateActivity: invalid Workout activity (KeyError: 'title')." + ), + ): + activity.process_activity() + + +class CommentActivitiesTestCase(RandomMixin): + def generate_random_object( + self, + activity_type: str, + remote_actor: Union[RandomActor, Actor], + workout: Optional[Workout] = None, + visibility: Optional[VisibilityLevel] = VisibilityLevel.PUBLIC, + text: Optional[str] = None, + ) -> Dict: + remote_domain = ( + remote_actor.domain + if isinstance(remote_actor, RandomActor) + else f"https://{remote_actor.domain.name}" + ) + workout_short_id = ( + workout.short_id if workout else self.random_short_id() + ) + if not workout: + workout_api_id = ( + f"{remote_domain}/federation/users/" + f"{remote_actor.name}/" + f"workouts/{workout_short_id}" + ) + else: + workout_api_id = workout.ap_id # type: ignore + + comment_short_id = self.random_short_id() + comment_ap_id = ( + f"{remote_actor.activitypub_id}/workouts/{workout_short_id}" + f"/comments/{comment_short_id}" + ) + comment_url = ( + f"{remote_domain}/workouts/{workout_short_id}" + f"/comment/{comment_short_id}" + ) + published = datetime.now(timezone.utc).strftime(DATE_FORMAT) + activity: Dict = { + "@context": AP_CTX, + "id": f"{comment_ap_id}/activity", + "type": activity_type, + "actor": remote_actor.activitypub_id, + "published": published, + "to": [remote_actor.followers_url], + "cc": [], + "object": { + "id": comment_ap_id, + "type": "Note", + "published": published, + "url": comment_url, + "attributedTo": remote_actor.activitypub_id, + "inReplyTo": workout_api_id, + "content": self.random_string() if not text else text, + "to": [remote_actor.followers_url], + "cc": [], + }, + } + if visibility == VisibilityLevel.PUBLIC: + activity["to"] = [PUBLIC_STREAM] + activity["cc"] = [remote_actor.followers_url] + activity["object"]["to"] = [PUBLIC_STREAM] + activity["object"]["cc"] = [remote_actor.followers_url] + elif visibility == VisibilityLevel.FOLLOWERS_AND_REMOTE: + activity["to"] = [remote_actor.followers_url] + activity["cc"] = [] + activity["object"]["to"] = [remote_actor.followers_url] + activity["object"]["cc"] = [] + elif workout: + activity["to"] = [workout.user.actor.followers_url] + activity["cc"] = [] + activity["object"]["to"] = [workout.user.actor.followers_url] + activity["object"]["cc"] = [] + return activity + + def generate_workout_comment_create_activity( + self, + remote_actor: Union[RandomActor, Actor], + workout: Optional[Workout] = None, + visibility: Optional[VisibilityLevel] = VisibilityLevel.PUBLIC, + text: Optional[str] = None, + ) -> Dict: + return self.generate_random_object( + "Create", remote_actor, workout, visibility, text + ) + + def generate_workout_comment_update_activity( + self, + remote_actor: Union[RandomActor, Actor], + workout: Optional[Workout] = None, + visibility: Optional[VisibilityLevel] = VisibilityLevel.PUBLIC, + text: Optional[str] = None, + ) -> Dict: + return self.generate_random_object( + "Update", remote_actor, workout, visibility, text + ) + + def generate_workout_comment_delete_activity( + self, + remote_actor: Union[RandomActor, Actor], + remote_comment: Optional[Comment] = None, + ) -> Dict: + remote_domain = ( + remote_actor.domain + if isinstance(remote_actor, RandomActor) + else f"https://{remote_actor.domain.name}" + ) + comment_ap_id = ( + remote_comment.ap_id + if remote_comment + else ( + f"{remote_domain}/workouts/{self.random_short_id()}" + f"/comments/{self.random_short_id()}/" + ) + ) + return { + "@context": AP_CTX, + "id": f"{comment_ap_id}/delete", + "type": "Delete", + "actor": remote_actor.activitypub_id, + "to": [PUBLIC_STREAM], + "cc": [], + "object": { + "id": comment_ap_id, + "type": "Tombstone", + }, + } + + +class TestCreateActivityForComment(CommentActivitiesTestCase): + def test_it_creates_remote_comment_when_no_related_workout_found( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + """workout may be not visible""" + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=remote_user.actor + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + remote_comment = Comment.query.filter_by().one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + + def test_it_creates_remote_workout_comment_when_remote_user_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + random_actor: RandomActor, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=random_actor, + workout=workout_cycling_user_1, + visibility=VisibilityLevel.PUBLIC, + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + remote_comment = Comment.query.filter_by().one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + assert ( + User.query.filter_by(username=random_actor.name).first() + is not None + ) + + def test_it_creates_mentioned_users_when_comment_has_mention( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + random_actor: RandomActor, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=random_actor, + workout=workout_cycling_user_1, + text=( + f'@{random_actor.fullname}' + f"" + ), + visibility=VisibilityLevel.PUBLIC, + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + remote_comment = Comment.query.filter_by().one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + new_user = User.query.filter_by(username=random_actor.name).one() + assert new_user is not None + assert ( + Mention.query.filter_by( + comment_id=remote_comment.id, user_id=new_user.id + ).first() + is not None + ) + + @pytest.mark.parametrize( + "input_visibility", + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_creates_remote_workout_comment_with_expected_visibility( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=remote_user.actor, + workout=workout_cycling_user_1, + visibility=input_visibility, + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + remote_comment = Comment.query.filter_by(user_id=remote_user.id).one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + assert remote_comment.remote_url == comment_activity["object"]["url"] + assert remote_comment.text == comment_activity["object"]["content"] + assert remote_comment.text_visibility == input_visibility + assert remote_comment.reply_to is None + + +class TestCreateActivityForCommentReply( + CommentMixin, CommentActivitiesTestCase +): + def test_it_creates_comment_when_parent_comment_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=remote_user.actor, + workout=workout_cycling_user_1, + visibility=VisibilityLevel.PUBLIC, + ) + comment_activity["object"]["inReplyTo"] = ( + comment_activity["object"]["inReplyTo"] + + f"/comments/{self.random_short_id()}" + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + remote_comment = Comment.query.filter_by(user_id=remote_user.id).one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + assert remote_comment.remote_url == comment_activity["object"]["url"] + assert remote_comment.text == comment_activity["object"]["content"] + assert remote_comment.text_visibility == VisibilityLevel.PUBLIC + assert remote_comment.reply_to is None + + @pytest.mark.parametrize( + "input_visibility", + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_creates_remote_workout_comment_with_expected_visibility( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + parent_comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + parent_comment.ap_id = parent_comment.get_ap_id() + comment_activity = self.generate_workout_comment_create_activity( + remote_actor=remote_user.actor, + workout=workout_cycling_user_1, + visibility=input_visibility, + ) + comment_activity["object"]["inReplyTo"] = parent_comment.ap_id + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + remote_comment = Comment.query.filter_by(user_id=remote_user.id).one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + assert remote_comment.remote_url == comment_activity["object"]["url"] + assert remote_comment.text == comment_activity["object"]["content"] + assert remote_comment.text_visibility == input_visibility + assert remote_comment.reply_to == parent_comment.id + + +class TestUpdateActivityForComment(CommentMixin, CommentActivitiesTestCase): + def test_it_creates_comment_when_workout_actor_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + random_actor: RandomActor, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment_activity = self.generate_workout_comment_update_activity( + random_actor + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + remote_comment = Comment.query.filter_by().one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + new_user = User.query.filter_by(username=random_actor.name).one() + assert new_user is not None + + def test_it_creates_comment_when_original_comment_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + random_actor: RandomActor, + ) -> None: + # case of a comment edited to add a mention to a user (not-followers) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment_activity = self.generate_workout_comment_update_activity( + random_actor, text=f"@{user_1.username}" + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + remote_comment = Comment.query.filter_by().one() + assert remote_comment.ap_id == comment_activity["object"]["id"] + assert ( + User.query.filter_by(username=random_actor.name).first() + is not None + ) + assert ( + Mention.query.filter_by( + user_id=user_1.id, comment_id=remote_comment.id + ).first() + is not None + ) + + def test_it_raises_error_when_activity_actor_is_not_comment_actor( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + remote_user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = { + **remote_comment.get_activity("Update"), + "actor": remote_user_2.actor.activitypub_id, + } + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + with pytest.raises( + ActivityException, + match=re.escape( + "UpdateActivity: activity actor does not match Note actor." + ), + ): + activity.process_activity() + + assert remote_comment.user == remote_user + + def test_it_updates_remote_workout_comment_text( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = remote_comment.get_activity("Update") + comment_activity["object"]["content"] = self.random_string() + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + assert remote_comment.text == comment_activity["object"]["content"] + + def test_it_updates_mentioned_users_when_comment_has_mention( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + random_actor: RandomActor, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + with patch("fittrackee.federation.utils.user.update_remote_user"): + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text=f"@{remote_user.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = remote_comment.get_activity("Update") + comment_activity["object"]["content"] = f"@{random_actor.fullname}" + activity = get_activity_instance( + {"type": comment_activity["type"]} + )(activity_dict=comment_activity) + + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + ): + activity.process_activity() + + new_user = User.query.filter_by(username=random_actor.name).one() + assert new_user is not None + mentions = Mention.query.filter_by(comment_id=remote_comment.id).all() + assert len(mentions) == 1 + assert ( + Mention.query.filter_by( + comment_id=remote_comment.id, user_id=new_user.id + ).first() + is not None + ) + + def test_it_updates_remote_workout_modification_date( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = remote_comment.get_activity("Update") + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + assert remote_comment.modification_date is not None + + def test_it_updates_remote_workout_visibility( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = remote_comment.get_activity("Update") + comment_activity["to"] = [remote_user.actor.followers_url] + comment_activity["cc"] = [remote_user.actor.activitypub_id] + comment_activity["object"]["to"] = [remote_user.actor.followers_url] + comment_activity["object"]["cc"] = [remote_user.actor.activitypub_id] + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + + activity.process_activity() + + assert ( + remote_comment.text_visibility + == VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + def test_it_raises_exception_when_activity_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + remote_comment.modification_date = datetime.now(timezone.utc) + comment_activity = remote_comment.get_activity("Update") + del comment_activity["object"]["content"] + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + with pytest.raises( + ActivityException, + match=re.escape( + "UpdateActivity: invalid Note activity (KeyError: 'content')." + ), + ): + activity.process_activity() + + +class TestDeleteActivityForComment(CommentMixin, CommentActivitiesTestCase): + def test_it_raises_error_if_remote_workout_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment_activity = self.generate_workout_comment_delete_activity( + remote_actor=remote_user.actor + ) + activity = get_activity_instance({"type": comment_activity["type"]})( + activity_dict=comment_activity + ) + with pytest.raises( + ObjectNotFoundException, + match="object not found for DeleteActivity", + ): + activity.process_activity() + + def test_it_raises_error_if_workout_actor_does_not_exist( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + delete_activity = self.generate_workout_comment_delete_activity( + remote_actor=random_actor + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + + with pytest.raises( + ActorNotFoundException, + match="actor not found for DeleteActivity", + ): + activity.process_activity() + + def test_it_raises_error_when_activity_actor_is_not_comment_actor( + self, + app_with_federation: Flask, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + remote_user_2: User, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + delete_activity = self.generate_workout_comment_delete_activity( + remote_actor=remote_user_2.actor, + remote_comment=remote_comment, + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + + with pytest.raises( + ActivityException, + match=re.escape( + "DeleteActivity: activity actor does not match workout actor." + ), + ): + activity.process_activity() + assert ( + Comment.query.filter_by(id=remote_comment.id).first() is not None + ) + + def test_it_deletes_remote_workout( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + remote_cycling_workout, + text=f"@{user_1.fullname}", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + delete_activity = self.generate_workout_comment_delete_activity( + remote_actor=remote_user.actor, + remote_comment=remote_comment, + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + comment_id = remote_comment.id + + activity.process_activity() + + assert Comment.query.filter_by(id=comment_id).first() is None + assert Mention.query.filter_by(comment_id=comment_id).all() == [] + + def test_it_deletes_remote_workout_with_reply( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + remote_comment = self.create_comment( + remote_user, + remote_cycling_workout, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + reply = self.create_comment( + user_1, + remote_cycling_workout, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=remote_comment, + with_federation=True, + ) + delete_activity = self.generate_workout_comment_delete_activity( + remote_actor=remote_user.actor, + remote_comment=remote_comment, + ) + activity = get_activity_instance({"type": delete_activity["type"]})( + activity_dict=delete_activity + ) + comment_id = remote_comment.id + + activity.process_activity() + + assert Comment.query.filter_by(id=comment_id).first() is None + assert reply.reply_to is None + + +class TestLikeActivityForWorkout: + def test_it_raises_error_if_workout_does_not_exist( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + like_activity = LikeObject( + target_object_ap_id=random_string(), + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + with pytest.raises( + ObjectNotFoundException, + match="object not found for LikeActivity", + ): + activity.process_activity() + + def test_it_creates_like_when_workout_exists( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + like_activity = LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + assert ( + WorkoutLike.query.filter_by( + user_id=remote_user.id, workout_id=workout_cycling_user_1.id + ).first() + is not None + ) + + def test_it_creates_like_when_workout_exists_and_remote_actor_does_not_exist( # noqa + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + like_activity = LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + actor_ap_id=random_actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ): + activity.process_activity() + + remote_user = User.query.filter_by(username=random_actor.name).one() + assert ( + WorkoutLike.query.filter_by( + user_id=remote_user.id, workout_id=workout_cycling_user_1.id + ).first() + is not None + ) + + +class TestLikeActivityForComment(CommentMixin): + def test_it_creates_like_when_workout_exists( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like_activity = LikeObject( + target_object_ap_id=comment.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + assert ( + CommentLike.query.filter_by( + user_id=remote_user.id, comment_id=comment.id + ).first() + is not None + ) + + def test_it_creates_like_when_comment_exists_and_remote_actor_does_not_exist( # noqa + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like_activity = LikeObject( + target_object_ap_id=comment.ap_id, + actor_ap_id=random_actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ): + activity.process_activity() + + remote_user = User.query.filter_by(username=random_actor.name).one() + assert ( + CommentLike.query.filter_by( + user_id=remote_user.id, comment_id=comment.id + ).first() + is not None + ) + + +class TestUndoLikeActivityForWorkout: + def test_it_raises_error_if_workout_does_not_exist( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + like_activity = LikeObject( + target_object_ap_id=random_string(), + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + is_undo=True, + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + with pytest.raises( + ObjectNotFoundException, + match="object not found for UndoActivity", + ): + activity.process_activity() + + def test_it_deletes_existing_like( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + like = WorkoutLike( + user_id=remote_user.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + like_activity = LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=like.id, + is_undo=True, + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + assert ( + WorkoutLike.query.filter_by( + user_id=remote_user.id, workout_id=workout_cycling_user_1.id + ).first() + is None + ) + + def test_it_does_not_raise_error_if_like_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + like_activity = LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + def test_it_does_not_raise_error_if_actor_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + like_activity = LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + actor_ap_id=random_actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ): + activity.process_activity() + + +class TestUndoLikeActivityForComment(CommentMixin): + def test_it_deletes_existing_like( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like = CommentLike(user_id=remote_user.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + like_activity = LikeObject( + target_object_ap_id=comment.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=like.id, + is_undo=True, + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + assert ( + CommentLike.query.filter_by( + user_id=remote_user.id, comment_id=comment.id + ).first() + is None + ) + + def test_it_does_not_raise_error_if_like_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like_activity = LikeObject( + target_object_ap_id=comment.ap_id, + actor_ap_id=remote_user.actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + activity.process_activity() + + def test_it_does_not_raise_error_if_actor_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + like_activity = LikeObject( + target_object_ap_id=comment.ap_id, + actor_ap_id=random_actor.activitypub_id, + like_id=random_int(), + ).get_activity() + activity = get_activity_instance({"type": like_activity["type"]})( + activity_dict=like_activity + ) + + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ): + activity.process_activity() diff --git a/fittrackee/tests/federation/federation/test_federation_federation.py b/fittrackee/tests/federation/federation/test_federation_federation.py new file mode 100644 index 000000000..268dfc0b9 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_federation.py @@ -0,0 +1,455 @@ +import json +from unittest.mock import patch +from uuid import uuid4 + +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import FollowRequest, User + +from ...mixins import ApiTestCaseMixin + + +class TestFederationUser(ApiTestCaseMixin): + 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}", + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_json_resource_descriptor_as_content_type( + self, app_with_federation: Flask, user_1: User + ) -> None: + client = app_with_federation.test_client() + response = client.get( + 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, user_1: User + ) -> None: + client = app_with_federation.test_client() + response = client.get( + f"/federation/user/{user_1.actor.preferred_username}", + ) + + data = json.loads(response.data.decode()) + assert data == user_1.actor.serialize() + + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask, app_actor: Actor + ) -> None: + client = app.test_client() + response = client.get( + f"/federation/user/{app_actor.preferred_username}", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) + + +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", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) + + 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", + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_ordered_collection_without_follower( + self, app_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, user_1: User + ) -> None: + actor_1 = user_1.actor + client = app_with_federation.test_client() + + response = client.get( + f"/federation/user/{actor_1.preferred_username}/followers?page=un", + ) + + self.assert_500(response) + + def test_it_does_not_return_error_when_page_that_does_not_return_followers( + self, app_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, + 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_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( + 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": [ + 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, + 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_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( + 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": [ + user_3.actor.activitypub_id, + ], + } + + @patch("fittrackee.federation.federation.USERS_PER_PAGE", 1) + def test_it_returns_next_page( + self, + app_with_federation: 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: + 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( + 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": [ + user_2.actor.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", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) + + 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", + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_ordered_collection_without_following( + self, app_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, user_1: User + ) -> None: + actor_1 = user_1.actor + client = app_with_federation.test_client() + + response = client.get( + f"/federation/user/{actor_1.preferred_username}/following?page=un", + ) + + self.assert_500(response) + + def test_it_does_not_return_error_when_page_that_does_not_return_following( + self, app_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + 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, + 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_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( + 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": [ + 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, + 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_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( + 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": [ + user_2.actor.activitypub_id, + ], + } + + @patch("fittrackee.federation.federation.USERS_PER_PAGE", 1) + def test_it_returns_next_page( + self, + app_with_federation: 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: + 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( + 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": [ + 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 new file mode 100644 index 000000000..6244e0a11 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_models.py @@ -0,0 +1,327 @@ +import re +from typing import Optional +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 import VERSION +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.models import Actor, Domain, RemoteActorStats +from fittrackee.federation.utils import get_ap_url +from fittrackee.users.models import User + +from ...utils import random_actor_url + + +class TestGetApUrl: + def test_it_raises_error_if_url_type_is_invalid(self, app: Flask) -> None: + with pytest.raises(Exception, match=re.escape("Invalid 'url_type'.")): + get_ap_url(username=uuid4().hex, url_type="url") + + def test_it_returns_user_url(self, app: Flask) -> None: + username = uuid4().hex + + user_url = get_ap_url(username=username, url_type="user_url") + + assert ( + user_url + == f"https://{app.config['AP_DOMAIN']}/federation/user/{username}" + ) + + @pytest.mark.parametrize( + "input_url_type", ["inbox", "outbox", "following", "followers"] + ) + def test_it_returns_expected_url( + self, app: Flask, input_url_type: str + ) -> None: + username = uuid4().hex + + url = get_ap_url(username=username, url_type=input_url_type) + + assert url == ( + f"https://{app.config['AP_DOMAIN']}/federation/" + f"user/{username}/{input_url_type}" + ) + + def test_it_returns_user_profile_url(self, app: Flask) -> None: + username = uuid4().hex + + user_url = get_ap_url(username=username, url_type="profile_url") + + assert user_url == f"{app.config['UI_URL']}/users/{username}" + + def test_it_returns_shared_inbox(self, app: Flask) -> None: + shared_inbox = get_ap_url( + username=uuid4().hex, url_type="shared_inbox" + ) + + assert shared_inbox == ( + f"https://{app.config['AP_DOMAIN']}/federation/inbox" + ) + + +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"] + ).one() + + 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"] + ).one() + + 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_current_version_when_local( + self, app_with_federation: Flask + ) -> None: + local_domain = Domain.query.filter_by( + name=app_with_federation.config["AP_DOMAIN"] + ).one() + + assert local_domain.software_version is None + assert local_domain.software_current_version == VERSION + + @pytest.mark.parametrize( + "input_software_version,", + [ + (None,), + (uuid4().hex,), + ], + ) + def test_it_returns_current_version_when_remote( + self, + app_with_federation: Flask, + remote_domain: Domain, + input_software_version: Optional[str], + ) -> None: + remote_domain.software_version = input_software_version + + assert remote_domain.software_current_version == input_software_version + + 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"] + ).one() + + 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"] + assert serialized_domain["software_name"] == local_domain.software_name + assert ( + serialized_domain["software_version"] + == local_domain.software_current_version + ) + + +class TestActivityPubLocalPersonActorModel: + def test_it_returns_string_representation( + self, app_with_federation: Flask, user_1: User + ) -> None: + assert "" == str(user_1.actor) + + def test_actor_is_local( + self, app_with_federation: Flask, user_1: User + ) -> None: + assert not user_1.actor.is_remote + + def test_it_returns_fullname( + 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, 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 + assert serialized_actor["id"] == actor_1.activitypub_id + assert serialized_actor["type"] == "Person" + assert ( + 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" + ) + assert serialized_actor["outbox"] == ( + f"https://{ap_url}/federation/user/" + f"{actor_1.preferred_username}/outbox" + ) + assert serialized_actor["followers"] == ( + f"https://{ap_url}/federation/user/" + f"{actor_1.preferred_username}/followers" + ) + assert serialized_actor["following"] == ( + f"https://{ap_url}/federation/user/" + f"{actor_1.preferred_username}/following" + ) + assert serialized_actor["manuallyApprovesFollowers"] is True + assert ( + serialized_actor["publicKey"]["id"] + == f"{actor_1.activitypub_id}#main-key" + ) + assert serialized_actor["publicKey"]["owner"] == actor_1.activitypub_id + assert "publicKeyPem" in serialized_actor["publicKey"] + assert ( + 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( + 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 # type: ignore + ) + ) + hashed_message = SHA256.new("test message".encode()) + # it raises ValueError if signature is invalid + signer.verify(hashed_message, signer.sign(hashed_message)) + + def test_it_does_not_create_remote_actor_stats( + self, app_with_federation: Flask, user_1: User + ) -> None: + assert ( + RemoteActorStats.query.filter_by(actor_id=user_1.actor_id).first() + is None + ) + + +class TestActivityPubRemotePersonActorModel: + def test_actor_is_remote( + self, app_with_federation: Flask, remote_user: User + ) -> None: + assert remote_user.actor.is_remote + + def test_it_returns_fullname( + 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}" + ) + + def test_it_returns_ap_id_if_no_profile_url_provided( + self, + app_with_federation: Flask, + remote_user_without_profile_page: User, + ) -> None: + 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_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( + remote_actor.preferred_username, remote_domain_url + ) + 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["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" + 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"{remote_domain_url}/inbox" + ) + + def test_it_creates_remote_actor_stats( + self, app_with_federation: Flask, remote_user: User + ) -> None: + stats = RemoteActorStats.query.filter_by( + actor_id=remote_user.actor_id + ).one() + + assert stats.items == 0 + assert stats.followers == 0 + assert stats.following == 0 + + +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"] + ).one() + actor = Actor(preferred_username=uuid4().hex, domain_id=domain.id) + assert actor.name is None diff --git a/fittrackee/tests/federation/federation/test_federation_nodeinfo.py b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py new file mode 100644 index 000000000..735fe4b92 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_nodeinfo.py @@ -0,0 +1,151 @@ +import json + +from flask import Flask + +from fittrackee import VERSION +from fittrackee.users.models import User +from fittrackee.workouts.models import Sport, Workout + +from ...mixins import ApiTestCaseMixin + + +class TestWellKnowNodeInfo(ApiTestCaseMixin): + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask + ) -> None: + client = app.test_client() + response = client.get( + "/.well-known/nodeinfo", + content_type="application/json", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) + + def test_it_returns_instance_nodeinfo_url_if_federation_is_enabled( + self, app_with_federation: Flask, user_1: User + ) -> 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, + } + ] + } + + 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", + ) + + self.assert_500(response) + + +class TestNodeInfo(ApiTestCaseMixin): + def test_it_returns_error_if_federation_is_disabled( + self, app: Flask + ) -> None: + client = app.test_client() + response = client.get( + "/nodeinfo/2.0", + content_type="application/json", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) + + def test_it_returns_instance_nodeinfo_if_federation_is_enabled( + self, + app_with_federation: Flask, + user_1: User, + ) -> 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, + } + + def test_it_displays_workouts_count( + self, + app_with_federation: Flask, + user_1: User, + 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_active_actors_are_counted( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + inactive_user: User, + remote_user: User, + ) -> 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"] == 2 + + def test_it_displays_if_registration_is_disabled( + self, + app_with_federation: Flask, + user_1: User, + 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 diff --git a/fittrackee/tests/federation/federation/test_federation_objects_comment.py b/fittrackee/tests/federation/federation/test_federation_objects_comment.py new file mode 100644 index 000000000..24e91c38c --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_objects_comment.py @@ -0,0 +1,655 @@ +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.constants import AP_CTX, DATE_FORMAT +from fittrackee.federation.objects.comment import CommentObject +from fittrackee.federation.objects.exceptions import InvalidObjectException +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin + + +class TestWorkoutCommentCreateObject(CommentMixin): + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_when_visibility_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + # no mentioned users + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=input_visibility, + with_federation=True, + ) + with pytest.raises( + InvalidVisibilityException, + match=f"object visibility is: '{input_visibility.value}'", + ): + CommentObject(comment, "Create") + + def test_it_raises_error_when_comment_has_no_ap_id( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.remote_url = comment.get_remote_url() + with pytest.raises( + InvalidObjectException, + match="Invalid comment, missing 'ap_id' or 'remote_url'", + ): + CommentObject(comment, "Create") + + def test_it_raises_error_when_comment_has_no_remote_url( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.ap_id = comment.get_ap_id() + with pytest.raises( + InvalidObjectException, + match="Invalid comment, missing 'ap_id' or 'remote_url'", + ): + CommentObject(comment, "Create") + + def test_it_raises_error_when_activity_type_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + invalid_activity_type = self.random_string() + with pytest.raises( + ValueError, + match=f"'{invalid_activity_type}' is not a valid ActivityType", + ): + CommentObject(comment, invalid_activity_type) + + def test_it_generates_activity_when_visibility_is_followers_and_remote_only( # noqa + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/create", + "type": "Create", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": [user_2.actor.followers_url], + "cc": [], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": workout_cycling_user_1.ap_id, + "content": comment.text, + "to": [user_2.actor.followers_url], + "cc": [], + }, + } + + def test_it_generates_activity_when_visibility_is_public( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/create", + "type": "Create", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": workout_cycling_user_1.ap_id, + "content": comment.text, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + }, + } + + def test_it_generates_activity_when_comment_has_parent_comment( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + parent_comment = self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + with_federation=True, + ) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/create", + "type": "Create", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": parent_comment.ap_id, + "content": comment.text, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + }, + } + + +@patch("fittrackee.federation.utils.user.update_remote_user") +class TestWorkoutCommentWithMentionsCreateObject(CommentMixin): + def test_it_generates_activity_for_public_comment( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert serialized_comment["to"] == [ + "https://www.w3.org/ns/activitystreams#Public" + ] + assert set(serialized_comment["cc"]) == { + user_2.actor.followers_url, + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["to"] == [ + "https://www.w3.org/ns/activitystreams#Public" + ] + assert set(serialized_comment["object"]["cc"]) == { + user_2.actor.followers_url, + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + + def test_it_generates_activity_with_followers_and_remote_visibility( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert serialized_comment["to"] == [user_2.actor.followers_url] + assert set(serialized_comment["cc"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["to"] == [ + user_2.actor.followers_url + ] + assert set(serialized_comment["object"]["cc"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_generates_activity_with_mentioned_users( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=input_visibility, + with_federation=True, + ) + comment_object = CommentObject(comment, "Create") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert set(serialized_comment["to"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["cc"] == [] + assert set(serialized_comment["object"]["to"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["cc"] == [] + + +class TestWorkoutCommentUpdateObject(CommentMixin): + def test_it_raises_error_when_activity_type_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + invalid_activity_type = self.random_string() + with pytest.raises( + ValueError, + match=f"'{invalid_activity_type}' is not a valid ActivityType", + ): + CommentObject(comment, invalid_activity_type) + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_generates_activity_when_visibility_is_private_or_for_local_followers( # noqa + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + # case of mention removed + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=input_visibility, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/update", + "type": "Update", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": [], # mention removed + "cc": [], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": workout_cycling_user_1.ap_id, + "content": comment.text, + "to": [], + "cc": [], + "updated": comment.modification_date.strftime(DATE_FORMAT), + }, + } + + def test_it_generates_activity_when_visibility_is_followers_and_remote_only( # noqa + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/update", + "type": "Update", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": [user_2.actor.followers_url], + "cc": [], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": workout_cycling_user_1.ap_id, + "content": comment.text, + "to": [user_2.actor.followers_url], + "cc": [], + "updated": comment.modification_date.strftime(DATE_FORMAT), + }, + } + + def test_it_generates_activity_when_visibility_is_public( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + published = comment.created_at.strftime(DATE_FORMAT) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + assert serialized_comment == { + "@context": AP_CTX, + "id": f"{comment.ap_id}/update", + "type": "Update", + "actor": user_2.actor.activitypub_id, + "published": published, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + "object": { + "id": comment.ap_id, + "type": "Note", + "published": published, + "url": comment.remote_url, + "attributedTo": user_2.actor.activitypub_id, + "inReplyTo": workout_cycling_user_1.ap_id, + "content": comment.text, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_2.actor.followers_url], + "updated": comment.modification_date.strftime(DATE_FORMAT), + }, + } + + +@patch("fittrackee.federation.utils.user.update_remote_user") +class TestWorkoutCommentWithMentionsUpdateObject(CommentMixin): + def test_it_generates_activity_for_public_comment( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert serialized_comment["to"] == [ + "https://www.w3.org/ns/activitystreams#Public" + ] + assert set(serialized_comment["cc"]) == { + user_2.actor.followers_url, + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["to"] == [ + "https://www.w3.org/ns/activitystreams#Public" + ] + assert set(serialized_comment["object"]["cc"]) == { + user_2.actor.followers_url, + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + + def test_it_generates_activity_with_followers_and_remote_visibility( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert serialized_comment["to"] == [user_2.actor.followers_url] + assert set(serialized_comment["cc"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["to"] == [ + user_2.actor.followers_url + ] + assert set(serialized_comment["object"]["cc"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_generates_activity_for_private_comment_with_mentioned_users( + self, + update_remote_user_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + user_3: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} @{remote_user.fullname} great!", + text_visibility=VisibilityLevel.PRIVATE, + with_federation=True, + ) + comment.modification_date = datetime.now(timezone.utc) + comment_object = CommentObject(comment, "Update") + + serialized_comment = comment_object.get_activity() + + comment.handle_mentions() + assert set(serialized_comment["to"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["cc"] == [] + assert set(serialized_comment["object"]["to"]) == { + user_3.actor.activitypub_id, + remote_user.actor.activitypub_id, + } + assert serialized_comment["object"]["cc"] == [] diff --git a/fittrackee/tests/federation/federation/test_federation_objects_follow_request.py b/fittrackee/tests/federation/federation/test_federation_objects_follow_request.py new file mode 100644 index 000000000..357da477e --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_objects_follow_request.py @@ -0,0 +1,123 @@ +from flask import Flask + +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.objects.follow_request import FollowRequestObject +from fittrackee.users.models import User + + +class TestFollowRequestObject: + def test_it_generates_follow_activity( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = FollowRequestObject( + from_actor=user_1.actor, + to_actor=user_2.actor, + activity_type=ActivityType.FOLLOW, + ) + + serialized_follow_request = follow_request.get_activity() + + assert serialized_follow_request == { + "@context": AP_CTX, + "id": f"{user_1.actor.activitypub_id}#follows/{user_2.fullname}", + "type": "Follow", + "actor": user_1.actor.activitypub_id, + "object": user_2.actor.activitypub_id, + } + + def test_it_generates_accept_activity( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = FollowRequestObject( + from_actor=user_1.actor, + to_actor=user_2.actor, + activity_type=ActivityType.ACCEPT, + ) + + serialized_follow_request = follow_request.get_activity() + + assert serialized_follow_request == { + "@context": AP_CTX, + "id": ( + f"{user_2.actor.activitypub_id}#accepts/" + f"follow/{user_1.fullname}" + ), + "type": "Accept", + "actor": user_2.actor.activitypub_id, + "object": { + "id": ( + f"{user_1.actor.activitypub_id}#follows/{user_2.fullname}" + ), + "type": "Follow", + "actor": user_1.actor.activitypub_id, + "object": user_2.actor.activitypub_id, + }, + } + + def test_it_generates_reject_activity( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = FollowRequestObject( + from_actor=user_1.actor, + to_actor=user_2.actor, + activity_type=ActivityType.REJECT, + ) + + serialized_follow_request = follow_request.get_activity() + + assert serialized_follow_request == { + "@context": AP_CTX, + "id": ( + f"{user_2.actor.activitypub_id}#rejects/" + f"follow/{user_1.fullname}" + ), + "type": "Reject", + "actor": user_2.actor.activitypub_id, + "object": { + "id": ( + f"{user_1.actor.activitypub_id}#follows/{user_2.fullname}" + ), + "type": "Follow", + "actor": user_1.actor.activitypub_id, + "object": user_2.actor.activitypub_id, + }, + } + + def test_it_generates_undo_activity( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = FollowRequestObject( + from_actor=user_1.actor, + to_actor=user_2.actor, + activity_type=ActivityType.UNDO, + ) + + serialized_follow_request = follow_request.get_activity() + + assert serialized_follow_request == { + "@context": AP_CTX, + "id": f"{user_1.actor.activitypub_id}#undoes/{user_2.fullname}", + "type": "Undo", + "actor": user_1.actor.activitypub_id, + "object": { + "id": ( + f"{user_1.actor.activitypub_id}#follows/{user_2.fullname}" + ), + "type": "Follow", + "actor": user_1.actor.activitypub_id, + "object": user_2.actor.activitypub_id, + }, + } diff --git a/fittrackee/tests/federation/federation/test_federation_objects_like.py b/fittrackee/tests/federation/federation/test_federation_objects_like.py new file mode 100644 index 000000000..70a49261d --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_objects_like.py @@ -0,0 +1,81 @@ +import pytest +from flask import Flask + +from fittrackee.federation.constants import AP_CTX +from fittrackee.federation.objects.exceptions import InvalidObjectException +from fittrackee.federation.objects.like import LikeObject +from fittrackee.users.models import User +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin + + +class TestLikeObject(CommentMixin): + def test_it_raises_error_when_object_has_no_ap_id( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + with pytest.raises( + InvalidObjectException, + match="Invalid object, missing 'ap_id'", + ): + LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + like_id=self.random_int(), + actor_ap_id=user_1.actor.activitypub_id, + ) + + def test_it_generates_like_activity( + self, + app_with_federation: Flask, + user_1: User, + ) -> None: + target_object_ap_id = self.random_string() + like_id = self.random_int() + like_object = LikeObject( + target_object_ap_id=target_object_ap_id, + like_id=like_id, + actor_ap_id=user_1.actor.activitypub_id, + ) + + serialized_like = like_object.get_activity() + + assert serialized_like == { + "@context": AP_CTX, + "id": f"{user_1.actor.activitypub_id}#likes/{like_id}", + "type": "Like", + "actor": user_1.actor.activitypub_id, + "object": target_object_ap_id, + } + + def test_it_generates_undo_activity( + self, + app_with_federation: Flask, + user_1: User, + ) -> None: + target_object_ap_id = self.random_string() + like_id = self.random_int() + like_object = LikeObject( + target_object_ap_id=target_object_ap_id, + like_id=like_id, + actor_ap_id=user_1.actor.activitypub_id, + is_undo=True, + ) + + serialized_like = like_object.get_activity() + + assert serialized_like == { + "@context": AP_CTX, + "id": f"{user_1.actor.activitypub_id}#likes/{like_id}/undo", + "type": "Undo", + "actor": user_1.actor.activitypub_id, + "object": { + "id": f"{user_1.actor.activitypub_id}#likes/{like_id}", + "type": "Like", + "actor": user_1.actor.activitypub_id, + "object": target_object_ap_id, + }, + } diff --git a/fittrackee/tests/federation/federation/test_federation_objects_tombstone.py b/fittrackee/tests/federation/federation/test_federation_objects_tombstone.py new file mode 100644 index 000000000..34619a77d --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_objects_tombstone.py @@ -0,0 +1,242 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.constants import AP_CTX, PUBLIC_STREAM +from fittrackee.federation.objects.tombstone import TombstoneObject +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...comments.mixins import CommentMixin + + +class TestTombstoneObjectForWorkout: + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_when_visibility_is_private( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + with pytest.raises( + InvalidVisibilityException, + match=f"object visibility is: '{input_visibility.value}'", + ): + TombstoneObject(workout_cycling_user_1) + + def test_it_generates_delete_activity_for_workout_with_public_visibility( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + tombstone = TombstoneObject(workout_cycling_user_1) + + delete_activity = tombstone.get_activity() + + assert delete_activity == { + "@context": AP_CTX, + "id": f"{workout_cycling_user_1.ap_id}/delete", + "type": "Delete", + "actor": user_1.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": workout_cycling_user_1.ap_id, + }, + "to": [PUBLIC_STREAM], + "cc": [user_1.actor.followers_url], + } + + def test_it_generates_delete_activity_for_workout_with_follower_visibility( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + tombstone = TombstoneObject(workout_cycling_user_1) + + delete_activity = tombstone.get_activity() + + assert delete_activity == { + "@context": AP_CTX, + "id": f"{workout_cycling_user_1.ap_id}/delete", + "type": "Delete", + "actor": user_1.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": workout_cycling_user_1.ap_id, + }, + "to": [user_1.actor.followers_url], + "cc": [], + } + + +class TestTombstoneObjectForWorkoutComment(CommentMixin): + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_when_comment_has_no_mention( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_visibility, + with_federation=True, + ) + with pytest.raises( + InvalidVisibilityException, + match=f"object visibility is: '{input_visibility.value}'", + ): + TombstoneObject(comment) + + def test_it_generates_delete_activity_for_comment_with_public_visibility( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + with_federation=True, + ) + tombstone = TombstoneObject(comment) + + delete_activity = tombstone.get_activity() + + assert delete_activity == { + "@context": AP_CTX, + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}/delete" + ), + "type": "Delete", + "actor": user_1.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}" + ), + }, + "to": [PUBLIC_STREAM], + "cc": [user_1.actor.followers_url], + } + + def test_it_generates_delete_activity_for_comment_with_follower_visibility( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE, + with_federation=True, + ) + tombstone = TombstoneObject(comment) + + delete_activity = tombstone.get_activity() + + assert delete_activity == { + "@context": AP_CTX, + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}/delete" + ), + "type": "Delete", + "actor": user_1.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}" + ), + }, + "to": [user_1.actor.followers_url], + "cc": [], + } + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + @patch("fittrackee.federation.utils.user.update_remote_user") + def test_it_generates_delete_activity_for_comment_with_private_or_local_followers_visibility_and_mention( # noqa + self, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{remote_user.fullname}", + text_visibility=input_visibility, + with_federation=True, + ) + tombstone = TombstoneObject(comment) + + delete_activity = tombstone.get_activity() + + assert delete_activity == { + "@context": AP_CTX, + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}/delete" + ), + "type": "Delete", + "actor": user_1.actor.activitypub_id, + "object": { + "type": "Tombstone", + "id": ( + f"{user_1.actor.activitypub_id}/workouts/" + f"{workout_cycling_user_1.short_id}/comments/" + f"{comment.short_id}" + ), + }, + "to": [remote_user.actor.activitypub_id], + "cc": [], + } diff --git a/fittrackee/tests/federation/federation/test_federation_objects_workout.py b/fittrackee/tests/federation/federation/test_federation_objects_workout.py new file mode 100644 index 000000000..dd0f374aa --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_objects_workout.py @@ -0,0 +1,414 @@ +from datetime import datetime, timezone +from typing import Any, Dict + +import pytest +from flask import Flask + +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.constants import AP_CTX, DATE_FORMAT +from fittrackee.federation.exceptions import InvalidWorkoutException +from fittrackee.federation.objects.exceptions import InvalidObjectException +from fittrackee.federation.objects.workout import ( + WorkoutObject, + convert_duration_string_to_seconds, + convert_workout_activity, +) +from fittrackee.tests.mixins import RandomMixin +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.constants import WORKOUT_DATE_FORMAT +from fittrackee.workouts.models import Sport, Workout + + +class WorkoutObjectTestCase(RandomMixin): + @staticmethod + def get_updated(workout: Workout, activity_type: str) -> Dict: + return ( + {"updated": workout.modification_date.strftime(DATE_FORMAT)} + if activity_type == "Update" and workout.modification_date + else {} + ) + + +class TestWorkoutObject(WorkoutObjectTestCase): + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_when_visibility_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + with pytest.raises( + InvalidVisibilityException, + match=f"object visibility is: '{input_visibility.value}'", + ): + WorkoutObject(workout_cycling_user_1, "Create") + + def test_it_raises_error_when_activity_type_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + invalid_activity_type = self.random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + with pytest.raises( + ValueError, + match=f"'{invalid_activity_type}' is not a valid ActivityType", + ): + WorkoutObject(workout_cycling_user_1, invalid_activity_type) + + def test_it_raises_error_when_workout_has_no_ap_id( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + with pytest.raises( + InvalidObjectException, + match="Invalid workout, missing 'ap_id' or 'remote_url'", + ): + WorkoutObject(workout_cycling_user_1, "Create") + + def test_it_raises_error_when_workout_has_no_remote_url( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + with pytest.raises( + InvalidObjectException, + match="Invalid workout, missing 'ap_id' or 'remote_url'", + ): + WorkoutObject(workout_cycling_user_1, "Create") + + @pytest.mark.parametrize("input_activity_type", ["Create", "Update"]) + def test_it_generates_activity_when_visibility_is_followers_and_remote_only( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_activity_type: str, + ) -> None: + workout_cycling_user_1.title = self.random_string() + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + workout_cycling_user_1.modification_date = ( + datetime.now(timezone.utc) + if input_activity_type == "Update" + else None + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + published = workout_cycling_user_1.creation_date.strftime(DATE_FORMAT) + updated = self.get_updated(workout_cycling_user_1, input_activity_type) + workout = WorkoutObject(workout_cycling_user_1, input_activity_type) + + serialized_workout = workout.get_activity() + + assert serialized_workout == { + "@context": AP_CTX, + "id": ( + f"{workout_cycling_user_1.ap_id}/{input_activity_type.lower()}" + ), + "type": input_activity_type, + "actor": user_1.actor.activitypub_id, + "published": published, + "to": [user_1.actor.followers_url], + "cc": [], + "object": { + "id": workout_cycling_user_1.ap_id, + "type": "Workout", + "published": published, + "url": workout_cycling_user_1.remote_url, + "attributedTo": user_1.actor.activitypub_id, + "to": [user_1.actor.followers_url], + "cc": [], + "ave_speed": workout_cycling_user_1.ave_speed, + "distance": workout_cycling_user_1.distance, + "duration": str(workout_cycling_user_1.duration), + "max_speed": workout_cycling_user_1.max_speed, + "moving": str(workout_cycling_user_1.moving), + "sport_id": workout_cycling_user_1.sport_id, + "title": workout_cycling_user_1.title, + "workout_date": workout_cycling_user_1.workout_date.strftime( + WORKOUT_DATE_FORMAT + ), + **updated, + }, + } + + @pytest.mark.parametrize("input_activity_type", ["Create", "Update"]) + def test_it_generates_create_activity_when_visibility_is_public( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_activity_type: str, + ) -> None: + workout_cycling_user_1.title = self.random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.modification_date = ( + datetime.now(timezone.utc) + if input_activity_type == "Update" + else None + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + published = workout_cycling_user_1.creation_date.strftime(DATE_FORMAT) + updated = self.get_updated(workout_cycling_user_1, input_activity_type) + workout = WorkoutObject(workout_cycling_user_1, input_activity_type) + + serialized_workout = workout.get_activity() + + assert serialized_workout == { + "@context": AP_CTX, + "id": ( + f"{workout_cycling_user_1.ap_id}/{input_activity_type.lower()}" + ), + "type": input_activity_type, + "actor": user_1.actor.activitypub_id, + "published": published, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_1.actor.followers_url], + "object": { + "id": workout_cycling_user_1.ap_id, + "type": "Workout", + "published": published, + "url": workout_cycling_user_1.remote_url, + "attributedTo": user_1.actor.activitypub_id, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_1.actor.followers_url], + "ave_speed": workout_cycling_user_1.ave_speed, + "distance": workout_cycling_user_1.distance, + "duration": str(workout_cycling_user_1.duration), + "max_speed": workout_cycling_user_1.max_speed, + "moving": str(workout_cycling_user_1.moving), + "sport_id": workout_cycling_user_1.sport_id, + "title": workout_cycling_user_1.title, + "workout_date": workout_cycling_user_1.workout_date.strftime( + WORKOUT_DATE_FORMAT + ), + **updated, + }, + } + + +class TestWorkoutNoteObject(WorkoutObjectTestCase): + @staticmethod + def expected_workout_note(workout: Workout, expected_url: str) -> str: + return f"""

New workout: {workout.title} ({workout.sport.label}) + +Distance: {workout.distance:.2f}km +Duration: {workout.duration}

+""" + + @pytest.mark.parametrize("input_activity_type", ["Create", "Update"]) + def test_it_returns_note_activity_when_visibility_is_followers_only( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_activity_type: str, + ) -> None: + workout_cycling_user_1.title = self.random_string() + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + workout_cycling_user_1.modification_date = ( + datetime.now(timezone.utc) + if input_activity_type == "Update" + else None + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + published = workout_cycling_user_1.creation_date.strftime(DATE_FORMAT) + updated = self.get_updated(workout_cycling_user_1, input_activity_type) + workout = WorkoutObject(workout_cycling_user_1, input_activity_type) + + serialized_workout_note = workout.get_activity(is_note=True) + + assert serialized_workout_note == { + "@context": AP_CTX, + "id": ( + f"{workout_cycling_user_1.ap_id}/" + f"note/{input_activity_type.lower()}" + ), + "type": input_activity_type, + "actor": user_1.actor.activitypub_id, + "published": published, + "to": [user_1.actor.followers_url], + "cc": [], + "object": { + "id": workout_cycling_user_1.ap_id, + "type": "Note", + "published": published, + "url": workout_cycling_user_1.remote_url, + "attributedTo": user_1.actor.activitypub_id, + "content": self.expected_workout_note( + workout_cycling_user_1, + workout_cycling_user_1.remote_url, # type: ignore + ), + "to": [user_1.actor.followers_url], + "cc": [], + **updated, + }, + } + + @pytest.mark.parametrize("input_activity_type", ["Create", "Update"]) + def test_it_returns_note_activity_when_visibility_is_public( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_activity_type: str, + ) -> None: + workout_cycling_user_1.title = self.random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.modification_date = ( + datetime.now(timezone.utc) + if input_activity_type == "Update" + else None + ) + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + published = workout_cycling_user_1.creation_date.strftime(DATE_FORMAT) + updated = self.get_updated(workout_cycling_user_1, input_activity_type) + workout = WorkoutObject(workout_cycling_user_1, input_activity_type) + + serialized_workout_note = workout.get_activity(is_note=True) + + assert serialized_workout_note == { + "@context": AP_CTX, + "id": ( + f"{workout_cycling_user_1.ap_id}/" + f"note/{input_activity_type.lower()}" + ), + "type": input_activity_type, + "actor": user_1.actor.activitypub_id, + "published": published, + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_1.actor.followers_url], + "object": { + "id": workout_cycling_user_1.ap_id, + "type": "Note", + "published": published, + "url": workout_cycling_user_1.remote_url, + "attributedTo": user_1.actor.activitypub_id, + "content": self.expected_workout_note( + workout_cycling_user_1, + workout_cycling_user_1.remote_url, # type: ignore + ), + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [user_1.actor.followers_url], + **updated, + }, + } + + +class TestWorkoutConvertDurationStringToSeconds: + @pytest.mark.parametrize( + "input_duration,expected_seconds", + [ + ("0:00:00", 0), + ("1:00:00", 3600), + ("01:00:00", 3600), + ("00:30:00", 1800), + ("00:00:10", 10), + ("01:20:30", 4830), + ], + ) + def test_it_converts_duration_string_into_seconds( + self, input_duration: str, expected_seconds: int + ) -> None: + assert ( + convert_duration_string_to_seconds(duration_str=input_duration) + == expected_seconds + ) + + @pytest.mark.parametrize( + "input_duration", + ["", "1:00", 3600, None], + ) + def test_it_raises_exception_if_duration_is_invalid( + self, input_duration: Any + ) -> None: + with pytest.raises( + InvalidWorkoutException, + match="Invalid workout data: duration or moving format is invalid", + ): + convert_duration_string_to_seconds(duration_str=input_duration) + + +class TestWorkoutConvertWorkoutActivity(RandomMixin): + def test_it_convert_workout_data_from_activity( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_activity_object = { + "id": self.random_string(), + "type": "Workout", + "published": workout_cycling_user_1.creation_date.strftime( + DATE_FORMAT + ), + "url": self.random_string(), + "attributedTo": user_1.actor.activitypub_id, + "to": [user_1.actor.followers_url], + "cc": [], + "ave_speed": workout_cycling_user_1.ave_speed, + "distance": workout_cycling_user_1.distance, + "duration": str(workout_cycling_user_1.duration), + "max_speed": workout_cycling_user_1.max_speed, + "moving": str(workout_cycling_user_1.moving), + "sport_id": workout_cycling_user_1.sport_id, + "title": workout_cycling_user_1.title, + "workout_date": workout_cycling_user_1.workout_date.strftime( + WORKOUT_DATE_FORMAT + ), + "workout_visibility": workout_cycling_user_1.workout_visibility, + } + + assert convert_workout_activity(workout_activity_object) == { + **workout_activity_object, + "duration": workout_cycling_user_1.duration.seconds, + "moving": workout_cycling_user_1.moving.seconds, # type: ignore + } 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..466a6adf9 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_remote_inbox.py @@ -0,0 +1,132 @@ +from datetime import datetime, timezone +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 time_machine import travel + +from fittrackee.federation.inbox import send_to_inbox +from fittrackee.federation.signature import VALID_SIG_DATE_FORMAT +from fittrackee.users.models import User + +from ...mixins import BaseTestMixin, RandomMixin +from ...utils import generate_response + + +class TestSendToRemoteInbox(BaseTestMixin, RandomMixin): + @patch("fittrackee.federation.inbox.generate_digest") + @patch("fittrackee.federation.inbox.generate_signature_header") + @patch("fittrackee.federation.inbox.requests") + def test_it_calls_generate_signature_header( + self, + requests_mock: Mock, + generate_signature_header_mock: Mock, + generate_digest_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor + now = datetime.now(timezone.utc) + parsed_inbox_url = urlparse(remote_actor.inbox_url) + requests_mock.post.return_value = generate_response(status_code=200) + digest = self.random_string() + generate_digest_mock.return_value = digest + + with travel(now, tick=False): + send_to_inbox( + sender=actor_1, + activity={"foo": "bar"}, + inbox_url=remote_actor.inbox_url, + ) + + generate_signature_header_mock.assert_called_with( + host=parsed_inbox_url.netloc, + path=parsed_inbox_url.path, + date_str=self.get_date_string( + date_format=VALID_SIG_DATE_FORMAT, date=now + ), + actor=actor_1, + digest=digest, + ) + + @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, + generate_signature_header_mock: Mock, + generate_digest_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + actor_1 = user_1.actor + remote_actor = remote_user.actor + activity = {"foo": "bar"} + now = datetime.now(timezone.utc) + parsed_inbox_url = urlparse(remote_actor.inbox_url) + requests_mock.post.return_value = generate_response(status_code=200) + signed_header = self.random_string() + generate_signature_header_mock.return_value = signed_header + digest = self.random_string() + generate_digest_mock.return_value = digest + + with travel(now, tick=False): + send_to_inbox( + sender=actor_1, + activity=activity, + inbox_url=remote_actor.inbox_url, + ) + + requests_mock.post.assert_called_with( + remote_actor.inbox_url, + data=dumps(activity), + headers={ + "Host": parsed_inbox_url.netloc, + "Date": self.get_date_string( + date_format=VALID_SIG_DATE_FORMAT, date=now + ), + "Digest": digest, + "Signature": signed_header, + "Content-Type": "application/ld+json", + }, + timeout=30, + ) + + @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, + generate_signature_header_mock: Mock, + app_with_federation: Flask, + 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( + status_code=status_code, content=content + ) + + send_to_inbox( + sender=actor_1, + activity={"foo": "bar"}, + inbox_url=remote_actor.inbox_url, + ) + + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "ERROR" + assert caplog.records[0].message == ( + f"Error when send to inbox '{remote_actor.inbox_url}', " + f"status code: {status_code}, " + f"content: {content}" + ) diff --git a/fittrackee/tests/federation/federation/test_federation_remote_server.py b/fittrackee/tests/federation/federation/test_federation_remote_server.py new file mode 100644 index 000000000..5559b2820 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_remote_server.py @@ -0,0 +1,93 @@ +from unittest.mock import patch + +import pytest +import requests + +from fittrackee.federation.exceptions import RemoteServerException +from fittrackee.federation.utils.remote_domain import ( + get_remote_server_node_info_data, + get_remote_server_node_info_url, +) + +from ...utils import generate_response, random_domain + + +class TestGetNodeInfoUrl: + def test_it_raises_exception_when_requests_returns_error(self) -> None: + domain_name = random_domain() + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response(status_code=400) + + with pytest.raises( + RemoteServerException, + match=( + "Error when getting node_info url " + f"for server '{domain_name}'" + ), + ): + get_remote_server_node_info_url(domain_name) + + def test_it_raises_exception_when_node_info_url_is_invalid(self) -> None: + domain_name = random_domain() + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, content={} + ) + + with pytest.raises( + RemoteServerException, + match=f"Invalid node_info url for server '{domain_name}'", + ): + get_remote_server_node_info_url(domain_name) + + def test_it_returns_node_info_url(self) -> None: + domain_name = random_domain() + expected_node_info_url = f"https://{domain_name}/nodeinfo/2.0" + node_infos_links = { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": expected_node_info_url, + } + ] + } + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, content=node_infos_links + ) + + node_info_url = get_remote_server_node_info_url(domain_name) + + assert node_info_url == expected_node_info_url + + +class TestGetNodeInfoData: + node_info_url = f"https://{random_domain()}/nodeinfo/2.0" + + def test_it_raises_exception_when_requests_returns_error(self) -> None: + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response(status_code=400) + + with pytest.raises( + RemoteServerException, + match=( + "Error when getting node_info data from " + f"'{self.node_info_url}'" + ), + ): + get_remote_server_node_info_data(self.node_info_url) + + def test_it_returns_node_info_data(self) -> None: + expected_node_info_data = { + "protocols": ["activitypub"], + } + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, content=expected_node_info_data + ) + + node_info_data = get_remote_server_node_info_data( + self.node_info_url + ) + + assert node_info_data == expected_node_info_data diff --git a/fittrackee/tests/federation/federation/test_federation_shared_inbox.py b/fittrackee/tests/federation/federation/test_federation_shared_inbox.py new file mode 100644 index 000000000..fef96c5ef --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_shared_inbox.py @@ -0,0 +1,173 @@ +import json +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 +from fittrackee.federation.models import Actor +from fittrackee.federation.signature import ( + VALID_SIG_DATE_FORMAT, + generate_digest, + generate_signature_header, +) +from fittrackee.users.models import User + +from ...mixins import ApiTestCaseMixin, RandomMixin +from ...utils import generate_response + + +class TestSharedInbox(ApiTestCaseMixin, RandomMixin): + route = "/federation/inbox" + + def post_to_shared_inbox( + self, app_with_federation: Flask, actor: Actor + ) -> Tuple[Dict, TestResponse]: + actor.generate_keys() + date_str = self.get_date_string(date_format=VALID_SIG_DATE_FORMAT) + client = app_with_federation.test_client() + note_activity: Dict = { + "@context": AP_CTX, + "id": self.random_string(), + "type": ActivityType.CREATE.value, + "actor": actor.activitypub_id, + "object": { + "type": "Note", + "content": self.random_string(), + }, + } + digest = generate_digest(note_activity) + + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=actor.serialize(), + ) + requests_mock.path = self.route + response = client.post( + self.route, + content_type="application/json", + headers={ + "Host": actor.domain.name, + "Date": date_str, + "Digest": digest, + "Signature": generate_signature_header( + host=actor.domain.name, + path=self.route, + date_str=date_str, + actor=actor, + digest=digest, + ), + "Content-Type": "application/ld+json", + }, + data=json.dumps(note_activity), + ) + + return note_activity, response + + def test_it_returns_403_when_federation_is_disabled( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps({}), + ) + + self.assert_403( + response, "error, federation is disabled for this instance" + ) + + def test_it_returns_401_if_headers_are_missing( + self, app_with_federation: Flask, user_1: User + ) -> None: + client = app_with_federation.test_client() + note_activity = { + "@context": AP_CTX, + "id": self.random_string(), + "type": ActivityType.CREATE.value, + "actor": self.random_string(), + "object": { + "type": "Note", + "content": self.random_string(), + }, + } + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps(note_activity), + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + 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: + client = app_with_federation.test_client() + note_activity = { + "@context": AP_CTX, + "id": self.random_string(), + "type": ActivityType.CREATE.value, + "actor": self.random_string(), + "object": { + "type": "Note", + "content": self.random_string(), + }, + } + + response = client.post( + self.route, + content_type="application/json", + headers={ + "Host": self.random_string(), + "Date": self.random_string(), + "Signature": self.random_string(), + }, + data=json.dumps(note_activity), + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + 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, + handle_activity: Mock, + app_with_federation: Flask, + remote_user: User, + ) -> None: + _, response = self.post_to_shared_inbox( + app_with_federation, remote_user.actor + ) + + assert response.status_code == 200 + 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, + handle_activity: Mock, + app_with_federation: Flask, + remote_user: User, + ) -> None: + activity_dict, _ = self.post_to_shared_inbox( + app_with_federation, remote_user.actor + ) + + handle_activity.send.assert_called_with(activity=activity_dict) diff --git a/fittrackee/tests/federation/federation/test_federation_signature.py b/fittrackee/tests/federation/federation/test_federation_signature.py new file mode 100644 index 000000000..860eef233 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_signature.py @@ -0,0 +1,651 @@ +import base64 +import json +from datetime import datetime, timedelta, timezone +from typing import Dict, Optional +from unittest.mock import MagicMock, patch + +import pytest +import requests +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_SIG_DATE_FORMAT, + SignatureVerification, + generate_digest, + generate_signature, + generate_signature_header, +) +from fittrackee.users.models import User + +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 TestGenerateDigest: + def test_it_returns_digest_with_default_algorithm(self) -> None: + assert generate_digest(TEST_ACTIVITY).startswith("SHA-256=") + + def test_it_returns_digest_with_given_algorithm(self) -> None: + assert generate_digest(TEST_ACTIVITY, "rsa-sha512").startswith( + "SHA-512=" + ) + + +class TestGenerateSignatureHeader: + def test_it_raises_error_when_actor_has_no_private_key( + self, app_with_federation: Flask, remote_user: User + ) -> None: + with pytest.raises( + InvalidSignatureException, match="Invalid private key for actor" + ): + generate_signature_header( + random_string(), + "/inbox", + "Sat, 01 Feb 2025 11:55:28 GMT", + remote_user.actor, + random_string(), + ) + + +class SignatureVerificationTestCase: + @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, + 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() + 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 + '"' + ) + if date_str is None: + date_str = get_date_string( + date_format=VALID_SIG_DATE_FORMAT, date=date + ) + headers = { + "Host": host if host else random_string(), + "Date": date_str, + "Signature": signature_headers, + "Content-Type": "application/ld+json", + } + if digest: + headers["Digest"] = digest + return headers + + def generate_valid_headers( + self, + host: str, + actor: Actor, + activity: Dict, + date_str: Optional[str] = None, + ) -> Dict: + if date_str is None: + now = datetime.now(timezone.utc) + date_str = now.strftime(VALID_SIG_DATE_FORMAT) + digest = generate_digest(activity) + signed_header = generate_signature_header( + host, "/inbox", date_str, actor, digest + ) + return self.generate_headers( + key_id=actor.activitypub_id, + signature=signed_header, + date_str=date_str, + host=host, + algorithm="rsa-sha256", + 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, # type: ignore + signed_string, + ) + return ( + f'keyId="{actor.activitypub_id}#main-key",' + '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.now(timezone.utc) + date_str = now.strftime(VALID_SIG_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 + ) -> 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 + + @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: + 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 digest",' + 'signature="signature"', + ), + ( + "missing headers", + 'keyId="key_id", signature="signature"', + ), + ( + "missing signature", + 'keyId="key_id",headers="(request-target) host date digest"', + ), + ], + ) + 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": get_date_string(date_format=VALID_SIG_DATE_FORMAT), + "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() + algorithm = "rsa-sha256" + signature_headers = ( + f'keyId="{key_id}",algorithm={algorithm},' + 'headers="(request-target) host date digest",' + f'signature="' + signature + '"' + ) + date_str = get_date_string(date_format=VALID_SIG_DATE_FORMAT) + activity = {"foo": "bar"} + digest = generate_digest(activity) + valid_request_mock = self.get_request_mock( + headers={ + "Host": random_string(), + "Date": date_str, + "Digest": digest, + "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 digest" + assert sig_verification.signature == base64.b64decode(signature) + assert sig_verification.algorithm == algorithm + assert sig_verification.digest == digest + + +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.now(timezone.utc).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.now(timezone.utc) + - 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.now(timezone.utc) + - 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"}, + timeout=30, + ) + + 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 TestSignatureDigestVerification(SignatureVerificationTestCase): + def assert_http_digest_is_invalid( + self, app_with_federation: Flask, input_digest: str + ) -> None: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config["AP_DOMAIN"], + date_str=datetime.now(timezone.utc).strftime( + VALID_SIG_DATE_FORMAT + ), + algorithm="rsa-sha256", + digest=input_digest, + ), + data=TEST_ACTIVITY, + ) + ) + + with pytest.raises( + InvalidSignatureException, match="invalid HTTP digest" + ): + sig_verification.verify_digest() + + def test_verify_raises_error_with_invalid_digest( + self, app_with_federation: Flask, user_1: User + ) -> None: + digest = random_string() + self.assert_http_digest_is_invalid(app_with_federation, digest) + + def test_verify_raises_error_with_mismatched_digest( + self, app_with_federation: Flask, user_1: User + ) -> None: + # different data + digest = generate_digest({"foo": "bar"}) + self.assert_http_digest_is_invalid(app_with_federation, digest) + + def test_verify_raises_error_when_digest_is_generated_with_different_algo( + self, app_with_federation: Flask, user_1: User + ) -> None: + # instead of 'rsa-sha256' + digest = generate_digest(TEST_ACTIVITY, algorithm="rsa-sha512") + self.assert_http_digest_is_invalid(app_with_federation, 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, + user_1: User, + ) -> None: + activity = self.get_activity(actor=user_1.actor) + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_headers( + host=app_with_federation.config["AP_DOMAIN"], + date_str=datetime.now(timezone.utc).strftime( + VALID_SIG_DATE_FORMAT + ), + algorithm=input_algorithm, + digest=generate_digest(activity, input_algorithm), + ), + data=activity, + ) + ) + + sig_verification.verify_digest() + + +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: + sig_verification = SignatureVerification.get_signature( + self.get_request_mock( + self.generate_valid_headers( + host=app_with_federation.config["AP_DOMAIN"], + actor=user_1.actor, + date_str=datetime.now(timezone.utc).strftime( + VALID_SIG_DATE_FORMAT + ), + 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=user_1.actor.serialize(), + ) + + with pytest.raises( + InvalidSignatureException, match="invalid 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: + actor_1 = user_1.actor + 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=self.get_activity(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(), + ) + + with pytest.raises( + InvalidSignatureException, match="invalid date header" + ): + 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: + actor_1 = user_1.actor + 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=activity, + ), + data=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() + + @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: + actor_1 = user_1.actor + 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.now(timezone.utc).strftime( + VALID_SIG_DATE_FORMAT + ), + algorithm=algorithm, + digest=generate_digest(activity), + ), + data=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="unsupported algorithm" + ): + 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: + actor_1 = user_1.actor + 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.now(timezone.utc).strftime( + VALID_SIG_DATE_FORMAT + ), + algorithm="rsa-sha256", + digest=random_string(), + ), + data=self.get_activity(actor=actor_1), + ) + ) + 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() + + @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: + actor_1 = user_1.actor + 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=activity, + ), + data=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, match="verification failed" + ): + 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: + actor_1 = user_1.actor + 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=activity, + ), + 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() + + @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: + actor_1 = user_1.actor + 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() 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..e4954a3f8 --- /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_inbox.py b/fittrackee/tests/federation/federation/test_federation_tasks_inbox.py new file mode 100644 index 000000000..917f70709 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_tasks_inbox.py @@ -0,0 +1,67 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.federation.exceptions import SenderNotFoundException +from fittrackee.federation.tasks.inbox import send_to_remote_inbox +from fittrackee.users.models import FollowRequest, User + +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_user: User, + ) -> None: + with pytest.raises(SenderNotFoundException): + send_to_remote_inbox( + sender_id=0, + activity=follow_request_from_user_1_to_user_2.get_activity(), + recipients=[remote_user.actor.inbox_url], + ) + + @patch("fittrackee.federation.tasks.inbox.send_to_inbox") + def test_it_calls_send_to_inbox( + self, + send_to_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + activity = {"foo": "bar"} + send_to_remote_inbox( + sender_id=user_1.actor.id, + activity=activity, + recipients=[remote_user.actor.inbox_url], + ) + + send_to_inbox_mock.assert_called_with( + sender=user_1.actor, + activity=activity, + inbox_url=remote_user.actor.inbox_url, + ) + + @patch("fittrackee.federation.tasks.inbox.send_to_inbox") + def test_it_calls_send_to_inbox_for_each_recipient( + self, + send_to_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + ) -> None: + nb_recipients = 3 + recipients = [ + f"{random_domain_with_scheme}/{random_string()}/inbox" + for _ in range(nb_recipients) + ] + + send_to_remote_inbox( + sender_id=user_1.actor.id, + activity={}, + recipients=recipients, + ) + + assert send_to_inbox_mock.call_count == nb_recipients diff --git a/fittrackee/tests/federation/federation/test_federation_tasks_remote_server.py b/fittrackee/tests/federation/federation/test_federation_tasks_remote_server.py new file mode 100644 index 000000000..d15bbe5c4 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_tasks_remote_server.py @@ -0,0 +1,39 @@ +from unittest.mock import patch + +from fittrackee.federation.models import Domain +from fittrackee.federation.tasks.remote_server import update_remote_server + +from ...utils import random_string + +MODULE = "fittrackee.federation.tasks.remote_server" + + +class TestUpdateRemoteServer: + def test_it_update_remote_server(self, remote_domain: Domain) -> None: + expected_software_name = random_string() + expected_software_version = random_string() + node_info_data = { + "version": "2.0", + "software": { + "name": expected_software_name, + "version": expected_software_version, + }, + } + with ( + patch( + "fittrackee.federation.tasks.remote_server." + "get_remote_server_node_info_url" + ), + patch( + "fittrackee.federation.tasks.remote_server." + "get_remote_server_node_info_data", + return_value=node_info_data, + ), + ): + update_remote_server(remote_domain.name) + + assert remote_domain.software_name == expected_software_name + assert remote_domain.software_version == expected_software_version + assert ( + remote_domain.software_current_version == expected_software_version + ) diff --git a/fittrackee/tests/federation/federation/test_federation_users_inbox.py b/fittrackee/tests/federation/federation/test_federation_users_inbox.py new file mode 100644 index 000000000..3d53501de --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_users_inbox.py @@ -0,0 +1,202 @@ +import json +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 +from fittrackee.federation.models import Actor +from fittrackee.federation.signature import ( + VALID_SIG_DATE_FORMAT, + generate_digest, + generate_signature_header, +) +from fittrackee.users.models import User + +from ...mixins import ApiTestCaseMixin +from ...utils import generate_response, get_date_string, random_string + + +class TestUserInbox(ApiTestCaseMixin): + @staticmethod + def post_to_user_inbox( + app_with_federation: Flask, remote_actor: Actor, local_actor: Actor + ) -> Tuple[Dict, TestResponse]: + remote_actor.generate_keys() + date_str = get_date_string(date_format=VALID_SIG_DATE_FORMAT) + client = app_with_federation.test_client() + inbox_path = f"/federation/user/{local_actor.preferred_username}/inbox" + follow_activity: Dict = { + "@context": AP_CTX, + "id": random_string(), + "type": ActivityType.FOLLOW.value, + "actor": remote_actor.activitypub_id, + "object": local_actor.activitypub_id, + } + digest = generate_digest(follow_activity) + + with patch.object(requests, "get") as requests_mock: + requests_mock.return_value = generate_response( + status_code=200, + content=remote_actor.serialize(), + ) + requests_mock.path = inbox_path + response = client.post( + inbox_path, + content_type="application/json", + headers={ + "Host": remote_actor.domain.name, + "Date": date_str, + "Digest": digest, + "Signature": generate_signature_header( + host=remote_actor.domain.name, + path=inbox_path, + date_str=date_str, + actor=remote_actor, + digest=digest, + ), + "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, user_1: User + ) -> None: + client = app_with_federation.test_client() + + response = client.post( + f"/federation/user/{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, + user_1: User, + input_description: str, + input_activity: Dict, + ) -> None: + client = app_with_federation.test_client() + + response = client.post( + f"/federation/user/{user_1.actor.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, user_1: User + ) -> 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"/federation/user/{user_1.actor.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"] + + @pytest.mark.disable_autouse_generate_keys + def test_it_returns_401_if_signature_is_invalid( + self, app_with_federation: Flask, user_1: User + ) -> 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"/federation/user/{user_1.actor.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"] + + @pytest.mark.disable_autouse_generate_keys + @patch("fittrackee.federation.inbox.handle_activity") + def test_it_returns_200_if_activity_and_signature_are_valid( + self, + handle_activity: Mock, + app_with_federation: Flask, + remote_user: User, + user_1: User, + ) -> None: + _, response = self.post_to_user_inbox( + app_with_federation, remote_user.actor, user_1.actor + ) + + assert response.status_code == 200 + 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, + handle_activity: Mock, + app_with_federation: Flask, + remote_user: User, + user_1: User, + ) -> None: + activity_dict, _ = self.post_to_user_inbox( + app_with_federation, remote_user.actor, user_1.actor + ) + + handle_activity.send.assert_called_with(activity=activity_dict) 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..52aab4084 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_utils_user.py @@ -0,0 +1,941 @@ +import os +import re +from typing import Dict, Union +from unittest.mock import patch +from urllib.parse import urlparse + +import pytest +from flask import Flask + +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + RemoteActorException, +) +from fittrackee.federation.models import Domain +from fittrackee.federation.utils.user import ( + create_remote_user_from_username, + get_or_create_remote_domain_from_url, + get_user_from_username, + get_username_and_domain, + store_or_delete_user_picture, + update_remote_actor_stats, + update_remote_user, +) +from fittrackee.files import get_absolute_file_path +from fittrackee.users.exceptions import UserNotFoundException +from fittrackee.users.models import User + +from ...utils import RandomActor, generate_response, random_string + + +class TestGetUsernameAndDomain: + @pytest.mark.parametrize( + "input_user_account, expected_username_and_domain", + [ + ("@sam@example.com", ("sam", "example.com")), + ( + "@john.doe@test.example.social", + ("john.doe", "test.example.social"), + ), + ], + ) + def test_it_returns_user_name_and_domain( + self, + input_user_account: str, + expected_username_and_domain: Union[str, None], + ) -> None: + assert ( + get_username_and_domain(input_user_account) + == expected_username_and_domain + ) + + @pytest.mark.parametrize( + "input_description, input_user_account", + [ + ("sam", "sam"), + ("@sam", "@sam"), + ("sam@", "sam@"), + ("example.com", "example.com"), + ("@example.com", "@example.com"), + ], + ) + def test_it_returns_none_if_it_does_not_match( + self, + input_description: str, + input_user_account: str, + ) -> None: + assert get_username_and_domain(input_user_account) == (None, None) + + +class TestGetOrCreateDomainFromActorUrl: + @pytest.mark.parametrize( + "input_desc,input_url", + [ + ("empty string", ""), + ("random string", "invalid_url"), + ], + ) + def test_it_raises_exception_when_url_is_invalid( + self, input_desc: str, input_url: str + ) -> None: + with pytest.raises(RemoteActorException, match="invalid actor url"): + get_or_create_remote_domain_from_url(input_url) + + def test_it_raises_an_error_if_domain_is_local( + self, app_with_federation: Flask, user_1: User + ) -> None: + with pytest.raises( + RemoteActorException, + match="the provided account is not a remote account", + ): + get_or_create_remote_domain_from_url(user_1.actor.activitypub_id) + + def test_it_creates_and_returns_remote_domain( + self, app_with_federation: Flask, random_actor: RandomActor + ) -> None: + domain = get_or_create_remote_domain_from_url( + random_actor.activitypub_id + ) + + assert isinstance(domain, Domain) + assert domain.name == urlparse(random_actor.activitypub_id).netloc + + def test_it_calls_update_remote_server_when_creating_domain( + self, app_with_federation: Flask, random_actor: RandomActor + ) -> None: + with patch( + "fittrackee.federation.utils.user.update_remote_server" + ) as update_remote_server_mock: + domain = get_or_create_remote_domain_from_url( + random_actor.activitypub_id + ) + + update_remote_server_mock.send.assert_called_with( + domain_name=domain.name + ) + + def test_it_returns_existing_remote_domain( + self, app_with_federation: Flask, remote_domain: Domain + ) -> None: + domain = get_or_create_remote_domain_from_url( + f"https://{remote_domain.name}/users/random" + ) + + assert domain == remote_domain + + def test_it_calls_update_remote_server_when_domain_exists( + self, app_with_federation: Flask, remote_domain: Domain + ) -> None: + with patch( + "fittrackee.federation.utils.user.update_remote_server" + ) as update_remote_server_mock: + get_or_create_remote_domain_from_url( + f"https://{remote_domain.name}/users/random" + ) + + update_remote_server_mock.send.assert_called_with( + domain_name=remote_domain.name + ) + + +class TestCreateRemoteUser: + def test_it_returns_error_if_remote_actor_domain_is_local( + self, app_with_federation: Flask + ) -> None: + with pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: " + "the provided account is not a remote account." + ), + ): + create_remote_user_from_username( + random_string(), app_with_federation.config["AP_DOMAIN"] + ) + + def test_it_returns_error_if_remote_webfinger_returns_error( + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + side_effect=ActorNotFoundException(), + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: can not fetch remote actor." + ), + ), + ): + create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + @pytest.mark.parametrize( + "input_description, input_webfinger", + [ + ("empty dict", {}), + ( + "missing links", + { + "subject": f"acct:{random_string()}", + }, + ), + ( + "empty links", + { + "subject": f"acct:{random_string()}", + "links": [], + }, + ), + ( + 'missing "self" link', + { + "subject": f"acct:{random_string()}", + "links": [ + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": ( + "https://example.com/" + "authorize_interaction?uri={uri}" + ), + } + ], + }, + ), + ], + ) + def test_it_returns_error_if_remote_webfinger_does_not_return_links( + self, + app_with_federation: Flask, + remote_domain: Domain, + random_actor: RandomActor, + input_description: str, + input_webfinger: Dict, + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value={}, + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: invalid data fetched " + "from webfinger endpoint." + ), + ), + ): + create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + 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.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + side_effect=ActorNotFoundException(), + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: can not fetch remote actor." + ), + ), + ): + create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + 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: + remote_user_object = random_actor.get_remote_user_object() + del remote_user_object["preferredUsername"] + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_user_object, + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: invalid remote actor object." + ), + ), + ): + create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + 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.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value={ + "preferredUsername": random_actor.preferred_username, + }, + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: invalid remote actor object." + ), + ), + ): + create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + 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}" + random_actor.manually_approves_followers = False + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + assert user.username == random_actor.name + assert ( + user.manually_approves_followers + == random_actor.manually_approves_followers + ) + + def test_it_creates_remote_actor_when_actor_and_domain_does_not_exist( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + assert user.username == random_actor.name + + def test_it_uses_preferred_name_as_username_if_name_is_empty( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + remote_user_object = random_actor.get_remote_user_object() + remote_user_object["name"] = "" + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_user_object, + ), + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + assert user.username == random_actor.preferred_username + + def test_it_calls_store_or_delete_user_picture( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + remote_actor_object = random_actor.get_remote_user_object() + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_actor_object, + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ) as store_or_delete_mock, + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + store_or_delete_mock.assert_called_with(remote_actor_object, user) + + def test_it_calls_update_remote_actor_stats( + self, + app_with_federation: Flask, + random_actor: RandomActor, + ) -> None: + remote_actor_object = random_actor.get_remote_user_object() + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_actor_object, + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ) as update_remote_actor_stats_mock, + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + update_remote_actor_stats_mock.assert_called_with(user.actor) + + def test_it_raises_error_if_remote_actor_exists( + self, app_with_federation: Flask, remote_user: User + ) -> None: + remote_user_object = remote_user.actor.serialize() + updated_name = random_string() + remote_user_object["name"] = updated_name + remote_user_object["manuallyApprovesFollowers"] = False + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value={ + "subject": f"acct:{remote_user.fullname}", + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": remote_user.actor.activitypub_id, + } + ], + }, + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_user_object, + ), + pytest.raises( + RemoteActorException, + match=re.escape("Invalid remote actor: actor already exists."), + ), + ): + create_remote_user_from_username( + remote_user.actor.preferred_username, + remote_user.actor.domain.name, + ) + + def test_it_creates_additional_remote_actor( + self, + app_with_federation: Flask, + remote_user: User, + random_actor: RandomActor, + ) -> None: + """ + check constrains on User model + """ + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + user = create_remote_user_from_username( + random_actor.preferred_username, random_actor.domain + ) + + assert user.username == random_actor.name + + +class TestUpdateRemoteUser: + def test_it_does_not_update_user_if_local( + self, app_with_federation: Flask, user_1: User + ) -> None: + with patch("requests.get") as get_mock: + update_remote_user(user_1.actor) + + get_mock.assert_not_called() + + def test_it_raises_exception_if_can_not_fetch_user( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + side_effect=ActorNotFoundException(), + ), + pytest.raises( + RemoteActorException, + match=re.escape( + "Invalid remote actor: can not fetch remote actor." + ), + ), + ): + update_remote_user(remote_user.actor) + + def test_it_updates_user_username( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + expected_name = random_string()[0:30] + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value={ + "name": expected_name, + "manuallyApprovesFollowers": True, + }, + ): + update_remote_user(remote_user.actor) + + assert remote_user.username == expected_name + + def test_it_updates_manually_approves_followers( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + with patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value={ + "name": random_string()[0:30], + "manuallyApprovesFollowers": False, + }, + ): + update_remote_user(remote_user.actor) + + assert remote_user.manually_approves_followers is False + + def test_it_calls_store_or_delete_user_picture( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + remote_actor_object = { + "name": random_string()[0:30], + "manuallyApprovesFollowers": False, + } + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=remote_actor_object, + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ) as store_or_delete_user_picture_mock, + ): + update_remote_user(remote_user.actor) + + store_or_delete_user_picture_mock.assert_called_with( + remote_actor_object, remote_user + ) + + def test_it_calls_update_remote_actor_stats( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value={ + "name": random_string()[0:30], + "manuallyApprovesFollowers": False, + }, + ), + patch( + "fittrackee.federation.utils.user.store_or_delete_user_picture" + ), + patch( + "fittrackee.federation.utils.user.update_remote_actor_stats" + ) as update_remote_actor_stats_mock, + ): + update_remote_user(remote_user.actor) + + update_remote_actor_stats_mock.assert_called_with(remote_user.actor) + + +class TestGetUserFromUsernameWithoutUpdate: + def test_it_raises_exception_if_no_local_user( + self, app_with_federation: Flask + ) -> None: + with pytest.raises(UserNotFoundException): + get_user_from_username(random_string()) + + def test_it_raises_exception_if_no_remote_user( + self, app_with_federation: Flask, random_actor: RandomActor + ) -> None: + with pytest.raises(UserNotFoundException): + get_user_from_username(random_actor.fullname) + + def test_it_raises_exception_if_no_remote_user_with_provided_domain( + self, app_with_federation: Flask, remote_domain: Domain + ) -> None: + with pytest.raises(UserNotFoundException): + get_user_from_username(f"{random_string()}@{remote_domain.name}") + + def test_it_raises_exception_if_only_remote_user_exists_when_username_provided( # noqa + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + with pytest.raises(UserNotFoundException): + get_user_from_username(remote_user.username) + + def test_it_returns_local_user( + self, app_with_federation: Flask, user_1: User + ) -> None: + assert get_user_from_username(user_1.username) == user_1 + + def test_it_returns_remote_user( + self, app_with_federation: Flask, remote_user: User + ) -> None: + assert get_user_from_username(remote_user.fullname) == remote_user + + +class TestGetUserFromUsernameWithAction: + def test_it_raises_exception_if_no_local_user_and_with_creation( + self, app_with_federation: Flask + ) -> None: + """only remote user can be created""" + with pytest.raises(UserNotFoundException): + get_user_from_username(random_string(), with_action="creation") + + def test_it_raises_exception_if_no_remote_user_and_with_refresh( + self, app_with_federation: Flask, random_actor: RandomActor + ) -> None: + """only remote user can be created""" + with pytest.raises(UserNotFoundException): + get_user_from_username( + f"@{random_actor.fullname}", with_action="refresh" + ) + + def test_it_creates_and_returns_remote_user_if_not_existing_and_with_creation( # noqa + self, app_with_federation: Flask, random_actor: RandomActor + ) -> None: + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + user = get_user_from_username( + random_actor.fullname, with_action="creation" + ) + + assert user.username == random_actor.name + + def test_it_creates_remote_user_when_regsitreation_is_disabled( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + app_with_federation.config["is_registration_enabled"] = False + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + user = get_user_from_username( + random_actor.fullname, with_action="creation" + ) + + assert user.username == random_actor.name + + def test_it_calls_update_remote_user_if_remote_user_exists_and_with_refresh( # noqa + self, app_with_federation: Flask, remote_user: User + ) -> None: + with patch( + "fittrackee.federation.utils.user.update_remote_user" + ) as update_remote_user_mock: + get_user_from_username(remote_user.fullname, with_action="refresh") + + update_remote_user_mock.assert_called_with(remote_user.actor) + + def test_it_does_not_raise_error_if_refresh_fails( + self, app_with_federation: Flask, remote_user: User + ) -> None: + with patch( + "fittrackee.federation.utils.user.update_remote_user", + side_effect=RemoteActorException(), + ): + user = get_user_from_username( + remote_user.fullname, with_action="refresh" + ) + + assert user == remote_user + + +class TestStoreOrDeleteUserPicture: + @pytest.mark.parametrize( + "input_description, input_icon", + [ + ( + "type is not an image", + { + "type": random_string(), + "mediaType": "image/jpeg", + "url": "https://example/file.jpg", + }, + ), + ( + "mediatype is not supported", + { + "type": "Image", + "mediaType": random_string(), + "url": f"https://example/file.{random_string()}", + }, + ), + ], + ) + def test_it_does_not_store_picture_if_icon_is_not_supported( + self, + app_with_federation: Flask, + remote_user: User, + input_description: str, + input_icon: Dict, + ) -> None: + with patch("builtins.open") as open_mock: + store_or_delete_user_picture( + remote_actor_object={ + "icon": input_icon, + }, + user=remote_user, + ) + + assert remote_user.picture is None + open_mock.assert_not_called() + + def test_it_does_not_raise_error_if_it_can_not_fetch_image( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + with ( + patch( + "requests.get", return_value=generate_response(status_code=404) + ), + patch("builtins.open") as open_mock, + ): + store_or_delete_user_picture( + remote_actor_object={ + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://example.com/916cac70b7c694a4.jpg", + }, + }, + user=remote_user, + ) + + assert remote_user.picture is None + open_mock.assert_not_called() + + def test_it_stores_user_picture( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + expected_relative_picture_path = os.path.join( + "pictures", str(remote_user.id), f"{remote_user.username}.jpg" + ) + expected_absolute_picture_path = os.path.join( + app_with_federation.config["UPLOAD_FOLDER"], + expected_relative_picture_path, + ) + with ( + patch( + "requests.get", return_value=generate_response(status_code=200) + ), + patch("builtins.open") as open_mock, + ): + store_or_delete_user_picture( + remote_actor_object={ + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": f"https://example.com/{random_string()}.jpg", + }, + }, + user=remote_user, + ) + + assert remote_user.picture == expected_relative_picture_path + open_mock.assert_called_once_with(expected_absolute_picture_path, "wb") + + def test_it_updates_user_picture( + self, + app_with_federation: Flask, + remote_user: User, + ) -> None: + remote_user.picture = random_string() + expected_relative_picture_path = os.path.join( + "pictures", str(remote_user.id), f"{remote_user.username}.jpg" + ) + expected_absolute_picture_path = os.path.join( + app_with_federation.config["UPLOAD_FOLDER"], + expected_relative_picture_path, + ) + with ( + patch( + "requests.get", return_value=generate_response(status_code=200) + ), + patch("builtins.open") as open_mock, + ): + store_or_delete_user_picture( + remote_actor_object={ + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": f"https://example.com/{random_string()}.jpg", + }, + }, + user=remote_user, + ) + + assert remote_user.picture == expected_relative_picture_path + open_mock.assert_called_once_with(expected_absolute_picture_path, "wb") + + def test_it_deletes_user_image_if_no_image_in_remote_actor_object( + self, app_with_federation: Flask, remote_user: User + ) -> None: + user_picture_path = random_string() + remote_user.picture = user_picture_path + with ( + patch("os.path.isfile", return_value=True), + patch("os.remove") as os_remove_mock, + ): + store_or_delete_user_picture( + remote_actor_object={}, user=remote_user + ) + + assert remote_user.picture is None + os_remove_mock.assert_called_with( + get_absolute_file_path(user_picture_path) + ) + + +class TestUpdateRemoteActorStats: + def test_it_does_not_raise_error_if_actor_url_is_not_reachable( + self, app_with_federation: Flask, remote_user: User + ) -> None: + with patch( + "requests.get", return_value=generate_response(status_code=400) + ): + update_remote_actor_stats(remote_user.actor) + + assert remote_user.actor.stats.items == 0 + + def test_it_does_not_fetch_actor_urls_if_user_is_local( + self, app_with_federation: Flask, user_1: User + ) -> None: + with patch("requests.get") as get_mock: + update_remote_actor_stats(user_1.actor) + + get_mock.assert_not_called() + + def test_it_updates_followers_count( + self, app_with_federation: Flask, remote_user: User + ) -> None: + expected_followers_count = 10 + response_content = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": remote_user.actor.followers_url, + "type": "OrderedCollection", + "totalItems": expected_followers_count, + "first": f"{remote_user.actor.followers_url}?page=1", + } + with patch( + "requests.get", + return_value=generate_response(content=response_content), + ): + update_remote_actor_stats(remote_user.actor) + + assert remote_user.actor.stats.followers == expected_followers_count + + def test_it_updates_following_count( + self, app_with_federation: Flask, remote_user: User + ) -> None: + expected_following_count = 33 + response_content = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": remote_user.actor.following_url, + "type": "OrderedCollection", + "totalItems": expected_following_count, + "first": f"{remote_user.actor.following_url}?page=1", + } + with patch( + "requests.get", + return_value=generate_response(content=response_content), + ): + update_remote_actor_stats(remote_user.actor) + + assert remote_user.actor.stats.following == expected_following_count diff --git a/fittrackee/tests/federation/federation/test_federation_webfinger.py b/fittrackee/tests/federation/federation/test_federation_webfinger.py new file mode 100644 index 000000000..b2b916508 --- /dev/null +++ b/fittrackee/tests/federation/federation/test_federation_webfinger.py @@ -0,0 +1,145 @@ +import json +from uuid import uuid4 + +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import User + +from ...mixins import ApiTestCaseMixin + + +class TestWebfinger(ApiTestCaseMixin): + 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", + ) + + self.assert_400(response, "Missing resource in request args.") + + 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", + ) + + self.assert_400(response, "Missing resource in request args.") + + 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", + ) + + self.assert_400(response, "Invalid resource.") + + 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", + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_404_if_domain_is_not_instance_domain( + self, app_with_federation: Flask, user_1: User + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + "/.well-known/webfinger?resource=acct:" + f"{user_1.actor.preferred_username}@{uuid4().hex}", + content_type="application/json", + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_json_resource_descriptor_as_content_type( + self, app_with_federation: Flask, user_1: User + ) -> None: + client = app_with_federation.test_client() + + response = client.get( + f"/.well-known/webfinger?resource=acct:{user_1.fullname}", + 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_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + client = app_with_federation.test_client() + + response = client.get( + f"/.well-known/webfinger?resource=acct:{actor_1.fullname}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert f"acct:{actor_1.fullname}" in data["subject"] + + def test_it_returns_user_links( + self, app_with_federation: Flask, user_1: User + ) -> None: + actor_1 = user_1.actor + client = app_with_federation.test_client() + + response = client.get( + f"/.well-known/webfinger?resource=acct:{actor_1.fullname}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["links"] == [ + { + "href": ( + f"{app_with_federation.config['UI_URL']}/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 + ) -> None: + client = app.test_client() + + response = client.get( + f"/.well-known/webfinger?resource=acct:{app_actor.fullname}", + content_type="application/json", + ) + + self.assert_403( + response, + "error, federation is disabled for this instance", + ) diff --git a/fittrackee/tests/federation/federation/test_remote_actor.py b/fittrackee/tests/federation/federation/test_remote_actor.py new file mode 100644 index 000000000..72673308d --- /dev/null +++ b/fittrackee/tests/federation/federation/test_remote_actor.py @@ -0,0 +1,31 @@ +from unittest.mock import patch + +import pytest +import requests + +from fittrackee.federation.exceptions import ActorNotFoundException +from fittrackee.federation.utils.remote_actor import get_remote_actor_url + +from ...utils import RandomActor, generate_response, random_actor_url + + +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_actor_url(random_actor_url()) + + def test_it_returns_user_object_if_remote_response_is_successful( + self, random_actor: RandomActor + ) -> None: + 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_actor_url(random_actor.activitypub_id) + + assert remote_user == expected_user 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_auth_api.py b/fittrackee/tests/federation/users/test_auth_api.py new file mode 100644 index 000000000..700340bae --- /dev/null +++ b/fittrackee/tests/federation/users/test_auth_api.py @@ -0,0 +1,143 @@ +import json + +import pytest +from flask import Flask + +from fittrackee.tests.mixins import ApiTestCaseMixin +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel + + +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", + accepted_policy=True, + ) + ), + content_type="application/json", + ) + + user = User.query.filter_by(username=username).one() + 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) + + def test_local_user_can_register_if_remote_user_exists_with_same_username( + self, app_with_federation: Flask, remote_user: User + ) -> None: + client = app_with_federation.test_client() + + response = client.post( + "/api/auth/register", + data=json.dumps( + dict( + username=remote_user.username, + email="test@test.com", + password="12345678", + password_conf="12345678", + accepted_policy=True, + ) + ), + content_type="application/json", + ) + + assert response.status_code == 200 + created_user = User.query.filter( + User.username == remote_user.username, + User.is_remote == False, # noqa + ).one() + assert created_user.id != remote_user.id + + +class TestUserPreferencesUpdate(ApiTestCaseMixin): + @pytest.mark.parametrize( + "input_map_visibility,input_analysis_visibility," + "input_workout_visibility,expected_map_visibility," + "expected_analysis_visibility", + [ + ( + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_it_updates_user_preferences_with_remote_level( + self, + app_with_federation: Flask, + user_1: User, + input_map_visibility: VisibilityLevel, + input_analysis_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + expected_map_visibility: VisibilityLevel, + expected_analysis_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + "/api/auth/profile/edit/preferences", + content_type="application/json", + data=json.dumps( + dict( + timezone="America/New_York", + weekm=True, + language="fr", + imperial_units=True, + display_ascent=True, + date_format="MM/dd/yyyy", + start_elevation_at_zero=False, + use_raw_gpx_speed=True, + manually_approves_followers=False, + hide_profile_in_users_directory=False, + use_dark_mode=True, + map_visibility=input_map_visibility.value, + analysis_visibility=input_analysis_visibility.value, + workouts_visibility=input_workout_visibility.value, + hr_visibility="followers_only", + segments_creation_event="none", + split_workout_charts=False, + missing_elevations_processing="open_elevation", + calories_visibility="followers_only", + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["data"]["map_visibility"] == expected_map_visibility.value + assert ( + data["data"]["analysis_visibility"] + == expected_analysis_visibility.value + ) + assert ( + data["data"]["workouts_visibility"] + == input_workout_visibility.value + ) 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..74539a27a --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_api.py @@ -0,0 +1,376 @@ +import json +from unittest.mock import patch + +import pytest +from flask import Flask + +from fittrackee.federation.models import Actor +from fittrackee.users.models import User + +from ...mixins import ApiTestCaseMixin +from ...utils import RandomActor, generate_response, jsonify_dict + + +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 + + @pytest.mark.parametrize( + "input_desc, input_username", + [ + ("not existing user", "not_existing"), + ("remote user account", "@sam@example.com"), + ], + ) + def test_it_returns_empty_users_list_filtering_on_username( + self, + app_with_federation: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + input_desc: str, + input_username: str, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1_admin.email + ) + + response = client.get( + f"/api/users?q={input_username}", + 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"]) == 0 + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + +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"] + + def test_it_returns_remote_user_when_query_contains_account( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with patch( + "fittrackee.federation.utils.user.update_remote_user", + ): + response = client.get( + f"/api/users/remote?q=@{remote_user.fullname}", + 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( + remote_user.serialize(current_user=user_1) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_calls_update_remote_user_when_remote_user_exists( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with patch( + "fittrackee.federation.utils.user.update_remote_user", + ) as update_remote_user_mock: + client.get( + f"/api/users/remote?q=@{remote_user.fullname}", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + update_remote_user_mock.assert_called_with(remote_user.actor) + + def test_it_does_not_call_update_remote_user_for_local_user( + 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, user_1.email + ) + + with patch( + "fittrackee.federation.utils.user.update_remote_user", + ) as update_remote_user_mock: + client.get( + f"/api/users/remote?q={user_2.username}", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + update_remote_user_mock.assert_not_called() + + def test_it_creates_and_returns_remote_user( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with ( + patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + return_value=random_actor.get_webfinger(), + ), + patch( + "fittrackee.federation.utils.user.get_remote_actor_url", + return_value=random_actor.get_remote_user_object(), + ), + ): + response = client.get( + f"/api/users/remote?q=@{random_actor.fullname}", + 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"] == random_actor.name + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_empty_list_if_remote_user_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with patch( + "fittrackee.federation.utils.user.fetch_account_from_webfinger", + side_effect={}, + ): + response = client.get( + f"/api/users/remote?q=@{random_actor.fullname}", + 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"]) == 0 + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + +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 + + +class TestGetRemoteUser(ApiTestCaseMixin): + def test_it_returns_error_if_remote_user_does_not_exists( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + with patch( + "requests.get", return_value=generate_response(status_code=404) + ): + response = client.get( + f"/api/users/@{random_actor.fullname}", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_returns_remote_user_if_exists( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + with patch( + "fittrackee.federation.utils.user.update_remote_user", + ): + response = client.get( + f"/api/users/@{remote_user.fullname}", + 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 + + def test_it_calls_update_remote_user( + self, app_with_federation: Flask, user_1: User, remote_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + with patch( + "fittrackee.federation.utils.user.update_remote_user", + ) as update_remote_user_mock: + client.get( + f"/api/users/@{remote_user.fullname}", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + update_remote_user_mock.assert_called_with(remote_user.actor) + + +class TestGetUserPicture(ApiTestCaseMixin): + def test_it_returns_error_if_local_user_with_same_username_does_not_exist( + self, app_with_federation: Flask, remote_user: User + ) -> None: + client = app_with_federation.test_client() + + response = client.get(f"/api/users/{remote_user.username}/picture") + + self.assert_404_with_entity(response, "user") + + +class TestUpdateUser(ApiTestCaseMixin): + def test_it_updates_local_user_when_remote_user_exists_with_same_username( + self, + app_with_federation: Flask, + user_1_admin: User, + remote_user: User, + user_2: User, + ) -> None: + remote_user.username = user_2.username + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1_admin.email + ) + + response = client.patch( + f"/api/users/{user_2.username}", + content_type="application/json", + data=json.dumps(dict(admin=True)), + 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 + user = data["data"]["users"][0] + assert user["email"] == user_2.email + assert user["is_remote"] is False + + def test_it_raise_error_when_updating_remote_user( + 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.patch( + f"/api/users/@{remote_user.fullname}", + content_type="application/json", + data=json.dumps(dict(admin=True)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) 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..cbfc6ccee --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_follow_api.py @@ -0,0 +1,476 @@ +import json +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee.federation.models import Domain +from fittrackee.users.models import FollowRequest, User + +from ...mixins import ApiTestCaseMixin, UserInboxTestMixin +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, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_string()}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_raises_error_if_username_matches_only_a_remote_user( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{remote_user.username}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_raises_error_if_target_user_has_already_rejected_request( + self, + app_with_federation: 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( + timezone.utc + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.actor.preferred_username}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_creates_follow_request( + 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, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.actor.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 '{user_2.actor.preferred_username}' " + f"is sent." + ) + + def test_it_creates_follow_request_with_local_user_when_only_username_provided( # noqa + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + remote_user: User, + ) -> None: + remote_user.username = user_2.username + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.actor.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 '{user_2.actor.preferred_username}' " + f"is sent." + ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app_with_federation: 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_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.actor.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 '{user_2.actor.preferred_username}' " + f"is sent." + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + f"/api/users/{user_2.actor.preferred_username}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + +class TestRemoteFollowWithFederation(ApiTestCaseMixin, UserInboxTestMixin): + """Follow user from another instance""" + + def test_it_raise_error_if_remote_actor_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_actor.fullname}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domain( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_actor.fullname}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_creates_follow_request( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + response = client.post( + f"/api/users/{remote_actor.fullname}/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_actor.fullname}' is sent." + ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app_with_federation: Flask, + 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, user_1.email + ) + + response = client.post( + f"/api/users/{remote_actor.fullname}/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_actor.fullname}' is sent." + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_calls_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + client.post( + f"/api/users/{remote_actor.fullname}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + follow_request = FollowRequest.query.filter_by( + follower_user_id=user_1.id, + followed_user_id=remote_actor.user.id, + ).first() + self.assert_send_to_remote_inbox_called_once( + send_to_remote_inbox_mock, + local_actor=user_1.actor, + remote_actor=remote_actor, + base_object=follow_request, + ) + + +class TestUnfollowWithFederation(ApiTestCaseMixin): + """Follow user belonging to the same instance""" + + def test_it_raises_error_if_target_user_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_string()}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_raises_error_if_follow_request_does_not_exist( + 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, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.username}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message(response, "relationship does not exist") + + def test_it_removes_follow_request( + self, + app_with_federation: 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_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{user_2.username}/unfollow", + 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"Undo for a follow request to user '{user_2.username}' is sent." + ) + assert user_1.following.count() == 0 + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: 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_with_federation, user_1.email + ) + + client.post( + f"/api/users/{user_2.username}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + +class TestRemoteUnfollowWithFederation(ApiTestCaseMixin, UserInboxTestMixin): + """Follow user from another instance""" + + def test_it_raise_error_if_remote_actor_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_actor.fullname}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + def test_it_raise_error_if_remote_actor_does_not_exist_for_existing_remote_domain( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_domain: Domain, + random_actor: RandomActor, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{random_actor.fullname}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_entity(response, "user") + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_removes_follow_request( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_actor = remote_user.actor + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + f"/api/users/{remote_actor.fullname}/unfollow", + 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"] == ( + "Undo for a follow request to user " + f"'{remote_actor.fullname}' is sent." + ) + assert user_1.following.count() == 0 + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_calls_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_actor = remote_user.actor + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + follow_request = FollowRequest.query.filter_by( + follower_user_id=user_1.id, + followed_user_id=remote_actor.user.id, + ).first() + + client.post( + f"/api/users/{remote_actor.fullname}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_send_to_remote_inbox_called_once( + send_to_remote_inbox_mock, + local_actor=user_1.actor, + remote_actor=remote_actor, + base_object=follow_request, + activity_args={"undo": True}, + ) 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..a435520f3 --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_follow_request_api.py @@ -0,0 +1,336 @@ +import json +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee.users.models import FollowRequest, User + +from ...mixins import ApiTestCaseMixin, UserInboxTestMixin +from ...users.test_users_follow_request_api import FollowRequestTestCase +from ...utils import random_string + + +class TestGetFollowRequestWithFederation(ApiTestCaseMixin): + def test_it_returns_empty_list_if_no_follow_request( + self, app_with_federation: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + 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( + self, + app_with_federation: 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, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_3_to_user_1.updated_at = datetime.now( + timezone.utc + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + 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"] == "toto" + assert data["data"]["follow_requests"][0]["nb_workouts"] == 0 + + +class TestAcceptLocalFollowRequestWithFederation(FollowRequestTestCase): + def test_it_raises_error_if_target_user_does_not_exist( + self, + app_with_federation: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + 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, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + 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_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.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.now( + timezone.utc + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, "accept" + ) + + def test_it_accepts_follow_request( + self, + app_with_federation: 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_with_federation, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, "accept" + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: 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_with_federation, user_1.email + ) + + client.post( + f"/api/follow-requests/{user_2.username}/accept", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + +class TestAcceptRemoteFollowRequestWithFederation( + FollowRequestTestCase, UserInboxTestMixin +): + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_accepts_follow_request( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, + auth_token, + remote_user.fullname, # type: ignore + "accept", + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_calls_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + 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=user_1.id, + ).first() + self.assert_send_to_remote_inbox_called_once( + send_to_remote_inbox_mock, + local_actor=user_1.actor, + 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, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + 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, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, user_2.username, "reject" + ) + + def test_it_raises_error_if_follow_request_already_accepted( + 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( + timezone.utc + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, "reject" + ) + + def test_it_rejects_follow_request( + self, + app_with_federation: 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_with_federation, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, "reject" + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: 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_with_federation, user_1.email + ) + + client.post( + f"/api/follow-requests/{user_2.username}/reject", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + +class TestRejectRemoteFollowRequestWithFederation( + FollowRequestTestCase, UserInboxTestMixin +): + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_accepts_follow_request( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, + auth_token, + remote_user.fullname, # type: ignore + "reject", + ) + + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_calls_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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, user_1.email + ) + + 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=user_1.id, + ).first() + self.assert_send_to_remote_inbox_called_once( + send_to_remote_inbox_mock, + 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 new file mode 100644 index 000000000..de1dadbdb --- /dev/null +++ b/fittrackee/tests/federation/users/test_users_model.py @@ -0,0 +1,360 @@ +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee.federation.constants import AP_CTX +from fittrackee.tests.utils import generate_follow_request +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 + assert "fullname" not in user_1.serialize() + + 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 + assert ( + remote_user.serialize()["fullname"] == f"@{remote_user.fullname}" + ) + assert ( + remote_user.serialize()["profile_link"] + == remote_user.actor.profile_url + ) + + def test_it_returns_remote_actor_stats_when_user_is_remote( + self, app_with_federation: Flask, remote_user: User + ) -> None: + expected_followers = 10 + expected_following = 23 + remote_user.actor.stats.followers = expected_followers + remote_user.actor.stats.following = expected_following + + serialized_user = remote_user.serialize() + assert serialized_user["followers"] == expected_followers + assert serialized_user["following"] == expected_following + + +class TestFollowRequestModelWithFederation: + def test_follow_request_model( + self, + app_with_federation: 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() + + def test_it_returns_follow_activity_object( + self, + app_with_federation: Flask, + 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 == { + "@context": AP_CTX, + "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, + 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.now( + timezone.utc + ) + 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, + 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.now( + timezone.utc + ) + 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, + }, + } + + +class TestUserFollowingModelWithFederation: + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_local_actor_sends_follow_requests_to_remote_actor( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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() + assert follow_request.is_approved is False + assert follow_request.updated_at is None + send_to_remote_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_remote_inbox") + def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + 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() + assert follow_request.is_approved is True + assert follow_request.updated_at is not None + send_to_remote_inbox_mock.send.assert_called_with( + sender_id=actor_1.id, + activity=follow_request.get_activity(), + recipients=[remote_actor.inbox_url], + ) + + +class TestUserUnfollowModelWithFederation: + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_local_actor_sends_undo_activity_to_remote_actor( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + follow_request_from_user_1_to_remote_user.is_approved = True + follow_request_from_user_1_to_remote_user.updated_at = datetime.now( + timezone.utc + ) + expected_activity = ( + follow_request_from_user_1_to_remote_user.get_activity(undo=True) + ) + + user_1.unfollows(remote_user) + + send_to_remote_inbox_mock.send.assert_called_with( + sender_id=user_1.actor.id, + activity=expected_activity, + recipients=[remote_user.actor.inbox_url], + ) + + +class TestUserGetRecipientsSharedInbox: + def test_it_returns_empty_set_if_not_followers( + self, app_with_federation: Flask, user_1: User + ) -> None: + inboxes = user_1.get_followers_shared_inboxes() + + assert inboxes == { + "fittrackee": [], + "others": [], + } + + def test_it_returns_empty_set_if_only_local_followers( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + + inboxes = user_1.get_followers_shared_inboxes() + + assert inboxes == { + "fittrackee": [], + "others": [], + } + + def test_it_returns_shared_inbox_when_remote_followers_from_fittrackee_instance( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + + inboxes = user_1.get_followers_shared_inboxes() + + assert inboxes == { + "fittrackee": [remote_user.actor.shared_inbox_url], + "others": [], + } + + def test_it_returns_shared_inbox_when_remote_followers_from_not_fittrackee_instance( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + + inboxes = user_1.get_followers_shared_inboxes() + + assert inboxes == { + "fittrackee": [], + "others": [remote_user_2.actor.shared_inbox_url], + } + + def test_it_returns_shared_inbox_from_several_remote_users( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + remote_user_2: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + + inboxes = user_1.get_followers_shared_inboxes() + + assert inboxes == { + "fittrackee": [remote_user.actor.shared_inbox_url], + "others": [remote_user_2.actor.shared_inbox_url], + } + + +class TestUserGetRecipientsSharedInboxAsList: + def test_it_returns_empty_set_if_not_followers( + self, app_with_federation: Flask, user_1: User + ) -> None: + inboxes = user_1.get_followers_shared_inboxes_as_list() + + assert inboxes == [] + + def test_it_returns_empty_set_if_only_local_followers( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + + inboxes = user_1.get_followers_shared_inboxes_as_list() + + assert inboxes == [] + + def test_it_returns_shared_inbox_when_remote_followers_from_fittrackee_instance( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + + inboxes = user_1.get_followers_shared_inboxes_as_list() + + assert inboxes == [remote_user.actor.shared_inbox_url] + + def test_it_returns_shared_inbox_when_remote_followers_from_not_fittrackee_instance( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + + inboxes = user_1.get_followers_shared_inboxes_as_list() + + assert inboxes == [remote_user_2.actor.shared_inbox_url] + + def test_it_returns_shared_inbox_from_several_remote_users( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + remote_user_2: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + + inboxes = user_1.get_followers_shared_inboxes_as_list() + + assert sorted(inboxes) == sorted( + [ + remote_user.actor.shared_inbox_url, + remote_user_2.actor.shared_inbox_url, + ] + ) 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..030074b88 --- /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 ...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/tests/federation/workouts/test_timeline_api.py b/fittrackee/tests/federation/workouts/test_timeline_api.py new file mode 100644 index 000000000..2667d968c --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_timeline_api.py @@ -0,0 +1,216 @@ +import json + +import pytest +from flask import Flask +from werkzeug.test import TestResponse + +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...mixins import ApiTestCaseMixin + + +class TestFederationGetUserTimeline(ApiTestCaseMixin): + @staticmethod + def assert_no_workout_returned(response: TestResponse) -> None: + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert "success" in data["status"] + assert len(data["data"]["workouts"]) == 0 + + @staticmethod + def assert_workout_returned( + response: TestResponse, workout: Workout + ) -> None: + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert "success" in data["status"] + assert len(data["data"]["workouts"]) == 1 + assert data["data"]["workouts"][0]["id"] == workout.short_id + + @pytest.mark.parametrize( + "input_desc,input_workout_visibility", + [ + ("workout visibility: private", VisibilityLevel.PRIVATE), + ( + "workout visibility: followers_only", + VisibilityLevel.FOLLOWERS, + ), + ( + "workout visibility: followers_and_remote_only", + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ("workout visibility: public", VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_authenticated_user_workout( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + "/api/timeline", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_workout_returned(response, workout_cycling_user_1) + + @pytest.mark.parametrize( + "input_desc,input_workout_visibility", + [ + ( + "workout visibility: followers_and_remote_only", + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ("workout visibility: public", VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_followed_user_workout_when_visibility_allows_it( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + "/api/timeline", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_workout_returned(response, remote_cycling_workout) + + @pytest.mark.parametrize( + "input_desc,input_workout_visibility", + [ + ( + "workout visibility: followers_only", + VisibilityLevel.FOLLOWERS, + ), + ("workout visibility: private", VisibilityLevel.PRIVATE), + ], + ) + def test_it_does_return_workout_if_visibility_does_not_allow_it( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.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_with_federation, user_1.email + ) + + response = client.get( + "/api/timeline", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_no_workout_returned(response) + + @pytest.mark.parametrize( + "input_desc,input_map_visibility", + [ + ( + "map visibility: followers_only", + VisibilityLevel.FOLLOWERS, + ), + ("map visibility: private", VisibilityLevel.PRIVATE), + ], + ) + def test_it_does_not_return_followed_user_workout_map_when_privacy_does_not_allow_it( # noqa + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + remote_cycling_workout.map_visibility = input_map_visibility + remote_cycling_workout.map_id = self.random_string() + remote_cycling_workout.map = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + "/api/timeline", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + data = json.loads(response.data.decode()) + assert data["data"]["workouts"][0]["map"] is None + + @pytest.mark.parametrize( + "input_desc,input_visibility", + [ + ( + "workout, analysis and map visibility: " + "followers_and_remote_only", + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ( + "workout, analysis and map visibility: public", + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_it_returns_followed_user_workout_map_when_visibility_allows_it( + self, + input_desc: str, + input_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = input_visibility + remote_cycling_workout.analysis_visibility = input_visibility + remote_cycling_workout.map_visibility = input_visibility + map_id = self.random_string() + remote_cycling_workout.map_id = map_id + remote_cycling_workout.map = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.get( + "/api/timeline", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + data = json.loads(response.data.decode()) + assert data["data"]["workouts"][0]["map"] == map_id diff --git a/fittrackee/tests/federation/workouts/test_workouts_api_delete.py b/fittrackee/tests/federation/workouts/test_workouts_api_delete.py new file mode 100644 index 000000000..cc6a2596d --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_api_delete.py @@ -0,0 +1,119 @@ +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.tests.utils import generate_follow_request +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...mixins import ApiTestCaseMixin + + +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestFederationDeleteWorkout(ApiTestCaseMixin): + @pytest.mark.parametrize( + "workout_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_does_not_call_sent_to_inbox( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/workouts/{workout_cycling_user_1.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/workouts/{workout_cycling_user_1.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + delete_workout_activity, _ = workout_cycling_user_1.get_activities( + activity_type="Delete" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=delete_workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + workout_cycling_user_1.workout_visibility = workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.delete( + f"/api/workouts/{workout_cycling_user_1.short_id}", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + _, delete_note_activity = workout_cycling_user_1.get_activities( + activity_type="Delete" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=delete_note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) diff --git a/fittrackee/tests/federation/workouts/test_workouts_api_patch.py b/fittrackee/tests/federation/workouts/test_workouts_api_patch.py new file mode 100644 index 000000000..c35ab2801 --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_api_patch.py @@ -0,0 +1,195 @@ +import json +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ...mixins import ApiTestCaseMixin + + +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestFederationUpdateWorkout(ApiTestCaseMixin): + @pytest.mark.parametrize( + "workout_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_does_not_call_sent_to_inbox_when_visibility_does_not_change_for_local_workouts( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/workouts/{workout_cycling_user_1.short_id}", + content_type="application/json", + data=json.dumps(dict(sport_id=2, title=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "workout_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = workout_visibility + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/workouts/{workout_cycling_user_1.short_id}", + content_type="application/json", + data=json.dumps( + dict( + sport_id=2, + title=self.random_string(), + workout_visibility=workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + update_workout_activity, _ = workout_cycling_user_1.get_activities( + activity_type="Update" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=update_workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "old_workout_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + @pytest.mark.parametrize( + "new_workout_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_calls_sent_to_inbox_with_delete_activity_when_workout_is_not_visible_anymore_on_remote( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + old_workout_visibility: VisibilityLevel, + new_workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = old_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + delete_workout_activity, _ = workout_cycling_user_1.get_activities( + activity_type="Delete" + ) + + response = client.patch( + f"/api/workouts/{workout_cycling_user_1.short_id}", + content_type="application/json", + data=json.dumps( + dict( + sport_id=2, + title=self.random_string(), + workout_visibility=new_workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=delete_workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "old_workout_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + @pytest.mark.parametrize( + "new_workout_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_calls_sent_to_inbox_with_create_activity_when_workout_is_now_visible_on_remote( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + remote_user: User, + follow_request_from_remote_user_to_user_1: FollowRequest, + old_workout_visibility: VisibilityLevel, + new_workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + workout_cycling_user_1.workout_visibility = old_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.patch( + f"/api/workouts/{workout_cycling_user_1.short_id}", + content_type="application/json", + data=json.dumps( + dict( + sport_id=2, + title=self.random_string(), + workout_visibility=new_workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + create_workout_activity, _ = workout_cycling_user_1.get_activities( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=create_workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) diff --git a/fittrackee/tests/federation/workouts/test_workouts_api_post.py b/fittrackee/tests/federation/workouts/test_workouts_api_post.py new file mode 100644 index 000000000..6e48d5359 --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_api_post.py @@ -0,0 +1,675 @@ +import json +import os +from io import BytesIO +from unittest.mock import Mock, call, patch + +import pytest +from flask import Flask + +from fittrackee.tests.utils import generate_follow_request +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.constants import WORKOUT_DATE_FORMAT +from fittrackee.workouts.models import Sport, Workout + +from ...mixins import ApiTestCaseMixin + + +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestFederationPostWorkoutWithoutGpx(ApiTestCaseMixin): + def test_it_does_not_call_sent_to_inbox_if_user_has_no_remote_followers( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_privacy_is_local_followers_only( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=VisibilityLevel.FOLLOWERS.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_workout_is_private( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date=self.get_date_string( + date_format=WORKOUT_DATE_FORMAT + ), + distance=10, + workout_visibility=VisibilityLevel.PRIVATE.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + @pytest.mark.parametrize( + "workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date=self.get_date_string( + date_format=WORKOUT_DATE_FORMAT + ), + distance=10, + workout_visibility=workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + workout_activity, _ = user_1.workouts[0].get_activities( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + @pytest.mark.parametrize( + "workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + sport_1_cycling: Sport, + workout_visibility: VisibilityLevel, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + _, note_activity = user_1.workouts[0].get_activities( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) + + def test_workout_ap_id_and_remote_url_are_saved_when_activity_is_sent( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts/no_gpx", + content_type="application/json", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=VisibilityLevel.PUBLIC, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + workout = Workout.query.one() + assert workout.ap_id == ( + f"{user_1.actor.activitypub_id}/workouts/{workout.short_id}" + ) + assert workout.remote_url == ( + f"https://{user_1.actor.domain.name}/workouts/{workout.short_id}" + ) + + +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestFederationPostWorkoutWithGpx(ApiTestCaseMixin): + def test_it_does_not_call_sent_to_inbox_if_user_has_no_remote_followers( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + follow_request_from_user_2_to_user_1: FollowRequest, + gpx_file: str, + ) -> None: + user_1.approves_follow_request_from(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.FOLLOWERS.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_privacy_is_local_followers_only( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + gpx_file: str, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.FOLLOWERS.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_does_not_call_sent_to_inbox_if_workout_is_private( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + gpx_file: str, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + gpx_file: str, + ) -> None: + user_1.approves_follow_request_from(remote_user) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + workout_activity, _ = user_1.workouts[0].get_activities( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + _, note_activity = user_1.workouts[0].get_activities( + activity_type="Create" + ) + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) + + def test_workout_ap_id_and_remote_url_are_saved_when_activity_is_sent( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + workout = Workout.query.one() + assert workout.ap_id == ( + f"{user_1.actor.activitypub_id}/workouts/{workout.short_id}" + ) + assert workout.remote_url == ( + f"https://{user_1.actor.domain.name}/workouts/{workout.short_id}" + ) + + +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestFederationPostWorkoutWithZipArchive(ApiTestCaseMixin): + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_fittrackee_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + follow_request_from_remote_user_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(remote_user) + # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + file_path = os.path.join( + app_with_federation.root_path, "tests/files/gpx_test.zip" + ) + with open(file_path, "rb") as zip_file: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(zip_file, "gpx_test.zip"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + assert send_to_remote_inbox_mock.send.call_count == 3 + calls = [] + for workout in user_1.workouts: + workout_activity, _ = workout.get_activities( + activity_type="Create" + ) + calls.append( + call( + sender_id=user_1.actor.id, + activity=workout_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + ) + send_to_remote_inbox_mock.send.assert_has_calls( + calls, any_order=True + ) + + def test_it_calls_sent_to_inbox_if_user_has_follower_from_remote_other_instance( # noqa + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + file_path = os.path.join( + app_with_federation.root_path, "tests/files/gpx_test.zip" + ) + with open(file_path, "rb") as zip_file: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + "/api/workouts", + data=dict( + file=(zip_file, "gpx_test.zip"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + assert send_to_remote_inbox_mock.send.call_count == 3 + calls = [] + for workout in user_1.workouts: + _, note_activity = workout.get_activities( + activity_type="Create" + ) + calls.append( + call( + sender_id=user_1.actor.id, + activity=note_activity, + recipients=[remote_user_2.actor.shared_inbox_url], + ) + ) + send_to_remote_inbox_mock.send.assert_has_calls( + calls, any_order=True + ) + + def test_it_calls_sent_to_inbox_for_latest_workouts( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + generate_follow_request(remote_user_2, user_1) + user_1.approves_follow_request_from(remote_user_2) + # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + file_path = os.path.join( + app_with_federation.root_path, "tests/files/gpx_test.zip" + ) + max_workouts_to_send = 2 + + with open(file_path, "rb") as zip_file: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with patch( + "fittrackee.workouts.workouts.MAX_WORKOUTS_TO_SEND", + max_workouts_to_send, + ): + client.post( + "/api/workouts", + data=dict( + file=(zip_file, "gpx_test.zip"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + assert ( + send_to_remote_inbox_mock.send.call_count + == max_workouts_to_send + ) + + def test_workout_ap_id_and_remote_url_are_saved_when_activity_is_sent( + self, + send_to_remote_inbox_mock: Mock, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + ) -> None: + # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + file_path = os.path.join( + app_with_federation.root_path, "tests/files/gpx_test.zip" + ) + max_workouts_to_send = 2 + + with open(file_path, "rb") as zip_file: + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + with patch( + "fittrackee.workouts.workouts.MAX_WORKOUTS_TO_SEND", + max_workouts_to_send, + ): + client.post( + "/api/workouts", + data=dict( + file=(zip_file, "gpx_test.zip"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{VisibilityLevel.PRIVATE.value}", ' + f'"workout_visibility": ' + f'"{VisibilityLevel.FOLLOWERS_AND_REMOTE.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + workouts = Workout.query.order_by(Workout.workout_date.desc()).all() + for workout in workouts[:max_workouts_to_send]: + assert workout.ap_id == ( + f"{user_1.actor.activitypub_id}/workouts/{workout.short_id}" + ) + assert workout.remote_url == ( + f"https://{user_1.actor.domain.name}/" + f"workouts/{workout.short_id}" + ) + for workout in workouts[max_workouts_to_send:]: + assert workout.ap_id is None + assert workout.remote_url is None diff --git a/fittrackee/tests/federation/workouts/test_workouts_likes_api_post.py b/fittrackee/tests/federation/workouts/test_workouts_likes_api_post.py new file mode 100644 index 000000000..32c985f9a --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_likes_api_post.py @@ -0,0 +1,216 @@ +import json +from unittest.mock import Mock, patch + +from flask import Flask + +from fittrackee import db +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + +from ...mixins import ApiTestCaseMixin, BaseTestMixin + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestWorkoutLikePost(ApiTestCaseMixin, BaseTestMixin): + route = "/api/workouts/{workout_uuid}/like" + + def test_it_creates_workout_like( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=remote_cycling_workout.short_id), + 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"]["workouts"]) == 1 + assert ( + data["data"]["workouts"][0]["id"] + == remote_cycling_workout.short_id + ) + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=remote_cycling_workout.id + ).first() + is not None + ) + assert remote_cycling_workout.likes.all() == [user_1] + + def test_it_does_not_call_sent_to_inbox_if_workout_is_local( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_calls_sent_to_inbox_if_comment_is_remote( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(workout_uuid=remote_cycling_workout.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + like_activity = WorkoutLike.query.one().get_activity() + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=like_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) + + +@patch("fittrackee.federation.utils.user.update_remote_user") +@patch("fittrackee.workouts.workouts.send_to_remote_inbox") +class TestWorkoutUndoLikePost(ApiTestCaseMixin, BaseTestMixin): + route = "/api/workouts/{workout_uuid}/like/undo" + + def test_it_removes_workout_like( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=remote_cycling_workout.id + ) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(workout_uuid=remote_cycling_workout.short_id), + 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"]["workouts"]) == 1 + assert ( + data["data"]["workouts"][0]["id"] + == remote_cycling_workout.short_id + ) + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=remote_cycling_workout.id + ).first() + is None + ) + assert remote_cycling_workout.likes.all() == [] + + def test_it_does_not_call_sent_to_inbox_if_workout_is_local( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + + def test_it_calls_sent_to_inbox_if_like_is_remote( + self, + send_to_remote_inbox_mock: Mock, + update_mock: Mock, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + ) -> None: + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_1.id, workout_id=remote_cycling_workout.id + ) + db.session.add(like) + db.session.commit() + undo_activity = WorkoutLike.query.one().get_activity(is_undo=True) + client, auth_token = self.get_test_client_and_auth_token( + app_with_federation, user_1.email + ) + + client.post( + self.route.format(workout_uuid=remote_cycling_workout.short_id), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_called_once_with( + sender_id=user_1.actor.id, + activity=undo_activity, + recipients=[remote_user.actor.shared_inbox_url], + ) diff --git a/fittrackee/tests/federation/workouts/test_workouts_models.py b/fittrackee/tests/federation/workouts/test_workouts_models.py new file mode 100644 index 000000000..6a8d5d83c --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_models.py @@ -0,0 +1,311 @@ +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.exceptions import InvalidVisibilityException +from fittrackee.federation.objects.like import LikeObject +from fittrackee.tests.workouts.test_workouts_model import WorkoutModelTestCase +from fittrackee.tests.workouts.utils import add_follower +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.exceptions import WorkoutForbiddenException +from fittrackee.workouts.models import ( + Sport, + Workout, + WorkoutLike, + WorkoutSegment, +) + +from ...utils import random_string + +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +class TestWorkoutModelAsRemoteFollower(WorkoutModelTestCase): + def test_it_raises_exception_when_workout_visibility_is_private( + self, + app_with_federation: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + remote_user: User, + ) -> None: + add_follower(user_1, remote_user) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PRIVATE + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize(user=remote_user) + + def test_it_raises_exception_when_workout_visibility_is_local_follower_only( # noqa + self, + app_with_federation: Flask, + sport_1_cycling: Sport, + user_1: User, + remote_user: User, + workout_cycling_user_1: Workout, + ) -> None: + add_follower(user_1, remote_user) + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize(user=remote_user) + + @pytest.mark.parametrize( + "input_analysis_visibility,input_workout_visibility", + [ + ( + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ( + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ), + ( + VisibilityLevel.PUBLIC, + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_serializer_returns_analysis_related_data( + self, + input_analysis_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + sport_1_cycling: Sport, + user_1: User, + remote_user: User, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + add_follower(user_1, remote_user) + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.analysis_visibility = input_analysis_visibility + workout = self.update_workout_with_file_data( + workout_cycling_user_1, map_id=random_string() + ) + + serialized_workout = workout.serialize(user=remote_user, light=False) + + assert serialized_workout["map"] is None + assert serialized_workout["bounds"] == [] + assert serialized_workout["with_file"] is False + assert serialized_workout["map_visibility"] == VisibilityLevel.PRIVATE + assert ( + serialized_workout["analysis_visibility"] + == input_analysis_visibility + ) + assert ( + serialized_workout["workout_visibility"] + == input_workout_visibility + ) + assert serialized_workout["segments"] == [ + { + **workout_cycling_user_1_segment.serialize(), + "segment_number": 1, + } + ] + + @pytest.mark.parametrize( + "input_analysis_visibility,input_workout_visibility", + [ + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ), + ], + ) + def test_serializer_does_not_return_analysis_related_data( + self, + input_analysis_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + sport_1_cycling: Sport, + user_1: User, + remote_user: User, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + add_follower(user_1, remote_user) + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.analysis_visibility = input_analysis_visibility + workout = self.update_workout_with_file_data(workout_cycling_user_1) + + serialized_workout = workout.serialize(user=remote_user) + + assert serialized_workout["map"] is None + assert serialized_workout["bounds"] == [] + assert serialized_workout["with_file"] is False + assert serialized_workout["map_visibility"] == VisibilityLevel.PRIVATE + assert ( + serialized_workout["analysis_visibility"] + == VisibilityLevel.PRIVATE + ) + assert ( + serialized_workout["workout_visibility"] + == input_workout_visibility + ) + assert serialized_workout["segments"] == [] + + +class TestWorkoutModelGetWorkoutCreateActivity: + activity_type = "Create" + + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_if_visibility_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + with pytest.raises(InvalidVisibilityException): + workout_cycling_user_1.get_activities( + activity_type=self.activity_type + ) + + @pytest.mark.parametrize( + "workout_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_returns_activities_when_visibility_is_valid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = workout_visibility + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + + create_workout, create_note = workout_cycling_user_1.get_activities( + activity_type=self.activity_type + ) + + assert create_workout["type"] == self.activity_type + assert create_workout["object"]["type"] == "Workout" + assert create_note["type"] == self.activity_type + assert create_note["object"]["type"] == "Note" + + +class TestWorkoutModelGetWorkoutUpdateActivity( + TestWorkoutModelGetWorkoutCreateActivity +): + activity_type = "Update" + + +class TestWorkoutModelGetWorkoutDeleteActivity: + @pytest.mark.parametrize( + "input_visibility", + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_raises_error_if_visibility_is_invalid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + with pytest.raises(InvalidVisibilityException): + workout_cycling_user_1.get_activities(activity_type="Delete") + + @pytest.mark.parametrize( + "workout_visibility", + [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC], + ) + def test_it_returns_activities_when_visibility_is_valid( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = workout_visibility + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + + delete_workout, _ = workout_cycling_user_1.get_activities( + activity_type="Delete" + ) + + assert delete_workout["type"] == "Delete" + assert delete_workout["object"]["type"] == "Tombstone" + assert delete_workout["object"]["id"] == workout_cycling_user_1.ap_id + + +class TestWorkoutLikeActivities: + def test_it_returns_like_activity( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + like_activity = like.get_activity() + + assert ( + like_activity + == LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + like_id=like.id, + actor_ap_id=user_1.actor.activitypub_id, + ).get_activity() + ) + + def test_it_returns_undo_like_activity( + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.ap_id = workout_cycling_user_1.get_ap_id() + workout_cycling_user_1.remote_url = ( + workout_cycling_user_1.get_remote_url() + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + like_activity = like.get_activity(is_undo=True) + + assert ( + like_activity + == LikeObject( + target_object_ap_id=workout_cycling_user_1.ap_id, + like_id=like.id, + actor_ap_id=user_1.actor.activitypub_id, + is_undo=True, + ).get_activity() + ) diff --git a/fittrackee/tests/federation/workouts/test_workouts_utils_visibility.py b/fittrackee/tests/federation/workouts/test_workouts_utils_visibility.py new file mode 100644 index 000000000..ac2502cc4 --- /dev/null +++ b/fittrackee/tests/federation/workouts/test_workouts_utils_visibility.py @@ -0,0 +1,213 @@ +import pytest +from flask import Flask + +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel, can_view +from fittrackee.workouts.models import Sport, Workout + + +class TestFederationCanViewWorkout: + def test_workout_owner_can_view_his_workout( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_1, "workout_visibility", user_1) + is True + ) + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_remote_follower_can_not_view_workout_when_visibility_does_not_allow_it( # noqa + self, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + + assert ( + can_view(workout_cycling_user_2, "workout_visibility", user_1) + is False + ) + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_remote_follower_can_view_workout_when_visibility_allows_it( + self, + input_workout_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = input_workout_visibility + + assert ( + can_view(remote_cycling_workout, "workout_visibility", user_1) + is True + ) + + def test_local_follower_can_view_workout_when_follower_and_remote_only( + self, + app_with_federation: 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 = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_2, "workout_visibility", user_1) + is True + ) + + def test_another_user_can_not_view_workout_when_follower_and_remote_only( + self, + app_with_federation: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_2, "workout_visibility", user_1) + is False + ) + + +class TestFederationCanViewWorkoutMap: + def test_workout_owner_can_view_his_workout_map( + self, + app_with_federation: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.map_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_1, "map_visibility", user_1) is True + ) + + @pytest.mark.parametrize( + "input_map_visibility", + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_remote_follower_can_not_view_workout_map_when_visibility_does_not_allow_it( # noqa + self, + input_map_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.map_visibility = input_map_visibility + + assert ( + can_view(workout_cycling_user_2, "map_visibility", user_1) is False + ) + + @pytest.mark.parametrize( + "input_map_visibility", + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ], + ) + def test_remote_follower_can_view_workout_map_when_visibility_allows_it( + self, + input_map_visibility: VisibilityLevel, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + remote_cycling_workout: Workout, + follow_request_from_user_1_to_remote_user: FollowRequest, + ) -> None: + remote_user.approves_follow_request_from(user_1) + remote_cycling_workout.workout_visibility = VisibilityLevel.PUBLIC + remote_cycling_workout.map_visibility = input_map_visibility + + assert ( + can_view(remote_cycling_workout, "map_visibility", user_1) is True + ) + + def test_local_follower_can_not_view_workout_map_when_follower_and_remote_only( # noqa + self, + app_with_federation: 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 = VisibilityLevel.PUBLIC + workout_cycling_user_2.map_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_2, "map_visibility", user_1) is True + ) + + def test_another_user_can_not_view_workout_map_when_follower_and_remote_only( # noqa + self, + app_with_federation: Flask, + user_1: User, + remote_user: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.map_visibility = ( + VisibilityLevel.FOLLOWERS_AND_REMOTE + ) + + assert ( + can_view(workout_cycling_user_2, "map_visibility", user_1) is False + ) diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index 74bd3996f..86e8e6089 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -9,6 +9,7 @@ from fittrackee import create_app, db, limiter from fittrackee.application.models import AppConfig from fittrackee.application.utils import update_app_config_from_database +from fittrackee.federation.models import Domain from fittrackee.workouts.services.workout_from_file.base_workout_with_segment_service import ( # noqa weather_service, ) @@ -68,6 +69,7 @@ def get_app( max_zip_file_size: Optional[Union[int, float]] = None, max_users: Optional[int] = None, global_map_workouts_limit: Optional[int] = None, + with_domain: Optional[bool] = True, ) -> Generator: app = create_app() limiter.enabled = False @@ -84,6 +86,14 @@ def get_app( global_map_workouts_limit, ) update_app_config_from_database(app, app_db_config) + if with_domain: + domain = Domain.query.one_or_none() + if not domain: + domain = Domain( + name=app.config["AP_DOMAIN"], + software_name="fittrackee", + ) + db.session.add(domain) yield app except Exception as e: print(f"Error with app configuration: {e}") # noqa: T201 @@ -103,6 +113,7 @@ def get_app( @pytest.fixture def app(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") if os.getenv("TILE_SERVER_URL"): monkeypatch.delenv("TILE_SERVER_URL") @@ -127,6 +138,7 @@ def app(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture def app_default_static_map(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv( "TILE_SERVER_URL", "https://tile.openstreetmap.de/{z}/{x}/{y}.png" ) @@ -136,6 +148,7 @@ def app_default_static_map(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture def app_with_max_workouts(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") yield from get_app(with_config=True, max_sync_workouts=1, max_workouts=2) @@ -144,35 +157,41 @@ def app_with_max_workouts(monkeypatch: pytest.MonkeyPatch) -> Generator: def app_with_max_file_size_equals_0( monkeypatch: pytest.MonkeyPatch, ) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") yield from get_app(with_config=True, max_single_file_size=0) @pytest.fixture def app_with_max_file_size(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") yield from get_app(with_config=True, max_single_file_size=0.001) @pytest.fixture def app_with_max_zip_file_size(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") yield from get_app(with_config=True, max_zip_file_size=0.001) @pytest.fixture def app_with_3_users_max(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://none:none@0.0.0.0:1025") yield from get_app(with_config=True, max_users=3) @pytest.fixture def app_no_config(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") yield from get_app(with_config=False) @pytest.fixture def app_ssl(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv( "EMAIL_URL", "smtp://username:password@0.0.0.0:1025?ssl=True" ) @@ -181,6 +200,7 @@ def app_ssl(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture def app_tls(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv( "EMAIL_URL", "smtp://username:password@0.0.0.0:1025?tls=True" ) @@ -189,12 +209,14 @@ def app_tls(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture def app_wo_email_auth(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "smtp://0.0.0.0:1025") yield from get_app(with_config=True) @pytest.fixture def app_wo_email_activation(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "False") monkeypatch.setenv("EMAIL_URL", "") yield from get_app(with_config=True) @@ -250,6 +272,18 @@ def app_with_global_map_workouts_limit_equal_to_1( yield from get_app(with_config=True, global_map_workouts_limit=1) +@pytest.fixture +def app_with_federation(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "True") + yield from get_app(with_config=True) + + +@pytest.fixture +def app_wo_domain(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv("FEDERATION_ENABLED", "True") + yield from get_app(with_config=True, with_domain=False) + + @pytest.fixture() def app_config() -> AppConfig: config = AppConfig() diff --git a/fittrackee/tests/fixtures/fixtures_federation.py b/fittrackee/tests/fixtures/fixtures_federation.py new file mode 100644 index 000000000..5902e80a5 --- /dev/null +++ b/fittrackee/tests/fixtures/fixtures_federation.py @@ -0,0 +1,42 @@ +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.federation.models import Actor, Domain + +from ..utils import RandomActor, random_domain + + +@pytest.fixture() +def app_actor(app: Flask) -> Actor: + domain = Domain.query.filter_by(name=app.config["AP_DOMAIN"]).one() + actor = Actor(preferred_username="test", domain_id=domain.id) + db.session.add(actor) + db.session.commit() + return actor + + +@pytest.fixture() +def remote_domain(app_with_federation: Flask) -> Domain: + remote_domain = Domain(name=random_domain(), software_name="fittrackee") + db.session.add(remote_domain) + db.session.commit() + return remote_domain + + +@pytest.fixture() +def another_remote_domain(app_with_federation: Flask) -> Domain: + remote_domain = Domain(name=random_domain(), software_name="mastodon") + db.session.add(remote_domain) + db.session.commit() + return remote_domain + + +@pytest.fixture() +def random_actor() -> RandomActor: + return RandomActor() + + +@pytest.fixture() +def random_actor_2() -> RandomActor: + return RandomActor() diff --git a/fittrackee/tests/fixtures/fixtures_federation_users.py b/fittrackee/tests/fixtures/fixtures_federation_users.py new file mode 100644 index 000000000..19167e51e --- /dev/null +++ b/fittrackee/tests/fixtures/fixtures_federation_users.py @@ -0,0 +1,75 @@ +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, + get_remote_user_object, + random_string, +) + + +def generate_remote_user( + remote_domain: Domain, without_profile_page: bool = False +) -> User: + domain = f"https://{remote_domain.name}" + user_name = random_string()[0:30] + if without_profile_page: + remote_user_object = get_remote_user_object( + username=user_name.capitalize(), + preferred_username=user_name, + domain=domain, + ) + else: + remote_user_object = get_remote_user_object( + username=user_name.capitalize(), + 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 + user.is_active = True + 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_2(another_remote_domain: Domain) -> User: + return generate_remote_user(another_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 follow_request_from_remote_user_to_user_1( + user_1: User, + remote_user: User, +) -> FollowRequest: + return generate_follow_request(remote_user, user_1) + + +@pytest.fixture() +def follow_request_from_user_1_to_remote_user( + user_1: User, + remote_user: User, +) -> FollowRequest: + 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 836e2d7d9..fc65ae2c3 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -17,6 +17,8 @@ def user_1() -> User: user.hide_profile_in_users_directory = False user.accepted_policy_date = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -28,6 +30,8 @@ def user_1_upper() -> User: user.hide_profile_in_users_directory = False user.accepted_policy_date = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -93,6 +97,8 @@ def user_2() -> User: user.hide_profile_in_users_directory = False user.accepted_policy_date = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -126,6 +132,8 @@ def user_3() -> User: user.weekm = True user.accepted_policy_date = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -144,6 +152,8 @@ def user_4() -> User: user.hide_profile_in_users_directory = False user.weekm = True db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -156,6 +166,8 @@ def inactive_user() -> User: user.confirmation_token = random_string() user.accepted_policy_date = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user @@ -172,6 +184,8 @@ def suspended_user() -> User: user.accepted_policy_date = datetime.now(timezone.utc) user.suspended_at = datetime.now(timezone.utc) db.session.add(user) + db.session.flush() + user.create_actor() db.session.commit() return user diff --git a/fittrackee/tests/fixtures/fixtures_workouts.py b/fittrackee/tests/fixtures/fixtures_workouts.py index 39938c649..3403ff0c5 100644 --- a/fittrackee/tests/fixtures/fixtures_workouts.py +++ b/fittrackee/tests/fixtures/fixtures_workouts.py @@ -13,6 +13,8 @@ from fittrackee import VERSION, db from fittrackee.constants import PaceSpeedDisplay +from fittrackee.tests.utils import random_short_id +from fittrackee.users.models import User from fittrackee.workouts.models import ( TITLE_MAX_CHARACTERS, Sport, @@ -1431,6 +1433,28 @@ def workout_cycling_user_2_segment( return workout_segment +@pytest.fixture() +def remote_cycling_workout(remote_user: "User") -> "Workout": + workout = Workout( + user_id=remote_user.id, + sport_id=1, + workout_date=datetime(2022, 1, 1, tzinfo=timezone.utc), + distance=10, + duration=timedelta(seconds=3600), + ) + update_workout(workout) + remote_domain = remote_user.actor.domain.name + remote_id = random_short_id() + workout.ap_id = ( + f"https://{remote_domain}/federation/user/{remote_user.username}/" + f"workouts/{remote_id}" + ) + workout.remote_url = f"https://{remote_domain}/workouts/{remote_id}" + db.session.add(workout) + db.session.commit() + return workout + + @pytest.fixture() def gpx_track_points_without_elevations() -> List["GPXTrackPoint"]: return [ diff --git a/fittrackee/tests/mixins.py b/fittrackee/tests/mixins.py index 4c7a389f9..c550ca897 100644 --- a/fittrackee/tests/mixins.py +++ b/fittrackee/tests/mixins.py @@ -5,7 +5,7 @@ import time from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from unittest.mock import Mock, patch from urllib.parse import parse_qs from uuid import uuid4 @@ -20,6 +20,7 @@ from fittrackee import db from fittrackee.comments.models import Comment +from fittrackee.federation.models import Actor from fittrackee.files import get_absolute_file_path from fittrackee.oauth2.client import create_oauth2_client from fittrackee.oauth2.models import OAuth2Client, OAuth2Token @@ -45,6 +46,17 @@ from geoalchemy2.elements import WKBElement +class BaseTestMixin: + @staticmethod + def assert_call_kwargs_keys_equal(mock: Mock, expected_keys: List) -> None: + _, call_kwargs = mock.call_args + assert list(call_kwargs.keys()) == expected_keys + + @staticmethod + def assert_dict_contains_subset(container: Dict, subset: Dict) -> None: + assert subset.items() <= container.items() + + class RandomMixin: @staticmethod def random_string( @@ -122,8 +134,10 @@ def create_oauth2_token( class ApiTestCaseMixin(OAuth2Mixin, RandomMixin): @staticmethod def get_test_client_and_auth_token( - app: Flask, user_email: str + app: Flask, user_email: Optional[str] ) -> Tuple[FlaskClient, str]: + if not user_email: + raise Exception("Invalid user email") client = app.test_client() resp_login = client.post( "/api/auth/login", @@ -658,3 +672,27 @@ def get_response(response: Union[List, Dict]) -> "Mock": response_mock.json = Mock() response_mock.json.return_value = response return response_mock + + +class UserInboxTestMixin(BaseTestMixin): + def assert_send_to_remote_inbox_called_once( + self, + send_to_remote_inbox_mock: Mock, + local_actor: Actor, + remote_actor: Actor, + base_object: Any, + activity_args: Optional[Dict] = None, + ) -> None: + send_to_remote_inbox_mock.send.assert_called_once() + self.assert_call_kwargs_keys_equal( + send_to_remote_inbox_mock.send, + ["sender_id", "activity", "recipients"], + ) + _, call_kwargs = send_to_remote_inbox_mock.send.call_args + assert call_kwargs["sender_id"] == local_actor.id + assert call_kwargs["recipients"] == [remote_actor.inbox_url] + activity = base_object.get_activity( + {} if activity_args is None else activity_args + ) + del activity["id"] + self.assert_dict_contains_subset(call_kwargs["activity"], activity) diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 5e85c3afb..2522386c2 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -28,6 +28,7 @@ from fittrackee.workouts.models import Sport, Workout from ..comments.mixins import CommentMixin +from ..federation.users.test_auth_api import assert_actor_is_created from ..mixins import ApiTestCaseMixin, ReportMixin, UserTaskMixin from ..utils import jsonify_dict @@ -424,6 +425,10 @@ def test_it_creates_user_when_language_is_provided( new_user = User.query.filter_by(username=username).one() assert new_user.language == expected_language + 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_language,expected_language", [("en", "en"), ("fr", "fr"), ("invalid", "en"), (None, "en")], @@ -514,9 +519,9 @@ def test_it_does_not_return_error_if_a_user_already_exists_with_same_email( dict( username=self.random_string(), email=( - user_1.email.upper() + user_1.email.upper() # type: ignore if text_transformation == "upper" - else user_1.email.lower() + else user_1.email.lower() # type: ignore ), password=self.random_string(), accepted_policy=True, @@ -692,9 +697,9 @@ def test_user_can_login_regardless_username_case( data=json.dumps( dict( email=( - user_1.email.upper() + user_1.email.upper() # type: ignore if text_transformation == "upper" - else user_1.email.lower() + else user_1.email.lower() # type: ignore ), password="12345678", ) @@ -1711,6 +1716,44 @@ def test_it_updates_user_preferences_when_user_is_suspended( data["data"]["workouts_visibility"] == VisibilityLevel.PUBLIC.value ) + @pytest.mark.parametrize( + "input_map_visibility,input_workout_visibility", + [ + (VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.FOLLOWERS), + (VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS_AND_REMOTE), + ], + ) + def test_it_returns_400_when_privacy_level_is_invalid( + self, + app: Flask, + user_1: User, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + "/api/auth/profile/edit/preferences", + content_type="application/json", + data=json.dumps( + dict( + timezone="America/New_York", + weekm=True, + language="fr", + imperial_units=True, + display_ascent=True, + date_format="MM/dd/yyyy", + map_visibility=input_map_visibility.value, + workouts_visibility=input_workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + def test_expected_scope_is_profile_write( self, app: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 651431a63..3db3da018 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -11,6 +11,7 @@ from fittrackee import db from fittrackee.dates import get_readable_duration from fittrackee.equipments.models import Equipment +from fittrackee.federation.models import Actor from fittrackee.reports.models import Report, ReportAction from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.users.models import ( @@ -1775,6 +1776,27 @@ def test_it_returns_error_if_user_is_not_authenticated( self.assert_401(response) +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}"), + ) + + self.assert_403( + response, "error, federation is disabled for this instance" + ) + + class TestGetUserPicture(ApiTestCaseMixin): def test_it_return_error_if_user_has_no_picture( self, app: Flask, user_1: User @@ -2172,7 +2194,7 @@ def test_it_updates_user_email_to_confirm_when_email_sending_is_enabled( client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - new_email = "new." + user_2.email + new_email = f"new.{user_2.email}" user_2_email = user_2.email user_2_confirmation_token = user_2.confirmation_token @@ -2194,7 +2216,7 @@ def test_it_updates_user_email_when_email_sending_is_disabled( client, auth_token = self.get_test_client_and_auth_token( app_wo_email_activation, user_1_admin.email ) - new_email = "new." + user_2.email + new_email = f"new.{user_2.email}" response = client.patch( f"/api/users/{user_2.username}", @@ -2218,7 +2240,7 @@ def test_it_calls_email_updated_to_new_address_when_password_reset_is_successful client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - new_email = "new." + user_2.email + new_email = f"new.{user_2.email}" expected_token = self.random_string() with patch("secrets.token_urlsafe", return_value=expected_token): @@ -2256,7 +2278,7 @@ def test_it_does_not_call_email_updated_to_new_address_when_email_sending_is_dis client, auth_token = self.get_test_client_and_auth_token( app_wo_email_activation, user_1_admin.email ) - new_email = "new." + user_2.email + new_email = f"new.{user_2.email}" response = client.patch( f"/api/users/{user_2.username}", @@ -2800,6 +2822,21 @@ def test_it_does_not_enable_registration_on_user_delete_when_users_count_is_not_ self.assert_403(response, "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 + def test_expected_scope_is_users_write( self, app: Flask, user_1_admin: User ) -> None: diff --git a/fittrackee/tests/users/test_users_commands.py b/fittrackee/tests/users/test_users_commands.py index 6af445ebc..af2080772 100644 --- a/fittrackee/tests/users/test_users_commands.py +++ b/fittrackee/tests/users/test_users_commands.py @@ -54,7 +54,7 @@ def test_it_displays_error_when_user_exists_with_same_email( "create", self.random_string(), "--email", - user_1.email, + user_1.email, # type: ignore "--password", self.random_string(), ], @@ -292,7 +292,7 @@ def test_it_displays_error_when_role_is_invalid( "create", self.random_string(), "--email", - user_1.email, + user_1.email, # type: ignore "--role", "invalid", ], diff --git a/fittrackee/tests/users/test_users_export_data.py b/fittrackee/tests/users/test_users_export_data.py index d03c7f44b..961bbd6a1 100644 --- a/fittrackee/tests/users/test_users_export_data.py +++ b/fittrackee/tests/users/test_users_export_data.py @@ -341,6 +341,43 @@ def test_it_returns_user_comment( "created_at": comment.created_at, "id": comment.short_id, "modification_date": comment.modification_date, + "reply_to": None, + "text": comment.text, + "text_visibility": comment.text_visibility.value, + "workout_id": workout_cycling_user_1.short_id, + }, + ] + + def test_it_returns_user_reply( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=parent_comment, + ) + exporter = UserDataExporter(user_1) + + comments_data = exporter.get_user_comments_data() + + assert comments_data == [ + { + "created_at": comment.created_at, + "id": comment.short_id, + "modification_date": comment.modification_date, + "reply_to": parent_comment.short_id, "text": comment.text, "text_visibility": comment.text_visibility.value, "workout_id": workout_cycling_user_1.short_id, @@ -366,6 +403,7 @@ def test_it_returns_user_comment_without_workout( "created_at": comment.created_at, "id": comment.short_id, "modification_date": comment.modification_date, + "reply_to": None, "text": comment.text, "text_visibility": comment.text_visibility.value, "workout_id": None, diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py index bec10d09c..788a46311 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, timezone +from unittest.mock import Mock, patch from flask import Flask @@ -9,7 +10,7 @@ from ..utils import random_string -class TestFollow(ApiTestCaseMixin): +class TestFollowWithoutFederation(ApiTestCaseMixin): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, user_1: User ) -> None: @@ -148,6 +149,26 @@ def test_it_returns_success_if_follow_request_already_exists( == f"Follow request to user '{user_2.username}' is sent." ) + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.post( + f"/api/users/{user_2.username}/follow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + def test_expected_scope_is_follow_write( self, app: Flask, user_1: User ) -> None: @@ -161,7 +182,7 @@ def test_expected_scope_is_follow_write( ) -class TestUnfollow(ApiTestCaseMixin): +class TestUnfollowWithoutFederation(ApiTestCaseMixin): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, user_1: User ) -> None: @@ -269,6 +290,27 @@ def test_it_returns_error_when_user_is_blocked( self.assert_404_with_message(response, "relationship does not exist") + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox( + self, + send_to_remote_inbox_mock: Mock, + 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, user_1.email + ) + + client.post( + f"/api/users/{user_2.username}/unfollow", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + send_to_remote_inbox_mock.send.assert_not_called() + def test_expected_scope_is_follow_write( self, app: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py index 949976f9d..745ed8e8a 100644 --- a/fittrackee/tests/users/test_users_follow_request_api.py +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -11,7 +11,7 @@ from ..utils import random_string -class TestGetFollowRequest(ApiTestCaseMixin): +class TestGetFollowRequestWithoutFederation(ApiTestCaseMixin): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask ) -> None: @@ -330,7 +330,7 @@ def assert_it_returns_follow_request_processed( ) -class TestAcceptFollowRequest(FollowRequestTestCase): +class TestAcceptFollowRequestWithoutFederation(FollowRequestTestCase): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, user_1: User ) -> None: @@ -434,7 +434,7 @@ def test_expected_scope_is_follow_write( ) -class TestRejectFollowRequest(FollowRequestTestCase): +class TestRejectFollowRequestWithoutFederation(FollowRequestTestCase): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 4d000bd4c..7900f87a4 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Dict, Set +from unittest.mock import Mock, patch import pytest from flask import Flask @@ -14,6 +15,7 @@ from fittrackee import db from fittrackee.constants import ElevationDataSource, PaceSpeedDisplay from fittrackee.equipments.models import Equipment +from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.files import get_absolute_file_path from fittrackee.reports.models import ReportAction from fittrackee.tests.comments.mixins import CommentMixin @@ -624,6 +626,7 @@ def test_it_returns_limited_user_infos_by_default( "created_at": user_1.created_at, "followers": user_1.followers.count(), "following": user_1.following.count(), + "is_remote": False, "nb_workouts": user_1.workouts_count, "picture": user_1.picture is not None, "role": UserRole(user_1.role).name.lower(), @@ -1670,6 +1673,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=re.escape("Federation is disabled."), + ): + follow_request_from_user_1_to_user_2.get_activity() + def test_it_deletes_follow_request_on_followed_user_delete( self, app: Flask, @@ -1720,6 +1734,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_remote_inbox") + def test_it_does_not_call_send_to_user_inbox_when_federation_is_disabled( + self, + send_to_remote_inbox_mock: Mock, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_2.send_follow_request_to(user_1) + + send_to_remote_inbox_mock.send.assert_not_called() + def test_user_1_receives_follow_requests_from_user_2( self, app: Flask, @@ -1854,6 +1880,24 @@ def test_it_removes_pending_follow_request( assert user_1.sent_follow_requests.all() == [] + @patch("fittrackee.users.models.send_to_remote_inbox") + def test_it_does_not_call_send_to_user_inbox_when_federation_is_disabled( + self, + send_to_remote_inbox_mock: Mock, + 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( + timezone.utc + ) + + user_1.unfollows(user_2) + + send_to_remote_inbox_mock.send.assert_not_called() + class TestUserFollowers: def test_it_returns_empty_list_if_no_followers( @@ -1947,6 +1991,23 @@ def test_it_does_not_return_suspended_following_user( assert user_1.following.all() == [] +class TestUserGetRecipientsSharedInbox: + def test_it_raises_exception_if_federation_disabled( + self, app: Flask, user_1: User + ) -> None: + with pytest.raises(FederationDisabledException): + user_1.get_followers_shared_inboxes() + + +class TestUserFullname: + def test_it_returns_user_actor_fullname( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.fullname == user_1.actor.fullname + + class TestUserFollowRequestStatus(UserModelAssertMixin): def test_it_returns_user_1_and_user_2_dont_not_follow_each_other( self, @@ -2031,11 +2092,21 @@ def test_it_returns_linkified_mention_with_username( app: Flask, user_1: User, ) -> None: - assert user_1.linkify_mention() == ( + assert user_1.linkify_mention(with_domain=False) == ( f'@{user_1.username}' ) + def test_it_returns_linkified_mention_with_fullname( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.linkify_mention(with_domain=True) == ( + f'@{user_1.fullname}' + ) + class TestBlocksUser: def test_it_blocks_user( @@ -2393,6 +2464,7 @@ def test_it_returns_limited_user_infos_by_default( "follows": user_2.follows(user_1_admin), "is_active": True, "is_followed_by": user_2.is_followed_by(user_1_admin), + "is_remote": user_2.is_remote, "nb_workouts": user_2.workouts_count, "picture": user_2.picture is not None, "role": UserRole(user_2.role).name.lower(), @@ -2416,6 +2488,7 @@ def test_it_returns_limited_user_infos_as_admin( "follows": user_2.follows(user_1_admin), "is_active": True, "is_followed_by": user_2.is_followed_by(user_1_admin), + "is_remote": user_2.is_remote, "nb_workouts": user_2.workouts_count, "picture": user_2.picture is not None, "role": UserRole(user_2.role).name.lower(), @@ -2435,6 +2508,7 @@ def test_it_returns_limited_user_infos_as_user( "following": user_2.following.count(), "follows": user_2.follows(user_1), "is_followed_by": user_2.is_followed_by(user_1), + "is_remote": user_2.is_remote, "nb_workouts": user_2.workouts_count, "picture": user_2.picture is not None, "role": UserRole(user_2.role).name.lower(), @@ -2451,6 +2525,7 @@ def test_it_returns_limited_user_infos_as_unauthenticated_user( "created_at": user_2.created_at, "followers": user_2.followers.count(), "following": user_2.following.count(), + "is_remote": user_2.is_remote, "nb_workouts": user_2.workouts_count, "picture": user_2.picture is not None, "role": UserRole(user_2.role).name.lower(), diff --git a/fittrackee/tests/users/test_users_notifications_api.py b/fittrackee/tests/users/test_users_notifications_api.py index e2b7710b9..a073423ad 100644 --- a/fittrackee/tests/users/test_users_notifications_api.py +++ b/fittrackee/tests/users/test_users_notifications_api.py @@ -462,6 +462,49 @@ def test_it_does_not_return_comment_notification_from_blocked_user( "total": 0, } + def test_it_does_not_return_reply_notification_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + def test_it_does_not_return_mention_notification_from_blocked_user( self, app: Flask, @@ -629,6 +672,49 @@ def test_it_does_not_return_comment_notification_when_author_blocks_user( "total": 0, } + def test_it_does_not_return_reply_notification_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + def test_it_does_not_return_mention_notification_when_author_blocks_user( self, app: Flask, @@ -846,6 +932,50 @@ def test_it_does_not_return_comment_notification_from_suspended_user( "total": 0, } + def test_it_does_not_return_reply_notification_from_suspended_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + parent_comment=comment, + ) + user_2.suspended_at = datetime.now(timezone.utc) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + def test_it_does_not_return_mention_notification_from_suspended_user( self, app: Flask, @@ -926,6 +1056,53 @@ def test_it_does_not_return_comment_notification_when_user_does_not_follow_autho "total": 0, } + def test_it_does_not_return_reply_notification_when_user_does_not_follow_author_anymore( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + # comment without mention + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + user_1.unfollows(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + def test_it_returns_report_notification( self, app: Flask, @@ -1469,6 +1646,46 @@ def test_it_returns_unread_as_false_when_user_does_not_follow_comment_author_any assert data["status"] == "success" assert data["unread"] is False + def test_it_returns_unread_as_false_when_user_does_not_follow_reply_author_anymore( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: 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 = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + # comment without mention + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + parent_comment=comment, + ) + user_1.unfollows(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + def test_expected_scope_is_notifications_read( self, app: Flask, user_1_admin: User ) -> None: diff --git a/fittrackee/tests/users/test_users_notifications_model.py b/fittrackee/tests/users/test_users_notifications_model.py index 056bd7cd6..d38cd7087 100644 --- a/fittrackee/tests/users/test_users_notifications_model.py +++ b/fittrackee/tests/users/test_users_notifications_model.py @@ -34,6 +34,7 @@ def create_mention(user: User, comment: Comment) -> Mention: def comment_workout( user: User, workout: Workout, + reply_to: Optional[int] = None, text: Optional[str] = None, text_visibility: Optional[VisibilityLevel] = None, ) -> Comment: @@ -46,6 +47,7 @@ def comment_workout( ), ) db.session.add(comment) + comment.reply_to = reply_to db.session.commit() return comment @@ -935,6 +937,154 @@ def test_it_deletes_workout_notifications_on_workout_deletion( ) +class TestNotificationForCommentReply(NotificationTestCase): + def test_it_creates_notification_on_comment_reply( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout(user_2, workout_cycling_user_1) + reply = self.comment_workout( + user_3, workout_cycling_user_1, comment.id + ) + + notification = Notification.query.filter_by( + from_user_id=reply.user_id, + to_user_id=comment.user_id, + event_object_id=reply.id, + ).one() + assert notification.created_at == reply.created_at + assert notification.marked_as_read is False + assert notification.event_type == "comment_reply" + + def test_it_deletes_notification_on_comment_reply_delete( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_2, workout_cycling_user_1) + reply = self.comment_workout( + user_3, workout_cycling_user_1, comment.id + ) + reply_id = reply.id + + db.session.delete(comment) + + notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=reply_id, + event_type="comment_reply", + ).first() + assert notification is None + + def test_it_does_not_create_notification_when_user_replies_to_his_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + reply = self.comment_workout( + user_1, workout_cycling_user_1, comment.id + ) + + notification = Notification.query.filter_by( + from_user_id=reply.user_id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=reply.id, + ).first() + assert notification is None + + def test_it_does_not_create_notification_when_parent_comment_user_is_not_follower( # noqa + self, + 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 = VisibilityLevel.FOLLOWERS + comment = self.comment_workout( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + reply = self.comment_workout( + user_2, + workout_cycling_user_1, + comment.id, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + db.session.flush() + + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_object_id=reply.id, + ).first() + is None + ) + + def test_it_does_not_raise_error_when_user_deletes_reply_on_his_own_comment( # noqa + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + reply = self.comment_workout( + user_1, workout_cycling_user_1, comment.id + ) + + db.session.delete(reply) + + def test_it_serializes_comment_reply_notification( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + reply = self.comment_workout( + user_2, workout_cycling_user_1, comment.id + ) + notification = Notification.query.filter_by( + event_object_id=reply.id, + event_type="comment_reply", + ).one() + + serialized_notification = notification.serialize() + + assert serialized_notification["comment"] == reply.serialize(user_1) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1 + ) + assert serialized_notification["id"] == notification.short_id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "comment_reply" + assert "report_action" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + class TestNotificationForCommentLike(NotificationTestCase): def test_it_creates_notification_on_comment_like( self, @@ -1346,6 +1496,39 @@ def test_it_creates_notification_when_mentioned_user_is_workout_owner_when_only_ assert notifications[0].marked_as_read is False assert notifications[0].event_type == "workout_comment" + def test_it_does_not_create_notification_when_mentioned_user_is_parent_comment_owner( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + parent_comment = self.comment_workout( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment = self.comment_workout( + user_3, + workout_cycling_user_1, + reply_to=parent_comment.id, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_mention(user_2, comment) + + notifications = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_2.id, + ).all() + assert len(notifications) == 1 + assert notifications[0].created_at == comment.created_at + assert notifications[0].marked_as_read is False + assert notifications[0].event_type == "comment_reply" + def test_it_deletes_notification_on_mention_delete( self, app: Flask, @@ -1491,6 +1674,68 @@ def test_it_deletes_comment_notifications_on_comment_deletion( is not None ) + def test_it_deletes_all_notifications_on_reply_with_mention_and_like_delete( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout( + user_2, workout_cycling_user_1, text=f"@{user_3.username}" + ) + comment_id = comment.id + self.create_mention(user_3, comment) + self.like_comment(user_3, comment) + reply = self.comment_workout( + user_1, workout_cycling_user_1, comment.id + ) + + db.session.delete(comment) + + # workout_comment notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_object_id=comment_id, + event_type="workout_comment", + ).first() + is None + ) + # mention notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_3.id, + event_object_id=comment_id, + event_type="mention", + ).first() + is None + ) + # like notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_3.id, + event_object_id=comment_id, + event_type="comment_like", + ).first() + is None + ) + # comment_reply notification is not deleted + assert ( + Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + event_object_id=reply.id, + event_type="comment_reply", + ).first() + is not None + ) + class TestNotificationForReport(NotificationTestCase): def test_it_does_not_create_notifications_when_no_admin( diff --git a/fittrackee/tests/users/test_users_service.py b/fittrackee/tests/users/test_users_service.py index 8a2227f5a..4919b0ee2 100644 --- a/fittrackee/tests/users/test_users_service.py +++ b/fittrackee/tests/users/test_users_service.py @@ -727,7 +727,7 @@ def test_it_raises_exception_if_a_user_exists_with_same_email( UserCreationException, match=re.escape("This user already exists. No action done."), ): - user_manager_service.create(email=user_1.email) + user_manager_service.create(email=user_1.email) # type: ignore def test_it_creates_user_with_provided_password(self, app: Flask) -> None: username = random_string() diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 0e8e4a2c4..a68402f2a 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -1,5 +1,6 @@ import random import string +from dataclasses import dataclass, field from datetime import datetime, timezone from json import dumps, loads from typing import Dict, Optional, Union @@ -31,6 +32,10 @@ def random_string( ) +def random_domain_with_scheme() -> str: + return random_string(prefix="https://", suffix=".social") + + def random_domain() -> str: return random_string(suffix=".social") @@ -55,6 +60,95 @@ def random_short_id() -> str: return encode_uuid(uuid4()) +def get_remote_user_object( + username: str, + preferred_username: str, + domain: str, + profile_url: Optional[str] = None, + manually_approves_followers: bool = True, + with_icon: bool = False, +) -> Dict: + user_url = f"{domain}/users/{preferred_username}" + user_object = { + "@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": manually_approves_followers, + "publicKey": { + "id": f"{user_url}#main-key", + "owner": user_url, + "publicKeyPem": random_string(), + }, + "endpoints": {"sharedInbox": f"{domain}/inbox"}, + } + if profile_url: + user_object["url"] = profile_url + if with_icon: + user_object["icon"] = { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://example.com/916cac70b7c694a4.jpg", + } + return user_object + + +@dataclass +class RandomActor: + name: str = field(default_factory=random_string) + preferred_username: str = field(default_factory=random_string) + domain: str = field(default_factory=random_domain_with_scheme) + manually_approves_followers: bool = True + + @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}" + + @property + def profile_url(self) -> str: + return f"{self.domain}/{self.preferred_username}" + + @property + def followers_url(self) -> str: + return f"{self.domain}/users/{self.preferred_username}/followers" + + def get_remote_user_object(self, with_icon: bool = False) -> Dict: + return get_remote_user_object( + self.name, + self.preferred_username, + self.domain, + self.profile_url, + self.manually_approves_followers, + with_icon, + ) + + def get_webfinger(self) -> Dict: + return { + "subject": f"acct:{self.fullname}", + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": self.activitypub_id, + } + ], + } + + def generate_response( content: Optional[Union[str, Dict]] = None, status_code: Optional[int] = None, @@ -70,6 +164,18 @@ def generate_response( return response +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 generate_follow_request(follower: User, followed: User) -> FollowRequest: follow_request = FollowRequest( follower_user_id=follower.id, followed_user_id=followed.id diff --git a/fittrackee/tests/workouts/test_utils/test_workouts.py b/fittrackee/tests/workouts/test_utils/test_workouts.py index b649c28cf..fb97ddd6e 100644 --- a/fittrackee/tests/workouts/test_utils/test_workouts.py +++ b/fittrackee/tests/workouts/test_utils/test_workouts.py @@ -141,11 +141,11 @@ def test_it_returns_empty_list_when_no_workouts_provided( self, app: Flask, ) -> None: - ordered_workouts = get_ordered_workouts([], limit=3) + ordered_workouts = get_ordered_workouts([], limit=10) assert ordered_workouts == [] - def test_it_returns_last_workouts_depending_on_limit( + def test_it_returns_workouts_ordered_by_workout_date_descending( self, app: Flask, user_1: User, @@ -153,30 +153,18 @@ def test_it_returns_last_workouts_depending_on_limit( sport_2_running: Sport, seven_workouts_user_1: List[Workout], ) -> None: - ordered_workouts = get_ordered_workouts(seven_workouts_user_1, limit=3) + ordered_workouts = get_ordered_workouts( + seven_workouts_user_1, limit=10 + ) assert ordered_workouts == [ seven_workouts_user_1[6], seven_workouts_user_1[5], seven_workouts_user_1[3], - ] - - def test_it_returns_all_workouts_when_below_limit( - 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: - ordered_workouts = get_ordered_workouts( - [workout_cycling_user_1, workout_running_user_1], limit=3 - ) - - assert ordered_workouts == [ - workout_running_user_1, - workout_cycling_user_1, + seven_workouts_user_1[4], + seven_workouts_user_1[2], + seven_workouts_user_1[1], + seven_workouts_user_1[0], ] diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index da5cffc42..5401f594e 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -742,6 +742,49 @@ def test_it_adds_a_workout_with_kmz_file( assert data["data"]["workouts"][0]["notes"] is None assert len(data["data"]["workouts"][0]["segments"]) == 2 + @pytest.mark.parametrize( + "input_map_visibility,input_workout_visibility", + [ + (VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.FOLLOWERS), + (VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS_AND_REMOTE), + ], + ) + def test_it_returns_400_when_privacy_level_is_invalid( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + """ + when workout visibility is stricter, map visibility is initialised + with workout visibility value + """ + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + "/api/workouts", + data=dict( + file=(BytesIO(str.encode(gpx_file)), "example.gpx"), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{input_map_visibility.value}", ' + f'"workout_visibility": ' + f'"{input_workout_visibility.value}"}}' + ), + ), + headers=dict( + content_type="multipart/form-data", + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response) + class TestPostWorkoutWithTcx(WorkoutApiTestCaseMixin): def test_it_adds_a_workout_with_tcx_file( @@ -1390,6 +1433,144 @@ def test_it_returns_400_when_multiple_equipments_are_provided( self.assert_400(response, "only one equipment can be added") + @pytest.mark.parametrize( + "input_desc,input_visibility", + [ + ("private", VisibilityLevel.PRIVATE), + ("followers_only", VisibilityLevel.FOLLOWERS), + ("public", VisibilityLevel.PUBLIC), + ], + ) + def test_workout_is_created_with_user_privacy_parameters_when_no_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_desc: str, + input_visibility: VisibilityLevel, + ) -> None: + user_1.map_visibility = input_visibility + user_1.analysis_visibility = input_visibility + user_1.workouts_visibility = input_visibility + 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", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert "created" in data["status"] + assert len(data["data"]["workouts"]) == 1 + assert ( + data["data"]["workouts"][0]["map_visibility"] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data["data"]["workouts"][0]["analysis_visibility"] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data["data"]["workouts"][0]["workout_visibility"] + == user_1.workouts_visibility.value + ) + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_workout_is_created_with_provided_privacy_parameters( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_workout_visibility: VisibilityLevel, + ) -> None: + 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", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=input_workout_visibility.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert "created" in data["status"] + assert len(data["data"]["workouts"]) == 1 + assert ( + data["data"]["workouts"][0]["map_visibility"] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data["data"]["workouts"][0]["analysis_visibility"] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data["data"]["workouts"][0]["workout_visibility"] + == input_workout_visibility.value + ) + + def test_it_returns_400_when_privacy_level_is_invalid( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + """ + 'FOLLOWERS_AND_REMOTE' is not available on un-federated instances + """ + 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", + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date="2018-05-15 14:05", + distance=10, + workout_visibility=VisibilityLevel.FOLLOWERS_AND_REMOTE.value, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + def test_expected_scope_is_workouts_write( self, app: "Flask", user_1: "User" ) -> None: diff --git a/fittrackee/tests/workouts/test_workouts_model.py b/fittrackee/tests/workouts/test_workouts_model.py index 41fe7157a..43bddedf9 100644 --- a/fittrackee/tests/workouts/test_workouts_model.py +++ b/fittrackee/tests/workouts/test_workouts_model.py @@ -13,6 +13,7 @@ from fittrackee import db from fittrackee.constants import ElevationDataSource, PaceSpeedDisplay from fittrackee.equipments.models import Equipment +from fittrackee.federation.exceptions import FederationDisabledException from fittrackee.files import get_absolute_file_path from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.tests.fixtures.fixtures_workouts import update_workout @@ -1451,6 +1452,30 @@ def test_it_returns_elevation_data_source_when_sport_without_elevation( == ElevationDataSource.FILE ) + def test_it_gets_workout_ap_id( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + assert workout_cycling_user_1.get_ap_id() == ( + f"{user_1.actor.activitypub_id}/" + f"workouts/{workout_cycling_user_1.short_id}" + ) + + def test_it_gets_workout_remote_url( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + assert workout_cycling_user_1.get_remote_url() == ( + f"https://{user_1.actor.domain.name}/" + f"workouts/{workout_cycling_user_1.short_id}" + ) + class TestWorkoutModelAsFollower(CommentMixin, WorkoutModelTestCase): def test_it_raises_exception_when_workout_visibility_is_private( @@ -4046,3 +4071,15 @@ def test_it_stores_geometry_as_linestring( assert to_shape(workout_cycling_user_1_segment.geom) == LineString( segments_coordinates ) + + +class TestWorkoutModelGetActivity: + def test_it_raises_error_if_federation_is_disabled( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + with pytest.raises(FederationDisabledException): + workout_cycling_user_1.get_activities(activity_type="Create") diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 4a1fe0d3b..540957619 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -209,6 +209,9 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]: ) db.session.add(notification) db.session.commit() + # create actor even if federation is disabled + new_user.create_actor() + send_account_confirmation_email(new_user) return {"status": "success"}, 200 @@ -342,6 +345,7 @@ def get_authenticated_user_profile( "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, + "is_remote": false, "language": "en", "last_name": null, "location": null, @@ -487,6 +491,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, + "is_remote": false, "language": "en", "last_name": null, "location": null, @@ -939,6 +944,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, + "is_remote": false, "language": "en", "last_name": null, "location": null, @@ -1123,6 +1129,12 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: ) calories_visibility = post_data.get("calories_visibility") + if not current_app.config["FEDERATION_ENABLED"] and ( + map_visibility == VisibilityLevel.FOLLOWERS_AND_REMOTE.value + or workouts_visibility == VisibilityLevel.FOLLOWERS_AND_REMOTE.value + ): + return InvalidPayloadErrorResponse() + try: auth_user.date_format = date_format auth_user.display_ascent = display_ascent diff --git a/fittrackee/users/constants.py b/fittrackee/users/constants.py index 125beec34..63ac3644e 100644 --- a/fittrackee/users/constants.py +++ b/fittrackee/users/constants.py @@ -19,6 +19,7 @@ + MODERATOR_NOTIFICATION_TYPES + [ "comment_like", + "comment_reply", "comment_suspension", "comment_unsuspension", "follow", diff --git a/fittrackee/users/exceptions.py b/fittrackee/users/exceptions.py index 0c189159e..8f76bfc91 100644 --- a/fittrackee/users/exceptions.py +++ b/fittrackee/users/exceptions.py @@ -31,6 +31,10 @@ class MissingReportIdException(Exception): pass +class InvalidUserException(Exception): + pass + + class NotExistingFollowRequestError(Exception): pass diff --git a/fittrackee/users/export_data.py b/fittrackee/users/export_data.py index d2cf6f3cf..bc09531e3 100644 --- a/fittrackee/users/export_data.py +++ b/fittrackee/users/export_data.py @@ -63,6 +63,11 @@ def get_user_comments_data(self) -> List[Dict]: "created_at": comment.created_at, "id": comment.short_id, "modification_date": comment.modification_date, + "reply_to": ( + comment.parent_comment.short_id + if comment.reply_to + else None + ), "text": comment.text, "text_visibility": comment.text_visibility.value, "workout_id": ( diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py index d048ef960..abfa8ede2 100644 --- a/fittrackee/users/follow_requests.py +++ b/fittrackee/users/follow_requests.py @@ -1,9 +1,14 @@ from typing import Dict, Union from flask import Blueprint, request -from sqlalchemy import asc, desc, func +from sqlalchemy import asc, desc from fittrackee import appLog +from fittrackee.federation.exceptions import ( + ActorNotFoundException, + DomainNotFoundException, +) +from fittrackee.federation.utils.user import get_user_from_username from fittrackee.oauth2.server import require_auth from fittrackee.responses import ( HttpResponse, @@ -15,6 +20,7 @@ from .exceptions import ( FollowRequestAlreadyProcessedError, NotExistingFollowRequestError, + UserNotFoundException, ) from .models import FollowRequest, User @@ -46,7 +52,9 @@ def get_follow_requests(auth_user: User) -> Dict: GET /api/follow-requests?page=1&order=desc HTTP/1.1 - **Example response**: + **Example responses**: + + - if federation is disabled .. sourcecode:: http @@ -145,13 +153,14 @@ def get_follow_requests(auth_user: User) -> Dict: def process_follow_request( auth_user: User, user_name: str, action: str ) -> Union[Dict, HttpResponse]: - from_user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not from_user: - appLog.error( - f"Error when accepting follow request: {user_name} does not exist" - ) + 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() try: @@ -188,10 +197,18 @@ def accept_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 @@ -239,10 +256,18 @@ def reject_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 diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index e93379b7a..1210d21d7 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -1,6 +1,6 @@ import os from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from uuid import UUID, uuid4 import jwt @@ -24,6 +24,11 @@ from fittrackee.constants import ElevationDataSource, PaceSpeedDisplay from fittrackee.database import TZDateTime from fittrackee.dates import aware_utc_now +from fittrackee.federation.decorators import federation_required +from fittrackee.federation.enums import ActivityType +from fittrackee.federation.models import Actor, Domain +from fittrackee.federation.objects.follow_request import FollowRequestObject +from fittrackee.federation.tasks.inbox import send_to_remote_inbox from fittrackee.files import get_absolute_file_path from fittrackee.utils import encode_uuid from fittrackee.visibility_levels import VisibilityLevel @@ -123,6 +128,24 @@ def serialize(self) -> Dict: "to_user": self.to_user.serialize(), } + def _get_activity_type(self, undo: bool) -> ActivityType: + if self.updated_at is None: + return ActivityType.FOLLOW + if undo: + return ActivityType.UNDO + if self.is_approved: + return ActivityType.ACCEPT + return ActivityType.REJECT + + @federation_required + def get_activity(self, undo: bool = False) -> Dict: + follow_request_object = FollowRequestObject( + from_actor=self.from_user.actor, + to_actor=self.to_user.actor, + activity_type=self._get_activity_type(undo), + ) + return follow_request_object.get_activity() + @listens_for(FollowRequest, "after_insert") def on_follow_request_insert( @@ -270,14 +293,24 @@ def __init__( class User(BaseModel): __tablename__ = "users" + __table_args__ = ( + db.UniqueConstraint( + "username", "actor_id", name="username_actor_id_unique" + ), + ) + actor_id: Mapped[Optional[int]] = mapped_column( + db.ForeignKey("actors.id"), unique=True, nullable=True + ) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - username: Mapped[str] = mapped_column( - db.String(255), unique=True, nullable=False + username: Mapped[str] = mapped_column(db.String(255), nullable=False) + # Note: Null values are not considered equal + # source: https://www.postgresql.org/docs/current/indexes-unique.html + email: Mapped[Optional[str]] = mapped_column( + db.String(255), unique=True, nullable=True ) - email: Mapped[str] = mapped_column( - db.String(255), unique=True, nullable=False + password: Mapped[Optional[str]] = mapped_column( + db.String(255), nullable=True ) - password: Mapped[str] = mapped_column(db.String(255), nullable=False) created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False) first_name: Mapped[Optional[str]] = mapped_column( db.String(80), nullable=True @@ -387,6 +420,9 @@ class User(BaseModel): server_default="PRIVATE", nullable=False, ) + is_remote: Mapped[bool] = mapped_column( + db.Boolean, default=False, nullable=False + ) workouts: Mapped[List["Workout"]] = relationship( "Workout", lazy=True, back_populates="user" @@ -457,6 +493,7 @@ class User(BaseModel): lazy="dynamic", viewonly=True, ) + actor: Mapped["Actor"] = relationship(Actor, back_populates="user") def __repr__(self) -> str: return f"" @@ -464,18 +501,24 @@ def __repr__(self) -> str: def __init__( self, username: str, - email: str, - password: str, + email: Optional[str], + password: Optional[str], created_at: Optional[datetime] = None, + is_remote: bool = False, ) -> 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 = ( datetime.now(timezone.utc) if created_at is None else created_at ) + self.is_remote = is_remote @staticmethod def encode_auth_token(user_id: int) -> str: @@ -567,6 +610,24 @@ def send_follow_request_to(self, target: "User") -> FollowRequest: follow_request.updated_at = datetime.now(timezone.utc) db.session.commit() + if current_app.config["FEDERATION_ENABLED"]: + # send Follow activity to remote followed user + if target.actor.is_remote: + send_to_remote_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_remote_inbox.send( + sender_id=target.actor.id, + activity=follow_request.get_activity(), + recipients=[self.actor.inbox_url], + ) + return follow_request def unfollows(self, target: "User") -> None: @@ -576,6 +637,17 @@ def unfollows(self, target: "User") -> None: if not existing_follow_request: raise NotExistingFollowRequestError() + if current_app.config["FEDERATION_ENABLED"]: + undo_activity = existing_follow_request.get_activity(undo=True) + + # send Undo activity to remote followed user + if target.actor.is_remote: + send_to_remote_inbox.send( + sender_id=self.actor.id, + activity=undo_activity, + recipients=[target.actor.inbox_url], + ) + db.session.delete(existing_follow_request) db.session.commit() return None @@ -603,6 +675,14 @@ def _processes_follow_request_from( follow_request.is_approved = approved follow_request.updated_at = datetime.now(timezone.utc) db.session.commit() + + if current_app.config["FEDERATION_ENABLED"] and user.actor.is_remote: + send_to_remote_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: @@ -637,19 +717,80 @@ def follows(self, user: "User") -> str: ).first() return self.follow_request_status(follow_request) - def get_following_user_ids(self) -> List: - return [following.id for following in self.following] + def get_following_user_ids(self) -> Tuple[List, List]: + local_following_ids = [] + remote_following_ids = [] + for following in self.following: + if following.is_remote is True: + remote_following_ids.append(following.id) + else: + local_following_ids.append(following.id) + return local_following_ids, remote_following_ids def get_followers_user_ids(self) -> List: return [followers.id for followers in self.followers] + @federation_required + def get_followers_shared_inboxes(self) -> Dict[str, List[str]]: + """ + returns a dict with 2 distinct lists: + - followers for remote FitTrackee instances + - followers for remote non-FitTrackee instances + """ + fittrackee_shared_inboxes = set() + other_shared_inboxes = set() + for follower in self.followers.all(): + if follower.actor.is_remote: + if follower.actor.domain.software_name == "fittrackee": + fittrackee_shared_inboxes.add( + follower.actor.shared_inbox_url + ) + else: + other_shared_inboxes.add(follower.actor.shared_inbox_url) + return { + "fittrackee": list(fittrackee_shared_inboxes), + "others": list(other_shared_inboxes), + } + + def get_followers_shared_inboxes_as_list(self) -> List[str]: + """ + returns all remote followers regardless instances application + (FitTrackee or non-FitTrackee) + """ + return list( + set( + follower.actor.shared_inbox_url + for follower in self.followers.all() + if follower.actor.is_remote + ) + ) + def get_user_url(self) -> str: """Return user url on user interface""" return f"{current_app.config['UI_URL']}/users/{self.username}" - def linkify_mention(self) -> str: + def create_actor(self) -> None: + app_domain = Domain.query.filter_by( + name=current_app.config["AP_DOMAIN"] + ).one() + actor = Actor( + preferred_username=self.username, domain_id=app_domain.id + ) + db.session.add(actor) + db.session.flush() + self.actor_id = actor.id + db.session.commit() + + @property + def fullname(self) -> Optional[str]: + return self.actor.fullname + + def linkify_mention(self, with_domain: bool) -> str: + mention = f"@{self.username}" + if with_domain: + mention += f"@{self.actor.domain.name}" return USER_LINK_TEMPLATE.format( - profile_url=self.get_user_url(), username=f"@{self.username}" + profile_url=self.actor.profile_url, username=mention ) def blocks_user(self, user: "User") -> None: @@ -867,14 +1008,22 @@ def serialize( serialized_user: Dict = { "created_at": self.created_at, - "followers": self.followers.count(), - "following": self.following.count(), + "is_remote": self.is_remote, "nb_workouts": self.workouts_count, "picture": self.picture is not None, "role": UserRole(self.role).name.lower(), "suspended_at": self.suspended_at, "username": self.username, } + if self.is_remote: + serialized_user["fullname"] = f"@{self.fullname}" + serialized_user["followers"] = self.actor.stats.followers + serialized_user["following"] = self.actor.stats.following + serialized_user["profile_link"] = self.actor.profile_url + else: + serialized_user["followers"] = self.followers.count() + serialized_user["following"] = self.following.count() + if is_auth_user(role) or has_moderator_rights(role): serialized_user["is_active"] = self.is_active serialized_user["email"] = self.email @@ -1416,6 +1565,7 @@ def serialize(self) -> Dict: if self.event_type in [ "comment_like", + "comment_reply", "mention", "workout_comment", ]: diff --git a/fittrackee/users/notifications.py b/fittrackee/users/notifications.py index 8ce3a04b6..d3368ab7b 100644 --- a/fittrackee/users/notifications.py +++ b/fittrackee/users/notifications.py @@ -130,40 +130,62 @@ def get_auth_user_notifications(auth_user: User) -> Dict: blocked_users = auth_user.get_blocked_user_ids() blocked_by_users = auth_user.get_blocked_by_user_ids() - following_ids = auth_user.get_following_user_ids() - + ( + local_following_ids, + remote_following_ids, + ) = auth_user.get_following_user_ids() filters = [ Notification.to_user_id == auth_user.id, Notification.from_user_id.not_in(blocked_users), - or_( - ( - and_( - ( - or_( - Notification.event_type != "workout_comment", - and_( - Notification.event_type == "workout_comment", - Notification.from_user_id.not_in( - blocked_by_users + ( + or_( + ( + and_( + ( + or_( + Notification.event_type.not_in( + ["workout_comment", "comment_reply"] ), - or_( - Comment.text_visibility - == VisibilityLevel.PUBLIC, - and_( + and_( + Notification.event_type.in_( + [ + "workout_comment", + "comment_reply", + ] + ), + Notification.from_user_id.not_in( + blocked_by_users + ), + or_( Comment.text_visibility - == VisibilityLevel.FOLLOWERS, - Notification.from_user_id.in_( - following_ids + == VisibilityLevel.PUBLIC, + and_( + Comment.text_visibility.in_( + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ] + ), + Notification.from_user_id.in_( + local_following_ids + ), + ), + and_( + Comment.text_visibility + == VisibilityLevel.FOLLOWERS_AND_REMOTE, # noqa + Notification.from_user_id.in_( + remote_following_ids + ), ), ), ), - ), - ) - ), - User.suspended_at == None, # noqa - ) - ), - (Notification.event_type.in_(["report", "suspension_appeal"])), + ) + ), + User.suspended_at == None, # noqa + ) + ), + (Notification.event_type.in_(["report", "suspension_appeal"])), + ) ), ] if marked_as_read is not None: @@ -329,6 +351,10 @@ def get_status(auth_user: User) -> Dict: :statuscode 403: - ``you do not have permissions, your account is suspended`` """ + ( + local_following_ids, + remote_following_ids, + ) = auth_user.get_following_user_ids() unread_notifications = ( Notification.query.join( User, @@ -347,22 +373,38 @@ def get_status(auth_user: User) -> Dict: and_( ( or_( - Notification.event_type - != "workout_comment", + Notification.event_type.not_in( + ["workout_comment", "comment_reply"] + ), and_( - Notification.event_type - == "workout_comment", + Notification.event_type.in_( + [ + "workout_comment", + "comment_reply", + ] + ), Notification.from_user_id.not_in( auth_user.get_blocked_by_user_ids() ), or_( Comment.text_visibility == VisibilityLevel.PUBLIC, + and_( + Comment.text_visibility.in_( + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ] + ), + Notification.from_user_id.in_( + local_following_ids + ), + ), and_( Comment.text_visibility - == VisibilityLevel.FOLLOWERS, + == VisibilityLevel.FOLLOWERS_AND_REMOTE, # noqa Notification.from_user_id.in_( - auth_user.get_following_user_ids() + remote_following_ids ), ), ), diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index d85b756ec..263eb2579 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -1,4 +1,5 @@ import os +import re import shutil from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union @@ -9,6 +10,12 @@ from fittrackee.dates import get_readable_duration from fittrackee.emails.tasks import send_email from fittrackee.equipments.models import Equipment +from fittrackee.federation.decorators import federation_required_for_route +from fittrackee.federation.models import Domain +from fittrackee.federation.utils.user import ( + FULL_NAME_REGEX, + get_user_from_username, +) from fittrackee.files import get_absolute_file_path from fittrackee.oauth2.server import require_auth from fittrackee.reports.models import ReportAction @@ -27,6 +34,7 @@ BlockUserException, FollowRequestAlreadyRejectedError, InvalidEmailException, + InvalidUserException, InvalidUserRole, NotExistingFollowRequestError, OwnerException, @@ -71,10 +79,31 @@ def _get_value_depending_on_user_rights( return value -def get_users_list(auth_user: User) -> Dict: +def get_users_list(auth_user: User, remote: bool = False) -> Dict: params = request.args.copy() query = params.get("q") + if remote and query and re.match(FULL_NAME_REGEX, query): + try: + user = get_user_from_username(query, with_action="creation") + except Exception as e: + appLog.error(f"Error when searching user '{query}': {e}") + return EMPTY_USERS_RESPONSE + if user: + if not user.is_active and not auth_user.has_admin_rights: + return EMPTY_USERS_RESPONSE + return { + "status": "success", + "data": {"users": [user.serialize(current_user=auth_user)]}, + "pagination": { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + }, + } + page = int(params.get("page", 1)) per_page = int(params.get("per_page", USERS_PER_PAGE)) if per_page > 50: @@ -100,21 +129,26 @@ def get_users_list(auth_user: User) -> Dict: ) with_following = params.get("with_following", "false").lower() following_user_ids = ( - auth_user.get_following_user_ids() if with_following == "true" else [] + auth_user.get_following_user_ids() + if with_following == "true" + else ([], []) ) - filters: List[Union["ColumnElement", "BinaryExpression"]] = [] + filters: List[Union["ColumnElement", "BinaryExpression"]] = [ + User.is_remote == remote, + ] if query: filters.append(User.username.ilike("%" + query + "%")) if with_inactive != "true": filters.append(User.is_active == True) # noqa - if with_hidden_users != "true": + if with_hidden_users != "true" and not remote: filters.append( ( or_( User.hide_profile_in_users_directory == False, # noqa + # TODO: handle remote users? and_( - User.id.in_(following_user_ids), + User.id.in_(following_user_ids[0]), User.hide_profile_in_users_directory == True, # noqa ), ) @@ -147,7 +181,7 @@ def get_users_list(auth_user: User) -> Dict: @require_auth(scopes=["users:read"]) 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. It returns user preferences only for authenticated user. @@ -191,6 +225,7 @@ def get_users(auth_user: User) -> Dict: "following": 0, "follows": "false", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "private", @@ -265,6 +300,7 @@ def get_users(auth_user: User) -> Dict: "following": 0, "follows": "false", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "private", @@ -316,6 +352,98 @@ def get_users(auth_user: User) -> Dict: return get_users_list(auth_user) +@users_blueprint.route("/users/remote", methods=["GET"]) +@federation_required_for_route +@require_auth(scopes=["users:read"]) +def get_remote_users( + auth_user: User, + app_domain: Domain, +) -> Dict: + """ + Get all remote existing users (only if federation is enabled). + If a full account is provided in query, if creates remote user if it + doesn't exist. + + **Scope**: ``users:read`` + + **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, + "follows": "false", + "fullname": "@sam@example.com", + "is_followed_by": "false", + "is_remote": true, + "last_name": null, + "location": null, + "map_visibility": "private", + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "profile_link": "https://example.com/@sam" + "records": [], + "sports_list": [], + "total_distance": 0, + "total_duration": "0:00:00", + "username": "sam", + "workouts_visibility": "private" + } + ] + }, + "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 username or account + :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"]) @require_auth(scopes=["users:read"], optional_auth_user=True) def get_single_user( @@ -323,6 +451,7 @@ def get_single_user( ) -> Union[Dict, HttpResponse]: """ Get single user details. + If username is a remote user account, it returns remote user if exists. If a user is authenticated, it returns relationships. If authenticated user has admin rights, user email is returned. @@ -359,6 +488,7 @@ def get_single_user( "following": 0, "follows": "false", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "private", @@ -446,6 +576,7 @@ def get_single_user( "following": 0, "follows": "false", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "private", @@ -473,9 +604,7 @@ def get_single_user( - ``user does not exist`` """ try: - user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() + user = get_user_from_username(user_name, with_action="refresh") if user: if ( not auth_user or not auth_user.has_admin_rights @@ -522,11 +651,7 @@ def get_picture(user_name: str) -> Any: """ try: - user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not user: - return UserNotFoundErrorResponse() + user = get_user_from_username(user_name) if user.picture is not None: picture_path = get_absolute_file_path(user.picture) return send_file(picture_path) @@ -581,6 +706,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: "following": 0, "follows": "false", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "private", @@ -767,6 +893,8 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: } except UserNotFoundException: return UserNotFoundErrorResponse() + except InvalidUserException: + return InvalidPayloadErrorResponse() except (InvalidEmailException, InvalidUserRole, OwnerException) as e: return InvalidPayloadErrorResponse(str(e)) except (TypeError, exc.StatementError) as e: @@ -823,14 +951,17 @@ def delete_user( """ try: - user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not user: + try: + user = get_user_from_username(user_name) + except UserNotFoundException: return UserNotFoundErrorResponse() if user.id != auth_user.id and user.role == UserRole.OWNER.value: return ForbiddenErrorResponse("you can not delete owner account") + if user.is_remote: + # TODO: handle properly remote user deletion + return InvalidPayloadErrorResponse() + if user.id != auth_user.id and not auth_user.has_admin_rights: return ForbiddenErrorResponse() if ( @@ -859,9 +990,10 @@ 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) + picture_path = get_absolute_file_path(user_picture) if os.path.isfile(picture_path): os.remove(picture_path) shutil.rmtree( @@ -891,16 +1023,27 @@ def delete_user( 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. **Scope**: ``follow:write`` **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 @@ -935,13 +1078,10 @@ def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: "message": f"Follow request to user '{user_name}' is sent.", } - target_user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not target_user: - appLog.error( - f"Error when following a user: user {user_name} not found" - ) + try: + target_user = get_user_from_username(user_name) + except UserNotFoundException as e: + appLog.error(f"Error when following a user: {e}") return UserNotFoundErrorResponse() if auth_user.is_blocked_by(target_user): @@ -961,16 +1101,27 @@ def unfollow_user( ) -> Union[Dict, HttpResponse]: """ Unfollow a user. + If federation is enabled, it sends a Undo activity to the remote instance + if the targeted user is a remote user. **Scope**: ``follow:write`` **Example request**: + - unfollow local user + .. sourcecode:: http POST /api/users/john_doe/unfollow HTTP/1.1 Content-Type: application/json + - unfollow remote user + + .. sourcecode:: http + + POST /api/users/sam@remote-instance.net/unfollow HTTP/1.1 + Content-Type: application/json + **Example response**: .. sourcecode:: http @@ -1006,13 +1157,10 @@ def unfollow_user( "message": f"Undo for a follow request to user '{user_name}' is sent.", } - target_user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not target_user: - appLog.error( - f"Error when following a user: user {user_name} not found" - ) + try: + target_user = get_user_from_username(user_name) + except UserNotFoundException as e: + appLog.error(f"Error when following a user: {e}") return UserNotFoundErrorResponse() try: @@ -1031,10 +1179,9 @@ def get_user_relationships( except ValueError: page = 1 - user = User.query.filter( - func.lower(User.username) == func.lower(user_name), - ).first() - if not user: + try: + user = get_user_from_username(user_name) + except UserNotFoundException: return UserNotFoundErrorResponse() relations_object = ( @@ -1111,6 +1258,7 @@ def get_followers( "following": 1, "follows": "true", "is_followed_by": "false", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "followers_only", @@ -1205,6 +1353,7 @@ def get_following( "following": 1, "follows": "false", "is_followed_by": "true", + "is_remote": false, "last_name": null, "location": null, "map_visibility": "followers_only", diff --git a/fittrackee/users/users_service.py b/fittrackee/users/users_service.py index 0fa50c9b0..dca2c553c 100644 --- a/fittrackee/users/users_service.py +++ b/fittrackee/users/users_service.py @@ -5,6 +5,7 @@ from sqlalchemy import func from fittrackee import db +from fittrackee.federation.utils.user import get_user_from_username from fittrackee.reports.models import ReportAction from fittrackee.users.constants import ( ADMINISTRATOR_NOTIFICATION_TYPES, @@ -14,6 +15,7 @@ ) from fittrackee.users.exceptions import ( InvalidEmailException, + InvalidUserException, InvalidUserRole, MissingAdminIdException, MissingReportIdException, @@ -22,7 +24,6 @@ UserAlreadySuspendedException, UserControlsException, UserCreationException, - UserNotFoundException, ) from fittrackee.users.models import ( Notification, @@ -38,9 +39,9 @@ def __init__(self, username: str, moderator_id: Optional[int] = None): self.moderator_id = moderator_id def _get_user(self) -> User: - user = User.query.filter_by(username=self.username).first() - if not user: - raise UserNotFoundException() + user = get_user_from_username(self.username) + if user.is_remote: + raise InvalidUserException return user @staticmethod @@ -173,7 +174,8 @@ def create_user( raise UserControlsException(ret) user = User.query.filter( - func.lower(User.username) == func.lower(self.username) + User.is_remote == False, # noqa + func.lower(User.username) == func.lower(self.username), ).first() if user: raise UserCreationException( @@ -183,7 +185,8 @@ def create_user( # if a user exists with same email address, no error is returned # since a user has to confirm his email to activate his account user = User.query.filter( - func.lower(User.email) == func.lower(email) + User.is_remote == False, # noqa + func.lower(User.email) == func.lower(email), ).first() if user: if check_email: diff --git a/fittrackee/visibility_levels.py b/fittrackee/visibility_levels.py index 5848e7e2c..fb6ede14b 100644 --- a/fittrackee/visibility_levels.py +++ b/fittrackee/visibility_levels.py @@ -10,7 +10,8 @@ class VisibilityLevel(str, Enum): # to make enum serializable PUBLIC = "public" - FOLLOWERS = "followers_only" # only followers + FOLLOWERS_AND_REMOTE = "followers_and_remote_only" + FOLLOWERS = "followers_only" # only local followers in federated instances PRIVATE = "private" # in case of comments, for mentioned users only @@ -19,9 +20,17 @@ def get_calculated_visibility( ) -> VisibilityLevel: # - workout visibility overrides analysis visibility, when stricter, # - analysis visibility overrides map visibility, when stricter. - if parent_visibility == VisibilityLevel.PRIVATE or ( - parent_visibility == VisibilityLevel.FOLLOWERS - and visibility == VisibilityLevel.PUBLIC + if ( + parent_visibility == VisibilityLevel.PRIVATE + or ( + parent_visibility == VisibilityLevel.FOLLOWERS + and visibility + in [VisibilityLevel.FOLLOWERS_AND_REMOTE, VisibilityLevel.PUBLIC] + ) + or ( + parent_visibility == VisibilityLevel.FOLLOWERS_AND_REMOTE + and visibility == VisibilityLevel.PUBLIC + ) ): return parent_visibility return visibility @@ -71,6 +80,15 @@ def can_view( if ( target_object.__getattribute__(visibility) == VisibilityLevel.FOLLOWERS + and user.is_remote is False + and user in owner.followers.all() + and target_object.user.is_remote is False + ): + return True + + if ( + target_object.__getattribute__(visibility) + == VisibilityLevel.FOLLOWERS_AND_REMOTE and user in owner.followers.all() ): return True diff --git a/fittrackee/workouts/exceptions.py b/fittrackee/workouts/exceptions.py index f25480022..487c9400b 100644 --- a/fittrackee/workouts/exceptions.py +++ b/fittrackee/workouts/exceptions.py @@ -28,6 +28,10 @@ class InvalidGPXException(GenericException): pass +class SportNotFoundException(Exception): + pass + + class WorkoutExceedingValueException(GenericException): def __init__(self, detail: str) -> None: super().__init__( diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index 116201a62..d6673b6da 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -1,7 +1,7 @@ import os from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from uuid import UUID, uuid4 from geoalchemy2 import Geometry, WKBElement @@ -21,6 +21,10 @@ from fittrackee.database import PSQL_INTEGER_LIMIT, TZDateTime from fittrackee.dates import aware_utc_now from fittrackee.equipments.models import WorkoutEquipment +from fittrackee.federation.decorators import federation_required +from fittrackee.federation.objects.like import LikeObject +from fittrackee.federation.objects.tombstone import TombstoneObject +from fittrackee.federation.objects.workout import WorkoutObject from fittrackee.files import get_absolute_file_path from fittrackee.utils import encode_uuid from fittrackee.visibility_levels import ( @@ -384,6 +388,8 @@ class Workout(BaseModel): nullable=False, ) calories: Mapped[Optional[int]] = mapped_column(nullable=True) # kcal + ap_id: Mapped[Optional[str]] = mapped_column(db.Text(), nullable=True) + remote_url: Mapped[Optional[str]] = mapped_column(db.Text(), nullable=True) user: Mapped["User"] = relationship( "User", lazy="select", single_parent=True @@ -447,6 +453,14 @@ def short_id(self) -> str: def store_start_point_geometry(self, coordinates: List[float]) -> None: self.start_point_geom = str(Point(coordinates)) # type: ignore + def get_ap_id(self) -> str: + return f"{self.user.actor.activitypub_id}/workouts/{self.short_id}" + + def get_remote_url(self) -> str: + return ( + f"https://{self.user.actor.domain.name}/workouts/{self.short_id}" + ) + @property def calculated_analysis_visibility(self) -> VisibilityLevel: return get_calculated_visibility( @@ -864,6 +878,8 @@ def serialize( if self.bounds and can_see_map_data and additional_data else [] ) + if self.user.is_remote: + workout["remote_url"] = self.remote_url return workout @classmethod @@ -895,6 +911,19 @@ def get_user_workout_records(cls, user_id: int, sport_id: int) -> Dict: ) return records + @federation_required + def get_activities(self, activity_type: str) -> Tuple[Dict, Dict]: + if activity_type in ["Create", "Update"]: + workout_object = WorkoutObject(self, activity_type=activity_type) + return workout_object.get_activity(), workout_object.get_activity( + is_note=True + ) + # Delete activity + tombstone_object = TombstoneObject(self) + delete_activity = tombstone_object.get_activity() + # delete activities for workout and note are the same + return delete_activity, delete_activity + @listens_for(Workout, "after_insert") def on_workout_insert( @@ -902,7 +931,11 @@ def on_workout_insert( ) -> None: @listens_for(db.Session, "after_flush", once=True) def receive_after_flush(session: Session, context: Any) -> None: - update_records(workout.user_id, workout.sport_id, connection, session) + # For now only create records for local workouts + if not workout.remote_url: + update_records( + workout.user_id, workout.sport_id, connection, session + ) @listens_for(Workout, "after_update") @@ -1294,6 +1327,14 @@ def __init__( datetime.now(timezone.utc) if created_at is None else created_at ) + def get_activity(self, is_undo: bool = False) -> Dict: + return LikeObject( + actor_ap_id=self.user.actor.activitypub_id, + target_object_ap_id=self.workout.ap_id, + like_id=self.id, + is_undo=is_undo, + ).get_activity() + @listens_for(WorkoutLike, "after_insert") def on_workout_like_insert( diff --git a/fittrackee/workouts/services/workout_creation_service.py b/fittrackee/workouts/services/workout_creation_service.py index 2efd0ac2b..d90473f68 100644 --- a/fittrackee/workouts/services/workout_creation_service.py +++ b/fittrackee/workouts/services/workout_creation_service.py @@ -35,6 +35,18 @@ class WorkoutData: notes: Optional[str] = None title: Optional[str] = None workout_visibility: Optional[VisibilityLevel] = None + # TODO: to refacto + # remote content + id: Optional[str] = None + type: Optional[str] = None + published: Optional[str] = None + url: Optional[str] = None + attributedTo: Optional[str] = None + to: Optional[str] = None + cc: Optional[str] = None + ave_speed: Optional[int] = None + max_speed: Optional[int] = None + moving: Optional[int] = None class WorkoutCreationService(CheckWorkoutMixin, BaseWorkoutService): @@ -142,5 +154,9 @@ def process(self) -> Tuple[List["Workout"], Dict]: else self.auth_user.workouts_visibility ) + # for remote workout + new_workout.ap_id = self.workout_data.id + new_workout.remote_url = self.workout_data.url + db.session.flush() return [new_workout], {} diff --git a/fittrackee/workouts/services/workout_update_service.py b/fittrackee/workouts/services/workout_update_service.py index 916891642..3d3e0c8dc 100644 --- a/fittrackee/workouts/services/workout_update_service.py +++ b/fittrackee/workouts/services/workout_update_service.py @@ -216,7 +216,7 @@ def _update_workout_without_file(self) -> None: self._check_workout(self.workout) - def update(self) -> None: + def update(self) -> "Workout": if "sport_id" in self.workout_data: self.workout.sport_id = self.workout_data["sport_id"] if self.equipments_list is not None: @@ -257,6 +257,7 @@ def update(self) -> None: visibility=map_visibility, parent_visibility=self.workout.analysis_visibility, ) - return + return self.workout self._update_workout_without_file() + return self.workout diff --git a/fittrackee/workouts/stats.py b/fittrackee/workouts/stats.py index e53684139..9f539e238 100644 --- a/fittrackee/workouts/stats.py +++ b/fittrackee/workouts/stats.py @@ -650,6 +650,7 @@ def get_workouts_by_sport( def get_application_stats(auth_user: User) -> Dict: """ Get all application statistics. + Users count is local users count when federation is enabled. **Scope**: ``workouts:read`` @@ -692,7 +693,10 @@ def get_application_stats(auth_user: User) -> Dict: """ total_workouts = Workout.query.filter().count() - nb_users = User.query.filter(User.is_active == True).count() # noqa + nb_users = User.query.filter( + User.is_remote == False, # noqa + User.is_active == True, # noqa + ).count() nb_sports = ( db.session.query(func.count(Workout.sport_id)) .group_by(Workout.sport_id) diff --git a/fittrackee/workouts/timeline.py b/fittrackee/workouts/timeline.py index 0691ce010..7cb55e3a4 100644 --- a/fittrackee/workouts/timeline.py +++ b/fittrackee/workouts/timeline.py @@ -171,7 +171,10 @@ def get_user_timeline(auth_user: User) -> Union[Dict, HttpResponse]: try: params = request.args.copy() page = int(params.get("page", 1)) - following_ids = auth_user.get_following_user_ids() + ( + local_following_ids, + remote_following_ids, + ) = auth_user.get_following_user_ids() blocked_users = auth_user.get_blocked_user_ids() blocked_by_users = auth_user.get_blocked_by_user_ids() workouts_pagination = ( @@ -187,16 +190,34 @@ def get_user_timeline(auth_user: User) -> Union[Dict, HttpResponse]: # and user is not blocked and_( Workout.suspended_at == None, # noqa - and_( - Workout.user_id.in_(following_ids), - Workout.user_id.not_in( - blocked_users + blocked_by_users + Workout.user_id.not_in( + blocked_users + blocked_by_users + ), + or_( + and_( + Workout.user_id.in_(local_following_ids), + Workout.user_id.not_in( + blocked_users + blocked_by_users + ), + Workout.workout_visibility.in_( + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ] + ), ), - Workout.workout_visibility.in_( - [ - VisibilityLevel.FOLLOWERS, - VisibilityLevel.PUBLIC, - ] + and_( + Workout.user_id.in_(remote_following_ids), + Workout.user_id.not_in( + blocked_users + blocked_by_users + ), + Workout.workout_visibility.in_( + [ + VisibilityLevel.FOLLOWERS_AND_REMOTE, + VisibilityLevel.PUBLIC, + ] + ), ), ), ), diff --git a/fittrackee/workouts/workouts.py b/fittrackee/workouts/workouts.py index 0775798a6..d1a1b699d 100644 --- a/fittrackee/workouts/workouts.py +++ b/fittrackee/workouts/workouts.py @@ -1,4 +1,5 @@ import json +from copy import copy from datetime import timedelta from decimal import Decimal from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union @@ -25,6 +26,8 @@ InvalidEquipmentsException, ) from fittrackee.equipments.models import Equipment, WorkoutEquipment +from fittrackee.federation.tasks.inbox import send_to_remote_inbox +from fittrackee.federation.utils import sending_activities_allowed from fittrackee.oauth2.server import require_auth from fittrackee.reports.models import ReportActionAppeal from fittrackee.responses import ( @@ -90,7 +93,9 @@ get_speed, get_sport_displayed_data, ) -from .utils.workouts import get_datetime_from_request_args +from .utils.workouts import ( + get_datetime_from_request_args, +) if TYPE_CHECKING: from flask_sqlalchemy.query import Query @@ -432,6 +437,31 @@ def get_user_workouts_query( return workouts_query, page, per_page +def handle_workout_activities(workout: Workout, activity_type: str) -> None: + actor = workout.user.actor + if activity_type == "Create": + workout.ap_id = workout.get_ap_id() + workout.remote_url = workout.get_remote_url() + db.session.commit() + workout_activity, note_activity = workout.get_activities( + activity_type=activity_type + ) + recipients = workout.user.get_followers_shared_inboxes() + + if recipients["fittrackee"]: + send_to_remote_inbox.send( + sender_id=actor.id, + activity=workout_activity, + recipients=recipients["fittrackee"], + ) + if recipients["others"]: + send_to_remote_inbox.send( + sender_id=actor.id, + activity=note_activity, + recipients=recipients["others"], + ) + + @workouts_blueprint.route("/workouts", methods=["GET"]) @require_auth(scopes=["workouts:read"]) def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: @@ -2317,6 +2347,13 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: if not workout_data or workout_data.get("sport_id") is None: return InvalidPayloadErrorResponse() + if not current_app.config["FEDERATION_ENABLED"] and ( + workout_data.get("workout_visibility") + == VisibilityLevel.FOLLOWERS_AND_REMOTE.value + or workout_data.get("map_visibility") + == VisibilityLevel.FOLLOWERS_AND_REMOTE.value + ): + return InvalidPayloadErrorResponse() workout_file = request.files["file"] try: @@ -2331,6 +2368,15 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: }, 200 if len(new_workouts) > 0 and not processing_output["errored_workouts"]: + if sending_activities_allowed( + workout_data.get("workout_visibility") + ): + workouts_to_send = new_workouts[:MAX_WORKOUTS_TO_SEND] + for new_workout in workouts_to_send: + handle_workout_activities( + new_workout, activity_type="Create" + ) + response_object = { "status": "created", "data": { @@ -2545,11 +2591,21 @@ def post_workout_no_gpx( ): return InvalidPayloadErrorResponse() + if ( + not current_app.config["FEDERATION_ENABLED"] + and workout_data.get("workout_visibility") + == VisibilityLevel.FOLLOWERS_AND_REMOTE + ): + return InvalidPayloadErrorResponse() + try: service = WorkoutCreationService(auth_user, workout_data) [new_workout], _ = service.process() db.session.commit() + if sending_activities_allowed(new_workout.workout_visibility): + handle_workout_activities(new_workout, activity_type="Create") + return ( { "status": "created", @@ -2785,9 +2841,13 @@ def update_workout( ) try: old_sport_id = workout.sport_id + old_workout = ( + copy(workout) if current_app.config["FEDERATION_ENABLED"] else None + ) new_sport_id = workout_data.get("sport_id") + service = WorkoutUpdateService(auth_user, workout, workout_data) - service.update() + workout = service.update() if "elevation_data_source" in workout_data: elevation_service = ElevationService( @@ -2825,6 +2885,25 @@ def update_workout( db.session.commit() + if old_workout: + if workout.workout_visibility in ( + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ): + if old_workout.workout_visibility in ( + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ): + handle_workout_activities(workout, activity_type="Create") + else: + handle_workout_activities(workout, activity_type="Update") + + elif old_workout.workout_visibility in ( + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS_AND_REMOTE, + ): + handle_workout_activities(old_workout, activity_type="Delete") + return { "status": "success", "data": { @@ -2894,6 +2973,9 @@ def delete_workout( """ try: + if sending_activities_allowed(workout.workout_visibility): + handle_workout_activities(workout, activity_type="Delete") + # update equipments totals workout.equipments = [] db.session.flush() @@ -3020,6 +3102,13 @@ def like_workout( db.session.add(like) db.session.commit() + if current_app.config["FEDERATION_ENABLED"] and workout.user.is_remote: + like_activity = like.get_activity() + send_to_remote_inbox.send( + sender_id=auth_user.actor.id, + activity=like_activity, + recipients=[workout.user.actor.shared_inbox_url], + ) except IntegrityError: db.session.rollback() return { @@ -3140,6 +3229,14 @@ def undo_workout_like( db.session.delete(like) db.session.commit() + if current_app.config["FEDERATION_ENABLED"] and workout.user.is_remote: + undo_activity = like.get_activity(is_undo=True) + send_to_remote_inbox.send( + sender_id=auth_user.actor.id, + activity=undo_activity, + recipients=[workout.user.actor.shared_inbox_url], + ) + return { "status": "success", "data": {"workouts": [workout.serialize(user=auth_user, light=False)]}, diff --git a/fittrackee_client/src/components/Administration/AdminApplication.vue b/fittrackee_client/src/components/Administration/AdminApplication.vue index 69d8a1eac..d65b52c5a 100644 --- a/fittrackee_client/src/components/Administration/AdminApplication.vue +++ b/fittrackee_client/src/components/Administration/AdminApplication.vue @@ -231,6 +231,7 @@ const appData: Reactive = reactive({ admin_contact: '', + federation_enabled: false, max_users: 0, max_single_file_size: 0, max_zip_file_size: 0, diff --git a/fittrackee_client/src/components/Administration/AdminMenu.vue b/fittrackee_client/src/components/Administration/AdminMenu.vue index 515bbde0b..0ea9b1001 100644 --- a/fittrackee_client/src/components/Administration/AdminMenu.vue +++ b/fittrackee_client/src/components/Administration/AdminMenu.vue @@ -25,6 +25,15 @@ ) }} + + {{ + $t( + `admin.FEDERATION_${ + appConfig.federation_enabled ? 'ENABLED' : 'DISABLED' + }` + ) + }} +