Skip to content

Commit

Permalink
Make progress on WordPress+ActivityPubPlugin. Not working yet. (#355)
Browse files Browse the repository at this point in the history
* 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 <git@j12t.org>
  • Loading branch information
jernst and Johannes Ernst authored Sep 28, 2024
1 parent 5995135 commit 430e38e
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 48 deletions.
41 changes: 24 additions & 17 deletions src/feditest/nodedrivers/mastodon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down
169 changes: 152 additions & 17 deletions src/feditest/nodedrivers/wordpress/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
Loading

0 comments on commit 430e38e

Please sign in to comment.