From 430e38e717be65e3f4dbe6bab870b3eb8107e4a2 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Sat, 28 Sep 2024 10:49:29 -0700 Subject: [PATCH 1/5] Make progress on WordPress+ActivityPubPlugin. Not working yet. (#355) * Make progress on WordPress+ActivityPubPlugin. Not working yet. WordPress needs to have its own Account implementation -- different path than Mastodon Added OAuth token account provisioning code from Enable_Mastodon_Apps developer Throw Exception if mastodon_user_client was about to return None * Move _mastodon_user_client variable down in inheritance hierarchy, hopefully that will make mypy happy * Trailing slash for WordPress author URLs. Fixes #292 --------- Co-authored-by: Johannes Ernst --- src/feditest/nodedrivers/mastodon/__init__.py | 41 +++-- .../nodedrivers/wordpress/__init__.py | 169 ++++++++++++++++-- src/feditest/nodedrivers/wordpress/ubos.py | 71 ++++++-- src/feditest/ubos/__init__.py | 2 +- 4 files changed, 235 insertions(+), 48 deletions(-) diff --git a/src/feditest/nodedrivers/mastodon/__init__.py b/src/feditest/nodedrivers/mastodon/__init__.py index db60040..ac7d98c 100644 --- a/src/feditest/nodedrivers/mastodon/__init__.py +++ b/src/feditest/nodedrivers/mastodon/__init__.py @@ -139,13 +139,23 @@ def create(api_base_url: str, session: requests.Session) -> 'MastodonOAuthApp': return MastodonOAuthApp(client_id, client_secret, api_base_url, session) -class MastodonAccount(Account): # this is intended to be abstract +class AccountOnNodeWithMastodonAPI(Account): # this is intended to be abstract def __init__(self, role: str | None, userid: str): super().__init__(role) self.userid = userid - self._mastodon_user_client: Mastodon | None = None + @property + def webfinger_uri(self): + return f'acct:{ self.userid }@{ self.node.hostname }' + + + @abstractmethod + def mastodon_user_client(self, node: 'NodeWithMastodonAPI') -> Mastodon: + ... + + +class MastodonAccount(AccountOnNodeWithMastodonAPI): # this is intended to be abstract @staticmethod def create_from_account_info_in_testplan(account_info_in_testplan: dict[str, str | None], node_driver: NodeDriver): """ @@ -165,31 +175,23 @@ def create_from_account_info_in_testplan(account_info_in_testplan: dict[str, str return MastodonUserPasswordAccount(role, userid, password, email) - @property - def webfinger_uri(self): - return f'acct:{ self.userid }@{ self.node.hostname }' - - @property def actor_uri(self): return f'https://{ self.node.hostname }/users/{ self.userid }' - @abstractmethod - def mastodon_user_client(self, oauth_app: MastodonOAuthApp) -> Mastodon: - ... - - class MastodonUserPasswordAccount(MastodonAccount): def __init__(self, role: str | None, userid: str, password: str, email: str): super().__init__(role, userid) self.password = password self.email = email + self._mastodon_user_client: Mastodon | None = None # Allocated as needed # Python 3.12 @override - def mastodon_user_client(self, oauth_app: MastodonOAuthApp) -> Mastodon: + def mastodon_user_client(self, node: 'NodeWithMastodonAPI') -> Mastodon: if self._mastodon_user_client is None: + oauth_app = cast(MastodonOAuthApp,node._mastodon_oauth_app) trace(f'Logging into Mastodon at { oauth_app.api_base_url } as { self.email }') client = Mastodon( client_id = oauth_app.client_id, @@ -205,14 +207,19 @@ def mastodon_user_client(self, oauth_app: MastodonOAuthApp) -> Mastodon: class MastodonOAuthTokenAccount(MastodonAccount): + """ + Compare with WordPressAccount. + """ def __init__(self, role: str | None, userid: str, oauth_token: str): super().__init__(role, userid) self.oauth_token = oauth_token + self._mastodon_user_client: Mastodon | None = None # Allocated as needed # Python 3.12 @override - def mastodon_user_client(self, oauth_app: MastodonOAuthApp) -> Mastodon: + def mastodon_user_client(self, node: 'NodeWithMastodonAPI') -> Mastodon: if self._mastodon_user_client is None: + oauth_app = cast(MastodonOAuthApp,node._mastodon_oauth_app) client = Mastodon( client_id = oauth_app.client_id, client_secret=oauth_app.client_secret, @@ -577,11 +584,11 @@ def _get_mastodon_client_by_actor_uri(self, actor_uri: str) -> Mastodon: if not self._mastodon_oauth_app: self._mastodon_oauth_app = MastodonOAuthApp.create(f'https://{ self.hostname}', self._requests_session) - account = self._account_manager.get_account_by_match( lambda candidate: isinstance(candidate, MastodonAccount) and candidate.userid == userid ) + account = self._account_manager.get_account_by_match(lambda candidate: isinstance(candidate, AccountOnNodeWithMastodonAPI) and candidate.userid == userid ) if account is None: - return None + raise Exception(f'On Node { self }, failed to find account with userid "{ userid }".') - ret = cast(MastodonAccount, account).mastodon_user_client(self._mastodon_oauth_app) + ret = cast(MastodonAccount, account).mastodon_user_client(self) return ret diff --git a/src/feditest/nodedrivers/wordpress/__init__.py b/src/feditest/nodedrivers/wordpress/__init__.py index fcc652c..1e0f602 100644 --- a/src/feditest/nodedrivers/wordpress/__init__.py +++ b/src/feditest/nodedrivers/wordpress/__init__.py @@ -5,18 +5,11 @@ from typing import cast from feditest.nodedrivers.mastodon import ( - MastodonAccount, - MastodonNonExistingAccount, + AccountOnNodeWithMastodonAPI, + Mastodon, # Re-import from there to avoid duplicating the package import hackery + MastodonOAuthApp, NodeWithMastodonAPI, - NodeWithMastodonApiConfiguration, - EMAIL_ACCOUNT_FIELD, - OAUTH_TOKEN_ACCOUNT_FIELD, - PASSWORD_ACCOUNT_FIELD, - ROLE_ACCOUNT_FIELD, - ROLE_NON_EXISTING_ACCOUNT_FIELD, - USERID_ACCOUNT_FIELD, - USERID_NON_EXISTING_ACCOUNT_FIELD, - VERIFY_API_TLS_CERTIFICATE_PAR + NodeWithMastodonApiConfiguration ) from feditest.protocols import ( Account, @@ -31,7 +24,141 @@ ) from feditest.protocols.fediverse import FediverseNode from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameter -from feditest.utils import hostname_validate +from feditest.utils import boolean_parse_validate, hostname_validate + + +VERIFY_API_TLS_CERTIFICATE_PAR = TestPlanNodeParameter( + 'verify_api_tls_certificate', + """If set to false, accessing the Mastodon API will be performed without checking TLS certificates.""", + validate=boolean_parse_validate +) + +def _oauth_token_validate(candidate: str) -> str | None: + """ + Validate a WordPress "Enable Mastodon Apps" app client API token. Avoids user input errors. + FIXME this is a wild guess and can be better. + """ + candidate = candidate.strip() + return candidate if len(candidate)>10 else None + + +def _userid_validate(candidate: str) -> str | None: + """ + Validate a WordPress user name. Avoids user input errors. + FIXME this is a wild guess and can be better. + """ + candidate = candidate.strip() + return candidate if re.match(r'[a-zA-Z0-9_]', candidate) else None + + +USERID_ACCOUNT_FIELD = TestPlanNodeAccountField( + 'userid', + """Mastodon userid for a user (e.g. "joe") (required).""", + _userid_validate +) +OAUTH_TOKEN_ACCOUNT_FIELD = TestPlanNodeAccountField( + 'oauth_token', + """OAuth token of a user so the "Enable Mastodon apps" API can be invoked.""", + _oauth_token_validate +) +ROLE_ACCOUNT_FIELD = TestPlanNodeAccountField( + 'role', + """A symbolic name for the Account as used by tests (optional).""", + lambda x: len(x) +) + +USERID_NON_EXISTING_ACCOUNT_FIELD = TestPlanNodeNonExistingAccountField( + 'userid', + """Mastodon userid for a non-existing user (e.g. "joe") (required).""", + _userid_validate +) +ROLE_NON_EXISTING_ACCOUNT_FIELD = TestPlanNodeNonExistingAccountField( + 'role', + """A symbolic name for the non-existing Account as used by tests (optional).""", + lambda x: len(x) +) + + +class WordPressAccount(AccountOnNodeWithMastodonAPI): + """ + Compare with MastodonOAuthTokenAccount. + """ + def __init__(self, role: str | None, userid: str, oauth_token: str | None): + """ + The oauth_token may be None. In which case we dynamically obtain one. + """ + super().__init__(role, userid) + self.oauth_token = oauth_token + self._mastodon_user_client: Mastodon | None = None # Allocated as needed + + + @staticmethod + def create_from_account_info_in_testplan(account_info_in_testplan: dict[str, str | None], node_driver: NodeDriver): + """ + Parses the information provided in an "account" dict of TestPlanConstellationNode + """ + userid = USERID_ACCOUNT_FIELD.get_validate_from_or_raise(account_info_in_testplan, f'NodeDriver { node_driver }: ') + role = ROLE_ACCOUNT_FIELD.get_validate_from(account_info_in_testplan, f'NodeDriver { node_driver }: ') + + oauth_token = OAUTH_TOKEN_ACCOUNT_FIELD.get_validate_from_or_raise(account_info_in_testplan, f'NodeDriver { node_driver }: ') + return WordPressAccount(role, userid, oauth_token) + + + @property + def actor_uri(self): + return f'https://{ self.node.hostname }/author/{ self.userid }/' + + + def mastodon_user_client(self, node: NodeWithMastodonAPI) -> Mastodon: + if self._mastodon_user_client is None: + oauth_app = cast(MastodonOAuthApp,node._mastodon_oauth_app) + self._ensure_oauth_token(node, oauth_app.client_id) + client = Mastodon( + client_id = oauth_app.client_id, + client_secret=oauth_app.client_secret, + access_token=self.oauth_token, + api_base_url=oauth_app.api_base_url, + session=oauth_app.session + ) + self._mastodon_user_client = client + return self._mastodon_user_client + + + def _ensure_oauth_token(self, node: NodeWithMastodonAPI, oauth_client_id: str) -> None: + """ + Helper to dynamically provision an OAuth token if we don't have one yet. + """ + if self.oauth_token: + return + real_node = cast(WordPressPlusActivityPubPluginNode, node) + self.oauth_token = real_node._provision_oauth_token_for(self.userid, oauth_client_id) + + +class WordPressNonExistingAccount(NonExistingAccount): + def __init__(self, role: str | None, userid: str): + super().__init__(role) + self.userid = userid + + + @staticmethod + def create_from_non_existing_account_info_in_testplan(non_existing_account_info_in_testplan: dict[str, str | None], node_driver: NodeDriver): + """ + Parses the information provided in an "non_existing_account" dict of TestPlanConstellationNode + """ + userid = USERID_NON_EXISTING_ACCOUNT_FIELD.get_validate_from_or_raise(non_existing_account_info_in_testplan, f'NodeDriver { node_driver }: ') + role = ROLE_ACCOUNT_FIELD.get_validate_from(non_existing_account_info_in_testplan, f'NodeDriver { node_driver }: ') + + return WordPressNonExistingAccount(role, userid) + + + @property + def webfinger_uri(self): + return f'acct:{ self.userid }@{ self.node.hostname }' + + + @property + def actor_uri(self): + return f'https://{ self.node.hostname }/users/{ self.userid }' class WordPressPlusActivityPubPluginNode(NodeWithMastodonAPI): @@ -46,6 +173,13 @@ def _actor_uri_to_userid(self, actor_uri: str) -> str: raise ValueError( f'Cannot find actor at this node: { actor_uri }' ) + def _provision_oauth_token_for(self, userid: str, oauth_client_id: str): + ret = self.prompt_user(f'Enter the OAuth token for the Mastodon API for user "{ userid }"' + + f' on constellation role "{ self.rolename }" (user field "{ OAUTH_TOKEN_ACCOUNT_FIELD }"): ', + parse_validate=_oauth_token_validate) + return ret + + class WordPressPlusActivityPubPluginSaasNodeDriver(NodeDriver): """ Create a WordPress + ActivityPubPlugin Node that already runs as Saas @@ -59,7 +193,7 @@ def test_plan_node_parameters() -> list[TestPlanNodeParameter]: # Python 3.12 @override @staticmethod def test_plan_node_account_fields() -> list[TestPlanNodeAccountField]: - return [ USERID_ACCOUNT_FIELD, EMAIL_ACCOUNT_FIELD, PASSWORD_ACCOUNT_FIELD, OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD ] + return [ USERID_ACCOUNT_FIELD, OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD ] # Python 3.12 @override @@ -70,24 +204,25 @@ def test_plan_node_non_existing_account_fields() -> list[TestPlanNodeNonExisting # Python 3.12 @override def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]: - app = test_plan_node.parameter_or_raise(APP_PAR, { APP_PAR.name: 'Mastodon' }) # Let user give a more descriptive name if they want to + app = test_plan_node.parameter_or_raise(APP_PAR, { APP_PAR.name: 'WordPress + ActivityPub plugin' }) # Let user give a more descriptive name if they want to app_version = test_plan_node.parameter(APP_VERSION_PAR) hostname = test_plan_node.parameter_or_raise(HOSTNAME_PAR) verify_tls_certificate = test_plan_node.parameter_or_raise(VERIFY_API_TLS_CERTIFICATE_PAR, { VERIFY_API_TLS_CERTIFICATE_PAR.name: 'true' }) if not hostname: - hostname = self.prompt_user(f'Enter the hostname for the Mastodon Node of constellation role "{ rolename }" (node parameter "hostname"): ', + hostname = self.prompt_user(f'Enter the hostname for the WordPress + ActivityPub plugin Node of constellation role "{ rolename }"' + + f' (node parameter "{ HOSTNAME_PAR }"): ', parse_validate=hostname_validate) accounts : list[Account] = [] if test_plan_node.accounts: for account_info in test_plan_node.accounts: - accounts.append(MastodonAccount.create_from_account_info_in_testplan(account_info, self)) + accounts.append(WordPressAccount.create_from_account_info_in_testplan(account_info, self)) non_existing_accounts : list[NonExistingAccount] = [] if test_plan_node.non_existing_accounts: for non_existing_account_info in test_plan_node.non_existing_accounts: - non_existing_accounts.append(MastodonNonExistingAccount.create_from_non_existing_account_info_in_testplan(non_existing_account_info, self)) + non_existing_accounts.append(WordPressNonExistingAccount.create_from_non_existing_account_info_in_testplan(non_existing_account_info, self)) return ( NodeWithMastodonApiConfiguration( diff --git a/src/feditest/nodedrivers/wordpress/ubos.py b/src/feditest/nodedrivers/wordpress/ubos.py index 3fe3846..dcd44d1 100644 --- a/src/feditest/nodedrivers/wordpress/ubos.py +++ b/src/feditest/nodedrivers/wordpress/ubos.py @@ -3,23 +3,22 @@ from typing import cast -from feditest.nodedrivers.mastodon import ( - MastodonAccount, - MastodonNonExistingAccount, - EMAIL_ACCOUNT_FIELD, +from feditest.nodedrivers.mastodon.ubos import MastodonUbosNodeConfiguration +from feditest.nodedrivers.wordpress import ( OAUTH_TOKEN_ACCOUNT_FIELD, - PASSWORD_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD, ROLE_NON_EXISTING_ACCOUNT_FIELD, USERID_ACCOUNT_FIELD, - USERID_NON_EXISTING_ACCOUNT_FIELD + USERID_NON_EXISTING_ACCOUNT_FIELD, + WordPressAccount, + WordPressNonExistingAccount, + WordPressPlusActivityPubPluginNode ) -from feditest.nodedrivers.mastodon.ubos import MastodonUbosAccountManager, MastodonUbosNodeConfiguration -from feditest.nodedrivers.wordpress import WordPressPlusActivityPubPluginNode from feditest.protocols import ( Account, - NonExistingAccount, AccountManager, + DefaultAccountManager, + NonExistingAccount, Node, NodeConfiguration ) @@ -28,6 +27,25 @@ from feditest.ubos import UbosNodeConfiguration, UbosNodeDriver + +class WordPressUbosAccountManager(DefaultAccountManager): + """ + Knows how to provision new accounts in WordPress + """ + # Python 3.12 @override + def set_node(self, node: Node) -> None: + """ + We override this so we can insert the admin account in the list of accounts, now that the Node has been instantiated. + """ + super().set_node(node) + + if not self._accounts_allocated_to_role and not self._accounts_not_allocated_to_role: + config = cast(UbosNodeConfiguration, node.config) + admin_account = WordPressAccount(None, config.admin_userid, None) + admin_account.set_node(node) + self._accounts_not_allocated_to_role.append(admin_account) + + class WordPressPlusActivityPubPluginUbosNode(WordPressPlusActivityPubPluginNode): """ A WordPress + ActivityPubPlugin Node running on UBOS. This means we know how to interact with it exactly. @@ -56,6 +74,33 @@ def remove_cert_from_trust_store(self, root_cert: str) -> None: node_driver.remove_cert_from_trust_store_via(root_cert, config.rshcmd) + # Python 3.12 @override + def _provision_oauth_token_for(self, userid: str, oauth_client_id: str): + # Code from here: https://wordpress.org/support/topic/programmatically-obtaining-oauth-token-for-testing/ + # $desired_token = '123'; + # $user_id = 1; + # $oauth = new Enable_Mastodon_Apps\Mastodon_OAuth(); + # $oauth->get_token_storage()->setAccessToken( $desired_token, $app->get_client_id(), $user_id, time() + HOUR_IN_SECONDS, $app->get_scopes() ); + + config = cast(UbosNodeConfiguration, self.config) + node_driver = cast(WordPressPlusActivityPubPluginUbosNodeDriver, self.node_driver) + + php_script = f""" +get_token_storage()->setAccessToken( "11223344", "{ oauth_client_id }", "{ userid }", time() + HOUR_IN_SECONDS, 'read write follow push' ); +""" + dir = f'/ubos/http/sites/{ config.siteid }' + cmd = f'cd { dir } && sudo sudo -u http php' # from user ubosdev -> root -> http + + if node_driver._exec_shell(cmd, config.rshcmd, stdin_content=php_script).returncode: + raise Exception(self, f"Failed to create OAuth token for user { userid }, cmd: { cmd }") + + class WordPressPlusActivityPubPluginUbosNodeDriver(UbosNodeDriver): """ Knows how to instantiate Mastodon via UBOS. @@ -63,7 +108,7 @@ class WordPressPlusActivityPubPluginUbosNodeDriver(UbosNodeDriver): # Python 3.12 @override @staticmethod def test_plan_node_account_fields() -> list[TestPlanNodeAccountField]: - return [ USERID_ACCOUNT_FIELD, EMAIL_ACCOUNT_FIELD, PASSWORD_ACCOUNT_FIELD, OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD ] + return [ USERID_ACCOUNT_FIELD, OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD ] # Python 3.12 @override @@ -77,12 +122,12 @@ def create_configuration_account_manager(self, rolename: str, test_plan_node: Te accounts : list[Account] = [] if test_plan_node.accounts: for account_info in test_plan_node.accounts: - accounts.append(MastodonAccount.create_from_account_info_in_testplan(account_info, self)) + accounts.append(WordPressAccount.create_from_account_info_in_testplan(account_info, self)) non_existing_accounts : list[NonExistingAccount] = [] if test_plan_node.non_existing_accounts: for non_existing_account_info in test_plan_node.non_existing_accounts: - non_existing_accounts.append(MastodonNonExistingAccount.create_from_non_existing_account_info_in_testplan(non_existing_account_info, self)) + non_existing_accounts.append(WordPressNonExistingAccount.create_from_non_existing_account_info_in_testplan(non_existing_account_info, self)) # Once has the Node has been instantiated (we can't do that here yet): if the user did not specify at least one Account, we add the admin account @@ -102,7 +147,7 @@ def create_configuration_account_manager(self, rolename: str, test_plan_node: Te defaults = { 'app' : 'WordPress + ActivityPub plugin' }), - MastodonUbosAccountManager(accounts, non_existing_accounts) + WordPressUbosAccountManager(accounts, non_existing_accounts) ) # Python 3.12 @override diff --git a/src/feditest/ubos/__init__.py b/src/feditest/ubos/__init__.py index fbf0b58..f2c3380 100644 --- a/src/feditest/ubos/__init__.py +++ b/src/feditest/ubos/__init__.py @@ -494,7 +494,7 @@ def _provision_node_from_backupfile(self, config: UbosNodeFromBackupConfiguratio cmd += ' --newcontext ""' cmd += f' --in "{ config.backupfile }"' - if self._exec_shell(cmd).returncode: + if self._exec_shell(cmd, config.rshcmd).returncode: raise UbosAdminException(self, cmd) From 0311dadb364cb74eb611ca52c8ba8376f65c7768 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Sat, 28 Sep 2024 10:50:46 -0700 Subject: [PATCH 2/5] Added methods to support https://github.com/fediverse-devnet/feditest-tests-fediverse/issues/141 (#359) Clarify some comments. Co-authored-by: Johannes Ernst --- .../nodedrivers/fallback/fediverse.py | 24 ++++++++- src/feditest/nodedrivers/mastodon/__init__.py | 41 ++++++++++++++- src/feditest/protocols/fediverse/__init__.py | 51 ++++++++++++++----- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/feditest/nodedrivers/fallback/fediverse.py b/src/feditest/nodedrivers/fallback/fediverse.py index a334d81..57d6252 100644 --- a/src/feditest/nodedrivers/fallback/fediverse.py +++ b/src/feditest/nodedrivers/fallback/fediverse.py @@ -244,7 +244,7 @@ def make_follow(self, actor_uri: str, to_follow_actor_uri: str) -> None: # Python 3.12 @override def wait_until_actor_is_following_actor(self, actor_uri: str, to_be_followed_uri: str, max_wait: float = 5.) -> None: answer = self.prompt_user( - f'On FediverseNode "{ self.hostname }", wait until in actor "{ actor_uri }" is following actor "{ to_be_followed_uri }"' + f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_uri }" is following actor "{ to_be_followed_uri }"' + ' and enter "true"; "false" if it didn\'t happen.', parse_validate=boolean_parse_validate) if not answer: @@ -254,13 +254,33 @@ def wait_until_actor_is_following_actor(self, actor_uri: str, to_be_followed_uri # Python 3.12 @override def wait_until_actor_is_followed_by_actor(self, actor_uri: str, to_be_following_uri: str, max_wait: float = 5.) -> None: answer = self.prompt_user( - f'On FediverseNode "{ self.hostname }", wait until in actor "{ actor_uri }" is followed by actor "{ to_be_following_uri }"' + f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_uri }" is followed by actor "{ to_be_following_uri }"' + ' and enter "true"; "false" if it didn\'t happen.', parse_validate=boolean_parse_validate) if not answer: raise TimeoutException(f'Actor { actor_uri } not followed by actor { to_be_following_uri}.', max_wait) + # Python 3.12 @override + def wait_until_actor_is_unfollowing_actor(self, actor_uri: str, to_be_unfollowed_uri: str, max_wait: float = 5.) -> None: + answer = self.prompt_user( + f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_uri }" is not following any more actor "{ to_be_unfollowed_uri }"' + + ' and enter "true"; "false" if it didn\'t happen.', + parse_validate=boolean_parse_validate) + if not answer: + raise TimeoutException(f'Actor { actor_uri } still following actor { to_be_unfollowed_uri}.', max_wait) + + + # Python 3.12 @override + def wait_until_actor_is_unfollowed_by_actor(self, actor_uri: str, to_be_unfollowing_uri: str, max_wait: float = 5.) -> None: + answer = self.prompt_user( + f'On FediverseNode "{ self.hostname }", wait until in actor "{ actor_uri }" is not followed any more by actor "{ to_be_unfollowing_uri }"' + + ' and enter "true"; "false" if it didn\'t happen.', + parse_validate=boolean_parse_validate) + if not answer: + raise TimeoutException(f'Actor { actor_uri } is still followed by actor { to_be_unfollowing_uri}.', max_wait) + + class AbstractFallbackFediverseNodeDriver(NodeDriver): """ Abstract superclass of NodeDrivers that support all web server-side protocols but don't diff --git a/src/feditest/nodedrivers/mastodon/__init__.py b/src/feditest/nodedrivers/mastodon/__init__.py index ac7d98c..3955d91 100644 --- a/src/feditest/nodedrivers/mastodon/__init__.py +++ b/src/feditest/nodedrivers/mastodon/__init__.py @@ -382,8 +382,14 @@ def make_follow_reject(self, actor_uri: str, follower_actor_uri: str) -> None: # Python 3.12 @override - def make_follow_undo(self, actor_uri: str, follower_actor_uri: str) -> None: - super().make_follow_undo(actor_uri, follower_actor_uri) # FIXME + def make_follow_undo(self, actor_uri: str, following_actor_uri: str) -> None: + trace('make_follow_undo:') + mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) + + if following_account := self._get_account_dict_by_other_actor_uri(mastodon_client, following_actor_uri): + relationship = mastodon_client.account_unfollow(following_account) # noqa: F841 + return + raise ValueError(f'Account not found with Actor URI: { following_actor_uri }') # Python 3.12 @override @@ -443,6 +449,37 @@ def wait_until_actor_is_followed_by_actor(self, actor_uri: str, to_be_following_ return raise ValueError(f'Account not found with Actor URI: { to_be_following_uri }') + + # Python 3.12 @override + def wait_until_actor_is_unfollowing_actor(self, actor_uri: str, to_be_unfollowed_uri: str, max_wait: float = 5.) -> None: + trace(f'wait_until_actor_is_unfollowing_actor: actor_uri = { actor_uri }, to_be_unfollowed_uri = { to_be_unfollowed_uri }') + mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) + + if to_be_unfollowed_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to_be_unfollowed_uri): + self._poll_until_result( # may throw + lambda: not self._is_following(mastodon_client, to_be_unfollowed_account), + int(max_wait), + 1.0, + f'Actor { actor_uri } is still following { to_be_unfollowed_uri }') + return + raise ValueError(f'Account not found with Actor URI: { to_be_unfollowed_uri }') + + + # Python 3.12 @override + def wait_until_actor_is_unfollowed_by_actor(self, actor_uri: str, to_be_unfollowing_uri: str, max_wait: float = 5.) -> None: + trace(f'wait_until_actor_is_unfollowed_by_actor: actor_uri = { actor_uri }, to_be_unfollowing_uri = { to_be_unfollowing_uri }') + mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) + + if to_be_unfollowing_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to_be_unfollowing_uri): + self._poll_until_result( # may throw + lambda: not self._is_followed_by(mastodon_client, to_be_unfollowing_account), + int(max_wait), + 1.0, + f'Actor { actor_uri } is still followed by { to_be_unfollowing_uri }') + return + raise ValueError(f'Account not found with Actor URI: { to_be_unfollowing_uri }') + + # From ActivityPubNode # Python 3.12 @override diff --git a/src/feditest/protocols/fediverse/__init__.py b/src/feditest/protocols/fediverse/__init__.py index a32a8b6..f1cd63d 100644 --- a/src/feditest/protocols/fediverse/__init__.py +++ b/src/feditest/protocols/fediverse/__init__.py @@ -29,8 +29,8 @@ def make_create_note(self, actor_uri: str, content: str, deliver_to: list[str] | def make_announce_object(self, actor_uri, announced_object_uri: str) -> str: """ - Perform whatever actions are necessary so the actor with actor_uri will have created - an Announce object on this Node announcing the object with announced_object_uri. + Perform whatever actions are necessary so the actor with actor_uri will have Announced + on this Node, the object with announced_object_uri. return: URI to the Announce object """ raise NotImplementedByNodeError(self, FediverseNode.make_announce_object) @@ -47,8 +47,9 @@ def make_reply_note(self, actor_uri, replied_object_uri: str, reply_content: str def make_follow(self, actor_uri: str, to_follow_actor_uri: str) -> None: """ - Perform whatever actions are necessary so the actor with actor_uri will have created - a Follow activity for the Actor with to_follow_actor_uri. + Perform whatever actions are necessary so the actor with actor_uri will have initiated + a Follow request for the Actor with to_follow_actor_uri. + The actor with actor_uri must be on this Node. No return value: we already have the to_follow_actor_uri """ raise NotImplementedByNodeError(self, FediverseNode.make_follow) @@ -58,6 +59,7 @@ def set_auto_accept_follow(self, actor_uri: str, auto_accept_follow: bool = True """ Configure the behavior of this Node for the Actor with actor_uri so that when Follow requests come in, they are automatically accepted. + The actor with actor_uri must be on this Node. This method exists so that implementations can throw a NotImplementedByNodeError if they do not have the requested behavior (or it cannot be scripted) and the corresponding tests does not run. @@ -65,26 +67,33 @@ def set_auto_accept_follow(self, actor_uri: str, auto_accept_follow: bool = True raise NotImplementedByNodeError(self, FediverseNode.set_auto_accept_follow) - def make_follow_accept(self, actor_uri: str, follower_actor_uri: str) -> None: + def make_follow_accept(self, actor_uri: str, would_be_follower_actor_uri: str) -> None: """ - Perform whatever actions are necessary so the actor with actor_uri will have created - an Accept activity for a Follow request by the Actor with follower_actor_uri. + Perform whatever actions are necessary so the actor with actor_uri will have accepted + a Follow request previously made by the Actor with would_be_follower_actor_uri. + Calling this makes no sense if `auto_accept_follow` is true for the actor with actor_uri, + as it only applies to a pending Follow request. + The actor with actor_uri must be on this Node. """ raise NotImplementedByNodeError(self, FediverseNode.make_follow_accept) - def make_follow_reject(self, actor_uri: str, follower_actor_uri: str) -> None: + def make_follow_reject(self, actor_uri: str, would_be_follower_actor_uri: str) -> None: """ - Perform whatever actions are necessary so the actor with actor_uri will have created - a Reject activity for a Follow request by the Actor with follower_actor_uri. + Perform whatever actions are necessary so the actor with actor_uri will have rejected + a Follow request previously made by the Actor with would_be_follower_actor_uri. + Calling this makes no sense if `auto_accept_follow` is true for the actor with actor_uri, + as it only applies to a pending Follow request. + The actor with actor_uri must be on this Node. """ raise NotImplementedByNodeError(self, FediverseNode.make_follow_reject) - def make_follow_undo(self, actor_uri: str, follower_actor_uri: str) -> None: + def make_follow_undo(self, actor_uri: str, following_actor_uri: str) -> None: """ Perform whatever actions are necessary so the actor with actor_uri will have created - an Undo activity for a Follow Accept withy the Actor with follower_actor_uri. + unfollowed the Actor with following_actor_uri. + The actor with actor_uri must be on this Node. """ raise NotImplementedByNodeError(self, FediverseNode.make_follow_undo) @@ -117,3 +126,21 @@ def wait_until_actor_is_followed_by_actor(self, actor_uri: str, to_be_following_ a TimeoutException. """ raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_followed_by_actor) + + + def wait_until_actor_is_unfollowing_actor(self, actor_uri: str, to_be_unfollowed_uri: str, max_wait: float = 5.) -> None: + """ + Wait until the Actor at actor_uri has ceased following the Actor at to_be_unfollowed_uri on this Node. + If the condition has not arrived by the time max_wait seconds have passed, throw + a TimeoutException. + """ + raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_unfollowing_actor) + + + def wait_until_actor_is_unfollowed_by_actor(self, actor_uri: str, to_be_unfollowing_uri: str, max_wait: float = 5.) -> None: + """ + Wait until the Actor at actor_uri has ceased following the Actor with to_be_unfollowing_uri on this Node. + If the condition has not arrived by the time max_wait seconds have passed, throw + a TimeoutException. + """ + raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_unfollowed_by_actor) From 316256c1c727d790670aeeba6717a6eb2a262e36 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Sat, 28 Sep 2024 10:51:23 -0700 Subject: [PATCH 3/5] Allow registry to generate certs for hostnames specified in the TestPlan as well (#360) Better Node toString Co-authored-by: Johannes Ernst --- src/feditest/protocols/__init__.py | 2 ++ src/feditest/registry.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/feditest/protocols/__init__.py b/src/feditest/protocols/__init__.py index 1805216..abd74cf 100644 --- a/src/feditest/protocols/__init__.py +++ b/src/feditest/protocols/__init__.py @@ -458,6 +458,8 @@ def remove_cert_from_trust_store(self, root_cert: str) -> None: def __str__(self) -> str: + if self._config.hostname: + return f'"{ type(self).__name__}", hostname "{ self._config.hostname }" in constellation role "{self.rolename}"' return f'"{ type(self).__name__}" in constellation role "{self.rolename}"' diff --git a/src/feditest/registry.py b/src/feditest/registry.py index 57c94de..f387288 100644 --- a/src/feditest/registry.py +++ b/src/feditest/registry.py @@ -169,7 +169,9 @@ def obtain_hostinfo(self, host: str) -> RegistryHostInfo: trace(f'Registry.obtain_hostinfo({ host }) with domain { self.ca.domain }') ret = self.hosts.get(host) if ret is None: - raise Exception(f'Unknown host: {host}') + # An externally specified hostname: add to set of known hosts + ret = RegistryHostInfo(host=host) + self.hosts[host] = ret host_key: rsa.RSAPrivateKey if ret.key is None: From 47f8e680f5d7c2152ea3b1a75163af6ba0fa7c14 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Sat, 28 Sep 2024 10:52:38 -0700 Subject: [PATCH 4/5] Allow "run" to be invoked with session and constellation arguments, not just full test plan (#362) * Allow specification of the components of a testplan as an alternative to the --testplan in the run command. * Fixes #191 well enough, not sure we need to do this for the other commands * Use ArgumentError for CLI invocation errors, not fatal * Move feditest unit tests into their own tests directory; makes invocation easier * Instead of having test plans in the feditest unit test directory, have constellations and session templates: much better for usability * Add more noop tests * Don't add built-in noop tests to test sessions created from looking on disk what tests we can find * Print needed role names when "feditest info --test " * add --test option to "feditest run" so we can specify which tests to run directly from the command-line --------- Co-authored-by: Johannes Ernst --- src/feditest/__init__.py | 8 +- src/feditest/cli/__init__.py | 4 +- .../cli/commands/create_constellation.py | 9 +- .../cli/commands/create_session_template.py | 2 + src/feditest/cli/commands/info.py | 7 +- src/feditest/cli/commands/run.py | 146 +++++++++++++++++- src/feditest/tests.py | 19 ++- tests.unit/feditest/README.md | 55 +++++++ .../feditest/mastodon.saas.constellation.json | 21 +++ .../feditest/mastodon.ubos.constellation.json | 8 + tests.unit/feditest/mastodon_api.session.json | 16 ++ .../mastodon_api_mastodon_api.session.json | 17 ++ .../mastodon_mastodon.ubos.constellation.json | 11 ++ .../feditest/mastodon_mastodon_ubos.json | 28 ---- tests.unit/feditest/mastodon_saas_api.json | 38 ----- tests.unit/feditest/mastodon_ubos_api.json | 25 --- .../{ => tests}/node_with_mastodon_api.py | 0 .../nodes_with_mastodon_api_communicate.py} | 2 +- .../wordpress.saas.constellation.json | 21 +++ .../wordpress.ubos.constellation.json | 8 + ...wordpress_mastodon.ubos.constellation.json | 11 ++ tests.unit/feditest/wordpress_ubos_api.json | 25 --- 22 files changed, 345 insertions(+), 136 deletions(-) create mode 100644 tests.unit/feditest/README.md create mode 100644 tests.unit/feditest/mastodon.saas.constellation.json create mode 100644 tests.unit/feditest/mastodon.ubos.constellation.json create mode 100644 tests.unit/feditest/mastodon_api.session.json create mode 100644 tests.unit/feditest/mastodon_api_mastodon_api.session.json create mode 100644 tests.unit/feditest/mastodon_mastodon.ubos.constellation.json delete mode 100644 tests.unit/feditest/mastodon_mastodon_ubos.json delete mode 100644 tests.unit/feditest/mastodon_saas_api.json delete mode 100644 tests.unit/feditest/mastodon_ubos_api.json rename tests.unit/feditest/{ => tests}/node_with_mastodon_api.py (100%) rename tests.unit/feditest/{mastodon_mastodon.py => tests/nodes_with_mastodon_api_communicate.py} (96%) create mode 100644 tests.unit/feditest/wordpress.saas.constellation.json create mode 100644 tests.unit/feditest/wordpress.ubos.constellation.json create mode 100644 tests.unit/feditest/wordpress_mastodon.ubos.constellation.json delete mode 100644 tests.unit/feditest/wordpress_ubos_api.json diff --git a/src/feditest/__init__.py b/src/feditest/__init__.py index 3bf61ba..e55c2c4 100644 --- a/src/feditest/__init__.py +++ b/src/feditest/__init__.py @@ -117,9 +117,11 @@ def load_default_tests() -> None: """ global all_tests - all_tests['noop2'] = TestFromTestFunction('noop2', 'This denegerate 2-node test does nothing', lambda node1, node2: None ) - all_tests['noop3'] = TestFromTestFunction('noop3', 'This denegerate 3-node test does nothing', lambda node1, node2, node3: None ) - all_tests['noop4'] = TestFromTestFunction('noop4', 'This denegerate 4-node test does nothing', lambda node1, node2, node3, node4: None ) + all_tests['noop0'] = TestFromTestFunction('noop0', 'This denegerate 0-node test does nothing', lambda: None, builtin=True ) + all_tests['noop1'] = TestFromTestFunction('noop1', 'This denegerate 1-node test does nothing', lambda node1: None, builtin=True ) + all_tests['noop2'] = TestFromTestFunction('noop2', 'This denegerate 2-node test does nothing', lambda node1, node2: None, builtin=True ) + all_tests['noop3'] = TestFromTestFunction('noop3', 'This denegerate 3-node test does nothing', lambda node1, node2, node3: None, builtin=True ) + all_tests['noop4'] = TestFromTestFunction('noop4', 'This denegerate 4-node test does nothing', lambda node1, node2, node3, node4: None, builtin=True ) # Do not replace those lambda parameters with _: we need to look up their names for role mapping diff --git a/src/feditest/cli/__init__.py b/src/feditest/cli/__init__.py index b54192e..fddb4e9 100644 --- a/src/feditest/cli/__init__.py +++ b/src/feditest/cli/__init__.py @@ -2,7 +2,7 @@ Main entry point for CLI invocation """ -from argparse import ArgumentParser, Action +from argparse import ArgumentError, ArgumentParser, Action import importlib import sys import traceback @@ -39,6 +39,8 @@ def main() -> None: ret = cmds[cmd_name].run(cmd_sub_parsers[cmd_name], args, remaining) sys.exit( ret ) + except ArgumentError as e: + fatal(e.message) except Exception as e: # pylint: disable=broad-exception-caught if args.verbose > 1: traceback.print_exception( e ) diff --git a/src/feditest/cli/commands/create_constellation.py b/src/feditest/cli/commands/create_constellation.py index 915bd34..be0c4f3 100644 --- a/src/feditest/cli/commands/create_constellation.py +++ b/src/feditest/cli/commands/create_constellation.py @@ -2,10 +2,9 @@ Combine node definitions into a constellation. """ -from argparse import ArgumentParser, Namespace, _SubParsersAction +from argparse import ArgumentError, ArgumentParser, Namespace, _SubParsersAction from feditest.testplan import TestPlanConstellation, TestPlanConstellationNode -from feditest.reporting import fatal def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int: @@ -22,11 +21,11 @@ def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int: for nodepair in args.node: rolename, nodefile = nodepair.split('=', 1) if not rolename: - fatal('Rolename component must not be empty:', nodepair) + raise ArgumentError(None, f'Rolename component of --node must not be empty: "{ nodepair }".') if rolename in roles: - fatal('Role is already taken:', rolename) + raise ArgumentError(None, f'Role is already taken: "{ rolename }".') if not nodefile: - fatal('Filename component must not be empty:', nodepair) + raise ArgumentError(None, f'Filename component must not be empty: "{ nodepair }".') node = TestPlanConstellationNode.load(nodefile) roles[rolename] = node diff --git a/src/feditest/cli/commands/create_session_template.py b/src/feditest/cli/commands/create_session_template.py index 050c716..bc88d5c 100644 --- a/src/feditest/cli/commands/create_session_template.py +++ b/src/feditest/cli/commands/create_session_template.py @@ -32,6 +32,8 @@ def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int: test = feditest.all_tests.get(name) if test is None: # make linter happy continue + if test.builtin: + continue test_plan_spec = TestPlanTestSpec(name) test_plan_specs.append(test_plan_spec) diff --git a/src/feditest/cli/commands/info.py b/src/feditest/cli/commands/info.py index e923aad..8cf022e 100644 --- a/src/feditest/cli/commands/info.py +++ b/src/feditest/cli/commands/info.py @@ -42,7 +42,12 @@ def run_info_test(name: str) -> int: """ test = feditest.all_tests.get(name) if test: - print(format_name_value_string(test.metadata()), end='') + test_metadata = test.metadata() + needed_role_names = test.needed_local_role_names() + if needed_role_names: + test_metadata['Needed roles:'] = sorted(needed_role_names) + + print(format_name_value_string(test_metadata), end='') return 0 warning( 'Test not found:', name) diff --git a/src/feditest/cli/commands/run.py b/src/feditest/cli/commands/run.py index 24a3998..63cf228 100644 --- a/src/feditest/cli/commands/run.py +++ b/src/feditest/cli/commands/run.py @@ -2,12 +2,17 @@ Run one or more tests """ -from argparse import ArgumentParser, Namespace, _SubParsersAction +from argparse import ArgumentError, ArgumentParser, Namespace, _SubParsersAction +import re +from typing import Any + +from msgspec import ValidationError import feditest from feditest.registry import Registry, set_registry_singleton from feditest.reporting import warning -from feditest.testplan import TestPlan +from feditest.tests import Test +from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSession, TestPlanTestSpec from feditest.testrun import TestRun from feditest.testruncontroller import AutomaticTestRunController, InteractiveTestRunController, TestRunController from feditest.testruntranscript import ( @@ -40,7 +45,24 @@ def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int: if args.domain: set_registry_singleton(Registry.create(args.domain)) # overwrite - plan = TestPlan.load(args.testplan) + # Determine testplan. While we are at it, check consistency of arguments. + if args.testplan: + plan = _create_plan_from_testplan(args) + else: + session_templates = _create_session_templates(args) + constellations = _create_constellations(args) + + sessions = [] + for session_template in session_templates: + for constellation in constellations: + session = session_template.instantiate_with_constellation(constellation, constellation.name) + sessions.append(session) + if sessions: + plan = TestPlan(sessions, None) + plan.simplify() + else: # neither sessions nor testplan specified + plan = TestPlan.load("feditest-default.json") + if not plan.is_compatible_type(): warning(f'Test plan has unexpected type { plan.type }: incompatibilities may occur.') if not plan.has_compatible_version(): @@ -86,15 +108,26 @@ def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> None: parent_parser: the parent argparse parser cmd_name: name of this command """ + # general flags and options parser = parent_parser.add_parser(cmd_name, help='Run one or more tests' ) parser.add_argument('--testsdir', nargs='*', default=['tests'], help='Directory or directories where to find tests') - parser.add_argument('--testplan', default='feditest-default.json', help='Name of the file that contains the test plan to run') parser.add_argument('--nodedriversdir', action='append', help='Directory or directories where to find extra drivers for nodes that can be tested') parser.add_argument('--domain', type=hostname_validate, help='Local-only DNS domain for the DNS hostnames that are auto-generated for nodes') parser.add_argument('--interactive', action="store_true", help="Run the tests interactively") parser.add_argument('--who', action='store_true', help="Record who ran the test plan on what host.") + + # test plan options. We do not use argparse groups, as the situation is more complicated than argparse seems to support + parser.add_argument('--testplan', help='Name of the file that contains the test plan to run') + parser.add_argument('--constellation', nargs='+', help='File(s) each containing a JSON fragment defining a constellation') + parser.add_argument('--session', '--session-template', nargs='+', help='File(s) each containing a JSON fragment defining a test session') + parser.add_argument('--node', action='append', + help="Use role=file to specify that the node definition in 'file' is supposed to be used for constellation role 'role'") + parser.add_argument('--filter-regex', default=None, help='Only include tests whose name matches this regular expression') + parser.add_argument('--test', nargs='+', help='Run this/these named tests(s)') + + # output options parser.add_argument('--tap', nargs="?", const=True, default=False, help="Write results in TAP format to stdout, or to the provided file (if given).") html_group = parser.add_argument_group('html', 'HTML options') @@ -108,3 +141,108 @@ def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> None: help="Write summary to stdout, or to the provided file (if given). This is the default if no other output option is given") return parser + + +def _create_plan_from_testplan(args: Namespace) -> TestPlan: + if args.constellation: + raise ArgumentError(None, '--testplan already defines --constellation. Do not provide both.') + if args.session: + raise ArgumentError(None, '--testplan already defines --session-template. Do not provide both.') + if args.node: + raise ArgumentError(None, '--testplan already defines --node via the contained constellation. Do not provide both.') + if args.test: + raise ArgumentError(None, '--testplan already defines --test via the contained session. Do not provide both.') + plan = TestPlan.load(args.testplan) + return plan + + +def _create_session_templates(args: Namespace) -> list[TestPlanSession]: + if args.session: + if args.filter_regex: + raise ArgumentError(None, '--session already defines the tests, do not provide --filter-regex') + if args.test: + raise ArgumentError(None, '--session already defines --test. Do not provide both.') + session_templates = [] + for session_file in args.session: + session_templates.append(TestPlanSession.load(session_file)) + return session_templates + + test_plan_specs : list[TestPlanTestSpec]= [] + constellation_role_names : dict[str,Any] = {} + constellation_roles: dict[str,TestPlanConstellationNode | None] = {} + tests : list[Test]= [] + + if args.test: + if args.filter_regex: + raise ArgumentError(None, '--filter-regex already defines --test. Do not provide both.') + for name in args.test: + test = feditest.all_tests.get(name) + if test is None: + raise ArgumentError(None, f'Cannot find test: "{ name }".') + tests.append(test) + + elif args.filter_regex: + pattern = re.compile(args.filter_regex) + for name in sorted(feditest.all_tests.keys()): + if pattern.match(name): + test = feditest.all_tests.get(name) + if test is None: # make linter happy + continue + if test.builtin: + continue + tests.append(test) + + else: + for name in sorted(feditest.all_tests.keys()): + test = feditest.all_tests.get(name) + if test is None: # make linter happy + continue + if test.builtin: + continue + tests.append(test) + + for test in tests: + test_plan_spec = TestPlanTestSpec(name) + test_plan_specs.append(test_plan_spec) + + for role_name in test.needed_local_role_names(): + constellation_role_names[role_name] = 1 + if not test_plan_spec.rolemapping: + test_plan_spec.rolemapping = {} + test_plan_spec.rolemapping[role_name] = role_name + + for constellation_role_name in constellation_role_names: + constellation_roles[constellation_role_name] = None + + session = TestPlanSession(TestPlanConstellation(constellation_roles), test_plan_specs) + return [ session ] + + +def _create_constellations(args: Namespace) -> list[TestPlanConstellation]: + if args.constellation: + if args.node: + raise ArgumentError(None, '--constellation already defines --node. Do not provide both.') + + constellations = [] + for constellation_file in args.constellation: + try: + constellations.append(TestPlanConstellation.load(constellation_file)) + except ValidationError as e: + raise ArgumentError(None, f'Constellation file { constellation_file }: { e }') + return constellations + + # Don't check for empty nodes: we need that for testing feditest + roles : dict[str, TestPlanConstellationNode | None] = {} + for nodepair in args.node: + rolename, nodefile = nodepair.split('=', 1) + if not rolename: + raise ArgumentError(None, f'Rolename component of --node must not be empty: "{ nodepair }".') + if rolename in roles: + raise ArgumentError(None, f'Role is already taken: "{ rolename }".') + if not nodefile: + raise ArgumentError(None, f'Filename component must not be empty: "{ nodepair }".') + node = TestPlanConstellationNode.load(nodefile) + roles[rolename] = node + + constellation = TestPlanConstellation(roles) + return [ constellation ] diff --git a/src/feditest/tests.py b/src/feditest/tests.py index d0a133b..2e8bcc7 100644 --- a/src/feditest/tests.py +++ b/src/feditest/tests.py @@ -12,9 +12,10 @@ class Test(ABC): """ Captures the notion of a Test, such as "see whether a follower is told about a new post". """ - def __init__(self, name: str, description: str | None ) -> None: + def __init__(self, name: str, description: str | None, builtin: bool) -> None: self.name: str = name self.description: str | None = description + self._builtin: bool = builtin def __str__(self): @@ -35,12 +36,20 @@ def needed_local_role_names(self) -> set[str]: ... + @property + def builtin(self): + """ + If true, do not add this test to a test session when the session is created by collecting tests. + """ + return self._builtin + + class TestFromTestFunction(Test): """ A test that is defined as a single function. """ - def __init__(self, name: str, description: str | None, test_function: Callable[..., None]) -> None: - super().__init__(name, description) + def __init__(self, name: str, description: str | None, test_function: Callable[..., None], builtin: bool = False) -> None: + super().__init__(name, description, builtin) self.test_function = test_function @@ -80,8 +89,8 @@ def __str__(self): class TestFromTestClass(Test): - def __init__(self, name: str, description: str | None, clazz: type) -> None: - super().__init__(name, description) + def __init__(self, name: str, description: str | None, clazz: type, builtin: bool = False) -> None: + super().__init__(name, description, builtin) self.clazz = clazz self.steps : list[TestStepInTestClass] = [] diff --git a/tests.unit/feditest/README.md b/tests.unit/feditest/README.md new file mode 100644 index 0000000..7c9b532 --- /dev/null +++ b/tests.unit/feditest/README.md @@ -0,0 +1,55 @@ +# FediTest unit tests using FediTest itself + +## Files + +The files in this directory: + +### Session Templates + +`mastodon_api.session.json`: +: Tests our use of the Mastodon API against a single Node that implements `NodeWithMastodonAPI`, such as Mastodon or WordPress with plugins + +`mastodon_api_mastodon_api.session.json`: +: Simple tests that test whether two `NodeWithMastodonAPI`s can communicate. + +### Constellations + +`mastodon_mastodon.ubos.constellation.json`: +: A Constellation consisting of two instances of Mastodon, running on UBOS + +`mastodon.saas.constellation.json`: +: A (degenerate) Constellation consisting of only one instance of Mastodon that already runs at a public hostname + +`mastodon.ubos.constellation.json`: +: A (degenerate) Constellation consisting of only one instance of Mastodon, running on UBOS + +`wordpress_mastodon.ubos.constellation.json`: +: A Constellation consisting of one instance of Mastodon and one of WordPress with plugins, running on UBOS + +`wordpress.saas.constellation.json`: +: A (degenerate) Constellation consisting of only one instance of WordPress with plugins that already runs at a public hostname + +`wordpress.ubos.constellation.json`: +: A (degenerate) Constellation consisting of only one instance of WordPress with plugins, running on UBOS + +### Actual Tests + +`tests/node_with_mastodon_api.py`: +: Tests the Mastodon API + +`tests/nodes_with_mastodon_api_communicate.py`: +: Tests that two nodes with the Mastodon API can communicate + +## How to run + +Combine a session template with a constellation, such as: + +`feditest run --session mastodon_api.session.json --constellation mastodon.ubos.constellation.json` +: Runs the Mastodon API test against a single Mastodon node on UBOS + +`feditest run --session mastodon_api.session.json --constellation wordpress.ubos.constellation.json` +: Runs the Mastodon API test against a single WordPress plus plugins node on UBOS + +etc. + +If you invoke FediTest from any directory other than this one, make sure you specify a `--testsdir ` to the subdirectory `tests` so FediTest can find the test files. diff --git a/tests.unit/feditest/mastodon.saas.constellation.json b/tests.unit/feditest/mastodon.saas.constellation.json new file mode 100644 index 0000000..676f772 --- /dev/null +++ b/tests.unit/feditest/mastodon.saas.constellation.json @@ -0,0 +1,21 @@ +{ + "roles": { + "server": { + "nodedriver": "MastodonSaasNodeDriver", + "parameters": { + "hostname" : "testing.feditest.org", + "verify_api_tls_certificate" : false + }, + "accounts": [ + { + "userid" : "testing", + "password" : "qwerqwer", + "email": "testing@feditest.org" + } + ], + "non_existing_accounts": [ + ] + } + }, + "name": null +} \ No newline at end of file diff --git a/tests.unit/feditest/mastodon.ubos.constellation.json b/tests.unit/feditest/mastodon.ubos.constellation.json new file mode 100644 index 0000000..6ea22e0 --- /dev/null +++ b/tests.unit/feditest/mastodon.ubos.constellation.json @@ -0,0 +1,8 @@ +{ + "roles": { + "server": { + "nodedriver": "MastodonUbosNodeDriver" + } + }, + "name": null +} diff --git a/tests.unit/feditest/mastodon_api.session.json b/tests.unit/feditest/mastodon_api.session.json new file mode 100644 index 0000000..8879c62 --- /dev/null +++ b/tests.unit/feditest/mastodon_api.session.json @@ -0,0 +1,16 @@ +{ + "constellation": { + "roles": { + "server": null + }, + "name": null + }, + "tests": [ + { + "name": "node_with_mastodon_api::CreateNoteTest", + "rolemapping": null, + "skip": null + } + ], + "name": null +} diff --git a/tests.unit/feditest/mastodon_api_mastodon_api.session.json b/tests.unit/feditest/mastodon_api_mastodon_api.session.json new file mode 100644 index 0000000..b15bc06 --- /dev/null +++ b/tests.unit/feditest/mastodon_api_mastodon_api.session.json @@ -0,0 +1,17 @@ +{ + "constellation": { + "roles": { + "leader_node": null, + "follower_node": null + }, + "name": null + }, + "tests": [ + { + "name": "nodes_with_mastodon_api_communicate::FollowTest", + "rolemapping": null, + "skip": null + } + ], + "name": null +} diff --git a/tests.unit/feditest/mastodon_mastodon.ubos.constellation.json b/tests.unit/feditest/mastodon_mastodon.ubos.constellation.json new file mode 100644 index 0000000..54fa728 --- /dev/null +++ b/tests.unit/feditest/mastodon_mastodon.ubos.constellation.json @@ -0,0 +1,11 @@ +{ + "roles": { + "leader_node": { + "nodedriver": "MastodonUbosNodeDriver" + }, + "follower_node": { + "nodedriver": "MastodonUbosNodeDriver" + } + }, + "name": null +} \ No newline at end of file diff --git a/tests.unit/feditest/mastodon_mastodon_ubos.json b/tests.unit/feditest/mastodon_mastodon_ubos.json deleted file mode 100644 index a887168..0000000 --- a/tests.unit/feditest/mastodon_mastodon_ubos.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "sessions": [ - { - "constellation": { - "roles": { - "leader_node": { - "nodedriver": "MastodonUbosNodeDriver" - }, - "follower_node": { - "nodedriver": "MastodonUbosNodeDriver" - } - }, - "name": null - }, - "tests": [ - { - "name": "mastodon_mastodon::FollowTest", - "rolemapping": null, - "skip": null - } - ], - "name": null - } - ], - "name": null, - "type": "feditest-testplan", - "feditest_version": "0.3" -} \ No newline at end of file diff --git a/tests.unit/feditest/mastodon_saas_api.json b/tests.unit/feditest/mastodon_saas_api.json deleted file mode 100644 index 60380ca..0000000 --- a/tests.unit/feditest/mastodon_saas_api.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "sessions": [ - { - "constellation": { - "roles": { - "server": { - "nodedriver": "MastodonSaasNodeDriver", - "parameters": { - "hostname" : "testing.feditest.org", - "verify_api_tls_certificate" : false - }, - "accounts": [ - { - "userid" : "testing", - "password" : "qwerqwer", - "email": "testing@feditest.org" - } - ], - "non_existing_accounts": [ - ] - } - }, - "name": null - }, - "tests": [ - { - "name": "node_with_mastodon_api::CreateNoteTest", - "rolemapping": null, - "skip": null - } - ], - "name": null - } - ], - "name": null, - "type": "feditest-testplan", - "feditest_version": "0.3" -} \ No newline at end of file diff --git a/tests.unit/feditest/mastodon_ubos_api.json b/tests.unit/feditest/mastodon_ubos_api.json deleted file mode 100644 index 0e88599..0000000 --- a/tests.unit/feditest/mastodon_ubos_api.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "sessions": [ - { - "constellation": { - "roles": { - "server": { - "nodedriver": "MastodonUbosNodeDriver" - } - }, - "name": null - }, - "tests": [ - { - "name": "node_with_mastodon_api::CreateNoteTest", - "rolemapping": null, - "skip": null - } - ], - "name": null - } - ], - "name": null, - "type": "feditest-testplan", - "feditest_version": "0.3" -} \ No newline at end of file diff --git a/tests.unit/feditest/node_with_mastodon_api.py b/tests.unit/feditest/tests/node_with_mastodon_api.py similarity index 100% rename from tests.unit/feditest/node_with_mastodon_api.py rename to tests.unit/feditest/tests/node_with_mastodon_api.py diff --git a/tests.unit/feditest/mastodon_mastodon.py b/tests.unit/feditest/tests/nodes_with_mastodon_api_communicate.py similarity index 96% rename from tests.unit/feditest/mastodon_mastodon.py rename to tests.unit/feditest/tests/nodes_with_mastodon_api_communicate.py index 321fde1..c419f67 100644 --- a/tests.unit/feditest/mastodon_mastodon.py +++ b/tests.unit/feditest/tests/nodes_with_mastodon_api_communicate.py @@ -1,5 +1,5 @@ """ -Tests that two instances of Mastodon can follow each other. +Tests that two nodes that implement the Mastodon API can follow each other. """ from datetime import datetime diff --git a/tests.unit/feditest/wordpress.saas.constellation.json b/tests.unit/feditest/wordpress.saas.constellation.json new file mode 100644 index 0000000..c5381ea --- /dev/null +++ b/tests.unit/feditest/wordpress.saas.constellation.json @@ -0,0 +1,21 @@ +{ + "roles": { + "server": { + "nodedriver": "WordPressPlusActivityPubPluginSaasNodeDriver", + "parameters": { + "hostname" : "testing.feditest.org", + "verify_api_tls_certificate" : false + }, + "accounts": [ + { + "userid" : "testing", + "password" : "qwerqwer", + "email": "testing@feditest.org" + } + ], + "non_existing_accounts": [ + ] + } + }, + "name": null +} \ No newline at end of file diff --git a/tests.unit/feditest/wordpress.ubos.constellation.json b/tests.unit/feditest/wordpress.ubos.constellation.json new file mode 100644 index 0000000..b916578 --- /dev/null +++ b/tests.unit/feditest/wordpress.ubos.constellation.json @@ -0,0 +1,8 @@ +{ + "roles": { + "server": { + "nodedriver": "WordPressPlusActivityPubPluginUbosNodeDriver" + } + }, + "name": null +} \ No newline at end of file diff --git a/tests.unit/feditest/wordpress_mastodon.ubos.constellation.json b/tests.unit/feditest/wordpress_mastodon.ubos.constellation.json new file mode 100644 index 0000000..dedbcff --- /dev/null +++ b/tests.unit/feditest/wordpress_mastodon.ubos.constellation.json @@ -0,0 +1,11 @@ +{ + "roles": { + "leader_node": { + "nodedriver": "WordPressPlusActivityPubPluginSaasNodeDriver" + }, + "follower_node": { + "nodedriver": "MastodonUbosNodeDriver" + } + }, + "name": null +} \ No newline at end of file diff --git a/tests.unit/feditest/wordpress_ubos_api.json b/tests.unit/feditest/wordpress_ubos_api.json deleted file mode 100644 index f4ea3ac..0000000 --- a/tests.unit/feditest/wordpress_ubos_api.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "sessions": [ - { - "constellation": { - "roles": { - "server": { - "nodedriver": "WordPressPlusActivityPubPluginUbosNodeDriver" - } - }, - "name": null - }, - "tests": [ - { - "name": "node_with_mastodon_api::CreateNoteTest", - "rolemapping": null, - "skip": null - } - ], - "name": null - } - ], - "name": null, - "type": "feditest-testplan", - "feditest_version": "0.3" -} \ No newline at end of file From 1ee2ee5ead7b1ed38a39d6b668d7d4ec77f4438f Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Sat, 28 Sep 2024 11:31:07 -0700 Subject: [PATCH 5/5] Fix test issue #148 (#366) * Fixes for testing replies https://github.com/fediverse-devnet/feditest-tests-fediverse/issues/148 * Slight changes in parameter names and internal function names for more clarity * Remove Mastodon status_dict cache: it introduces potential errors and race conditions that we would have to work through * Better, more consistent debug messages --------- Co-authored-by: Johannes Ernst --- .../nodedrivers/fallback/fediverse.py | 11 +-- src/feditest/nodedrivers/mastodon/__init__.py | 68 +++++++++---------- src/feditest/protocols/fediverse/__init__.py | 6 +- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/feditest/nodedrivers/fallback/fediverse.py b/src/feditest/nodedrivers/fallback/fediverse.py index 57d6252..e6def58 100644 --- a/src/feditest/nodedrivers/fallback/fediverse.py +++ b/src/feditest/nodedrivers/fallback/fediverse.py @@ -217,16 +217,17 @@ def make_create_note(self, actor_uri: str, content: str, deliver_to: list[str] | # Python 3.12 @override - def make_announce_object(self, actor_uri, announced_object_uri: str) -> str: + def make_announce_object(self, actor_uri, to_be_announced_object_uri: str) -> str: return cast(str, self.prompt_user( - f'On FediverseNode "{ self.hostname }", make actor "{ actor_uri }" boost "{ announced_object_uri }"' - + ' and enter the Announce object\'s local URI:')) + f'On FediverseNode "{ self.hostname }", make actor "{ actor_uri }" boost "{ to_be_announced_object_uri }"' + + ' and enter the Announce object\'s local URI:', + parse_validate=https_uri_validate)) # Python 3.12 @override - def make_reply_note(self, actor_uri, replied_object_uri: str, reply_content: str) -> str: + def make_reply_note(self, actor_uri, to_be_replied_to_object_uri: str, reply_content: str) -> str: return cast(str, self.prompt_user( - f'On FediverseNode "{ self.hostname }", make actor "{ actor_uri }" reply to object with "{ replied_object_uri }"' + f'On FediverseNode "{ self.hostname }", make actor "{ actor_uri }" reply to object with "{ to_be_replied_to_object_uri }"' + ' and enter the Announce object\'s URI when created.' + f' Reply content:"""\n{ reply_content }\n"""' )) diff --git a/src/feditest/nodedrivers/mastodon/__init__.py b/src/feditest/nodedrivers/mastodon/__init__.py index 3955d91..3c5444c 100644 --- a/src/feditest/nodedrivers/mastodon/__init__.py +++ b/src/feditest/nodedrivers/mastodon/__init__.py @@ -297,11 +297,6 @@ def __init__(self, rolename: str, config: NodeConfiguration, account_manager: Ac # The request.Session with the custom certificate authority set as verifier. # Allocated when needed, so our custom certificate authority has been created before this is used. - self._status_dict_by_uri: dict[str, dict[str,Any]] = {} - # Maps URIs of created status objects to the corresponding Mastodon.py "status dicts" - # We keep this around so we can look up id attributes by URI and we can map the FediverseNode API URI parameters - # to Mastodon's internal status ids - self._auto_accept_follow = auto_accept_follow # True is default for Mastodon @@ -314,43 +309,38 @@ def make_create_note(self, actor_uri: str, content: str, deliver_to: list[str] | if deliver_to: # The only way we can address specific accounts in Mastodon for to in deliver_to: - if to_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to): + if to_account := self._find_account_dict_by_other_actor_uri(mastodon_client, to): to_handle = f"@{to_account.acct}" content += f" {to_handle}" else: - raise ValueError(f'Cannot find account for Actor { to }') + raise ValueError(f'Cannot find account for Actor on { self }: "{ to }"') response = mastodon_client.status_post(content) - self._status_dict_by_uri[response.uri] = response trace(f'make_create_note returns with { response }') return response.uri # Python 3.12 @override - def make_announce_object(self, actor_uri, announced_object_uri: str) -> str: + def make_announce_object(self, actor_uri, to_be_announced_object_uri: str) -> str: trace('make_announce_object:') mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) - # FIXME: the URI could be remote, right? - if note := self._status_dict_by_uri.get(announced_object_uri): - reblog = mastodon_client.status_reblog(note['id']) - self._status_dict_by_uri[reblog.uri] = reblog + + if local_note := self._find_note_dict_by_uri(mastodon_client, to_be_announced_object_uri): + reblog = mastodon_client.status_reblog(local_note) trace(f'make_announce_object returns with { reblog }') return reblog.uri - raise ValueError(f'Note URI not found: { announced_object_uri }') + raise ValueError(f'Cannot find Note on { self } : "{ to_be_announced_object_uri }"') # Python 3.12 @override - def make_reply_note(self, actor_uri, replied_object_uri: str, reply_content: str) -> str: + def make_reply_note(self, actor_uri, to_be_replied_to_object_uri: str, reply_content: str) -> str: trace('make_reply_note:') mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) - # FIXME: the URI could be remote, right? - if note := self._status_dict_by_uri.get(replied_object_uri): - reply = mastodon_client.status_reply( - to_status=note, status=reply_content - ) - self._status_dict_by_uri[reply.uri] = reply + if local_note := self._find_note_dict_by_uri(mastodon_client, to_be_replied_to_object_uri): + reply = mastodon_client.status_reply(to_status=local_note, status=reply_content) trace(f'make_reply returns with { reply }') return reply.uri - raise ValueError(f'Note URI not found: { replied_object_uri }') + + raise ValueError(f'Cannot find Note on { self }: "{ to_be_replied_to_object_uri }"') # Python 3.12 @override @@ -358,10 +348,10 @@ def make_follow(self, actor_uri: str, to_follow_actor_uri: str) -> None: trace('make_follow:') mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) - if to_follow_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to_follow_actor_uri): + if to_follow_account := self._find_account_dict_by_other_actor_uri(mastodon_client, to_follow_actor_uri): relationship = mastodon_client.account_follow(to_follow_account) # noqa: F841 return - raise ValueError(f'Account not found with Actor URI: { to_follow_actor_uri }') + raise ValueError(f'Cannot find account for Actor on { self }: "{ to_follow_actor_uri }"') # Python 3.12 @override @@ -425,14 +415,14 @@ def wait_until_actor_is_following_actor(self, actor_uri: str, to_be_followed_uri trace(f'wait_until_actor_is_following_actor: actor_uri = { actor_uri }, to_be_followed_uri = { to_be_followed_uri }') mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) - if to_be_followed_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to_be_followed_uri): + if to_be_followed_account := self._find_account_dict_by_other_actor_uri(mastodon_client, to_be_followed_uri): self._poll_until_result( # may throw lambda: self._is_following(mastodon_client, to_be_followed_account), int(max_wait), 1.0, f'Actor { actor_uri } is not following { to_be_followed_uri }') return - raise ValueError(f'Account not found with Actor URI: { to_be_followed_uri }') + raise ValueError(f'Cannot find account on { self }: "{ to_be_followed_uri }"') # Python 3.12 @override @@ -440,14 +430,14 @@ def wait_until_actor_is_followed_by_actor(self, actor_uri: str, to_be_following_ trace(f'wait_until_actor_is_followed_by_actor: actor_uri = { actor_uri }, to_be_followed_uri = { to_be_following_uri }') mastodon_client = self._get_mastodon_client_by_actor_uri(actor_uri) - if to_be_following_account := self._get_account_dict_by_other_actor_uri(mastodon_client, to_be_following_uri): + if to_be_following_account := self._find_account_dict_by_other_actor_uri(mastodon_client, to_be_following_uri): self._poll_until_result( # may throw lambda: self._is_followed_by(mastodon_client, to_be_following_account), int(max_wait), 1.0, f'Actor { actor_uri } is not followed by { to_be_following_uri }') return - raise ValueError(f'Account not found with Actor URI: { to_be_following_uri }') + raise ValueError(f'Cannot find account on { self }: "{ to_be_following_uri }"') # Python 3.12 @override @@ -505,7 +495,7 @@ def assert_member_of_collection_at( raise AssertionFailure( spec_level or SpecLevel.UNSPECIFIED, interop_level or InteropLevel.UNKNOWN, - f"{candidate_member_uri} not in {collection_uri}") + f"Node { self }: {candidate_member_uri} not in {collection_uri}") # Python 3.12 @override @@ -521,7 +511,7 @@ def assert_not_member_of_collection_at( raise AssertionFailure( spec_level or SpecLevel.UNSPECIFIED, interop_level or InteropLevel.UNKNOWN, - f"{candidate_member_uri} must not be in {collection_uri}") + f"Node { self }: {candidate_member_uri} must not be in {collection_uri}") # From WebFingerServer @@ -609,7 +599,7 @@ def _get_mastodon_client_by_actor_uri(self, actor_uri: str) -> Mastodon: config = cast(NodeWithMastodonApiConfiguration, self.config) userid = self._actor_uri_to_userid(actor_uri) if not userid: - raise ValueError(f'Cannot find actor { actor_uri }') + raise ValueError(f'Cannot find Actor on { self }: "{ actor_uri }"') if self._requests_session is None: self._requests_session = requests.Session() @@ -629,9 +619,9 @@ def _get_mastodon_client_by_actor_uri(self, actor_uri: str) -> Mastodon: return ret - def _get_account_dict_by_other_actor_uri(self, mastodon_client: Mastodon, other_actor_uri) -> AttribAccessDict | None: + def _find_account_dict_by_other_actor_uri(self, mastodon_client: Mastodon, other_actor_uri: str) -> AttribAccessDict | None: """ - Using the specified Mastodon client, find an account dict for another Actor on this Node with + Using the specified Mastodon client, find an account dict for another Actor with other_actor_uri, or None. """ results = mastodon_client.search(q=other_actor_uri, result_type="accounts") @@ -639,6 +629,16 @@ def _get_account_dict_by_other_actor_uri(self, mastodon_client: Mastodon, other_ return ret + def _find_note_dict_by_uri(self, mastodon_client: Mastodon, uri: str) -> AttribAccessDict | None: + """ + Using the specified Mastodon client, find an account dict for another Actor with + other_actor_uri, or None. + """ + results = mastodon_client.search(q=uri, result_type="statuses") + ret = find_first_in_array(results.get("statuses"), lambda b: b.uri == uri) + return ret + + def _is_following(self, mastodon_client: Mastodon, candidate_leader: AttribAccessDict) -> bool: """ Determine whether the Actor of the specified Mastodon client is following the candidate_leader. @@ -696,7 +696,7 @@ def _actor_uri_to_userid(self, actor_uri: str) -> str: if m:= re.match('^https://([^/]+)/users/(.+)$', actor_uri): if m.group(1) == self._config.hostname: return m.group(2) - raise ValueError( f'Cannot find actor at this node: { actor_uri }' ) + raise ValueError( f'Cannot find Actor on { self }: "{ actor_uri }"' ) class MastodonSaasNodeDriver(NodeDriver): diff --git a/src/feditest/protocols/fediverse/__init__.py b/src/feditest/protocols/fediverse/__init__.py index f1cd63d..c9ac2b3 100644 --- a/src/feditest/protocols/fediverse/__init__.py +++ b/src/feditest/protocols/fediverse/__init__.py @@ -27,7 +27,7 @@ def make_create_note(self, actor_uri: str, content: str, deliver_to: list[str] | raise NotImplementedByNodeError(self, FediverseNode.make_create_note) - def make_announce_object(self, actor_uri, announced_object_uri: str) -> str: + def make_announce_object(self, actor_uri, to_be_announced_object_uri: str) -> str: """ Perform whatever actions are necessary so the actor with actor_uri will have Announced on this Node, the object with announced_object_uri. @@ -36,10 +36,10 @@ def make_announce_object(self, actor_uri, announced_object_uri: str) -> str: raise NotImplementedByNodeError(self, FediverseNode.make_announce_object) - def make_reply_note(self, actor_uri, replied_object_uri: str, reply_content: str) -> str: + def make_reply_note(self, actor_uri, to_be_replied_to_object_uri: str, reply_content: str) -> str: """ Perform whatever actions are necessary so the actor with actor_uri will have created - a Note object that replies to the object at replied_object_uri with the specified content. + a Note object that replies to the object at to_be_replied_to_object_uri with the specified content. return: URI to the Reply object """ raise NotImplementedByNodeError(self, FediverseNode.make_reply_note)