Add module API callbacks for adding and deleting local 3PID associations (#15044

pull/13083/head
Andrew Morgan 2023-02-27 14:19:19 +00:00 committed by GitHub
parent 4fc8875876
commit b40657314e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 324 additions and 48 deletions

View File

@ -0,0 +1 @@
Add two new Third Party Rules module API callbacks: [`on_add_user_third_party_identifier`](https://matrix-org.github.io/synapse/v1.79/modules/third_party_rules_callbacks.html#on_add_user_third_party_identifier) and [`on_remove_user_third_party_identifier`](https://matrix-org.github.io/synapse/v1.79/modules/third_party_rules_callbacks.html#on_remove_user_third_party_identifier).

View File

@ -254,6 +254,11 @@ If multiple modules implement this callback, Synapse runs them all in order.
_First introduced in Synapse v1.56.0_ _First introduced in Synapse v1.56.0_
**<span style="color:red">
This callback is deprecated in favour of the `on_add_user_third_party_identifier` callback, which
features the same functionality. The only difference is in name.
</span>**
```python ```python
async def on_threepid_bind(user_id: str, medium: str, address: str) -> None: async def on_threepid_bind(user_id: str, medium: str, address: str) -> None:
``` ```
@ -268,6 +273,44 @@ server_.
If multiple modules implement this callback, Synapse runs them all in order. If multiple modules implement this callback, Synapse runs them all in order.
### `on_add_user_third_party_identifier`
_First introduced in Synapse v1.79.0_
```python
async def on_add_user_third_party_identifier(user_id: str, medium: str, address: str) -> None:
```
Called after successfully creating an association between a user and a third-party identifier
(email address, phone number). The module is given the Matrix ID of the user the
association is for, as well as the medium (`email` or `msisdn`) and address of the
third-party identifier (i.e. an email address).
Note that this callback is _not_ called if a user attempts to bind their third-party identifier
to an identity server (via a call to [`POST
/_matrix/client/v3/account/3pid/bind`](https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3account3pidbind)).
If multiple modules implement this callback, Synapse runs them all in order.
### `on_remove_user_third_party_identifier`
_First introduced in Synapse v1.79.0_
```python
async def on_remove_user_third_party_identifier(user_id: str, medium: str, address: str) -> None:
```
Called after successfully removing an association between a user and a third-party identifier
(email address, phone number). The module is given the Matrix ID of the user the
association is for, as well as the medium (`email` or `msisdn`) and address of the
third-party identifier (i.e. an email address).
Note that this callback is _not_ called if a user attempts to unbind their third-party
identifier from an identity server (via a call to [`POST
/_matrix/client/v3/account/3pid/unbind`](https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3account3pidunbind)).
If multiple modules implement this callback, Synapse runs them all in order.
## Example ## Example
The example below is a module that implements the third-party rules callback The example below is a module that implements the third-party rules callback

View File

@ -88,6 +88,30 @@ process, for example:
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
``` ```
# Upgrading to v1.79.0
## The `on_threepid_bind` module callback method has been deprecated
Synapse v1.79.0 deprecates the
[`on_threepid_bind`](modules/third_party_rules_callbacks.md#on_threepid_bind)
"third-party rules" Synapse module callback method in favour of a new module method,
[`on_add_user_third_party_identifier`](modules/third_party_rules_callbacks.md#on_add_user_third_party_identifier).
`on_threepid_bind` will be removed in a future version of Synapse. You should check whether any Synapse
modules in use in your deployment are making use of `on_threepid_bind`, and update them where possible.
The arguments and functionality of the new method are the same.
The justification behind the name change is that the old method's name, `on_threepid_bind`, was
misleading. A user is considered to "bind" their third-party ID to their Matrix ID only if they
do so via an [identity server](https://spec.matrix.org/latest/identity-service-api/)
(so that users on other homeservers may find them). But this method was not called in that case -
it was only called when a user added a third-party identifier on the local homeserver.
Module developers may also be interested in the related
[`on_remove_user_third_party_identifier`](modules/third_party_rules_callbacks.md#on_remove_user_third_party_identifier)
module callback method that was also added in Synapse v1.79.0. This new method is called when a
user removes a third-party identifier from their account.
# Upgrading to v1.78.0 # Upgrading to v1.78.0
## Deprecate the `/_synapse/admin/v1/media/<server_name>/delete` admin API ## Deprecate the `/_synapse/admin/v1/media/<server_name>/delete` admin API

View File

@ -45,6 +45,8 @@ CHECK_CAN_DEACTIVATE_USER_CALLBACK = Callable[[str, bool], Awaitable[bool]]
ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool, bool], Awaitable] ON_PROFILE_UPDATE_CALLBACK = Callable[[str, ProfileInfo, bool, bool], Awaitable]
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable] ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK = Callable[[str, bool, bool], Awaitable]
ON_THREEPID_BIND_CALLBACK = Callable[[str, str, str], Awaitable] ON_THREEPID_BIND_CALLBACK = Callable[[str, str, str], Awaitable]
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK = Callable[[str, str, str], Awaitable]
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK = Callable[[str, str, str], Awaitable]
def load_legacy_third_party_event_rules(hs: "HomeServer") -> None: def load_legacy_third_party_event_rules(hs: "HomeServer") -> None:
@ -172,6 +174,12 @@ class ThirdPartyEventRules:
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = [] ] = []
self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = [] self._on_threepid_bind_callbacks: List[ON_THREEPID_BIND_CALLBACK] = []
self._on_add_user_third_party_identifier_callbacks: List[
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = []
self._on_remove_user_third_party_identifier_callbacks: List[
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = []
def register_third_party_rules_callbacks( def register_third_party_rules_callbacks(
self, self,
@ -191,6 +199,12 @@ class ThirdPartyEventRules:
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = None, ] = None,
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None,
on_add_user_third_party_identifier: Optional[
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = None,
on_remove_user_third_party_identifier: Optional[
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = None,
) -> None: ) -> None:
"""Register callbacks from modules for each hook.""" """Register callbacks from modules for each hook."""
if check_event_allowed is not None: if check_event_allowed is not None:
@ -228,6 +242,11 @@ class ThirdPartyEventRules:
if on_threepid_bind is not None: if on_threepid_bind is not None:
self._on_threepid_bind_callbacks.append(on_threepid_bind) self._on_threepid_bind_callbacks.append(on_threepid_bind)
if on_add_user_third_party_identifier is not None:
self._on_add_user_third_party_identifier_callbacks.append(
on_add_user_third_party_identifier
)
async def check_event_allowed( async def check_event_allowed(
self, self,
event: EventBase, event: EventBase,
@ -511,6 +530,9 @@ class ThirdPartyEventRules:
local homeserver, not when it's created on an identity server (and then kept track local homeserver, not when it's created on an identity server (and then kept track
of so that it can be unbound on the same IS later on). of so that it can be unbound on the same IS later on).
THIS MODULE CALLBACK METHOD HAS BEEN DEPRECATED. Please use the
`on_add_user_third_party_identifier` callback method instead.
Args: Args:
user_id: the user being associated with the threepid. user_id: the user being associated with the threepid.
medium: the threepid's medium. medium: the threepid's medium.
@ -523,3 +545,44 @@ class ThirdPartyEventRules:
logger.exception( logger.exception(
"Failed to run module API callback %s: %s", callback, e "Failed to run module API callback %s: %s", callback, e
) )
async def on_add_user_third_party_identifier(
self, user_id: str, medium: str, address: str
) -> None:
"""Called when an association between a user's Matrix ID and a third-party ID
(email, phone number) has successfully been registered on the homeserver.
Args:
user_id: The User ID included in the association.
medium: The medium of the third-party ID (email, msisdn).
address: The address of the third-party ID (i.e. an email address).
"""
for callback in self._on_add_user_third_party_identifier_callbacks:
try:
await callback(user_id, medium, address)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)
async def on_remove_user_third_party_identifier(
self, user_id: str, medium: str, address: str
) -> None:
"""Called when an association between a user's Matrix ID and a third-party ID
(email, phone number) has been successfully removed on the homeserver.
This is called *after* any known bindings on identity servers for this
association have been removed.
Args:
user_id: The User ID included in the removed association.
medium: The medium of the third-party ID (email, msisdn).
address: The address of the third-party ID (i.e. an email address).
"""
for callback in self._on_remove_user_third_party_identifier_callbacks:
try:
await callback(user_id, medium, address)
except Exception as e:
logger.exception(
"Failed to run module API callback %s: %s", callback, e
)

View File

@ -1542,6 +1542,17 @@ class AuthHandler:
async def add_threepid( async def add_threepid(
self, user_id: str, medium: str, address: str, validated_at: int self, user_id: str, medium: str, address: str, validated_at: int
) -> None: ) -> None:
"""
Adds an association between a user's Matrix ID and a third-party ID (email,
phone number).
Args:
user_id: The ID of the user to associate.
medium: The medium of the third-party ID (email, msisdn).
address: The address of the third-party ID (i.e. an email address).
validated_at: The timestamp in ms of when the validation that the user owns
this third-party ID occurred.
"""
# check if medium has a valid value # check if medium has a valid value
if medium not in ["email", "msisdn"]: if medium not in ["email", "msisdn"]:
raise SynapseError( raise SynapseError(
@ -1566,42 +1577,44 @@ class AuthHandler:
user_id, medium, address, validated_at, self.hs.get_clock().time_msec() user_id, medium, address, validated_at, self.hs.get_clock().time_msec()
) )
# Inform Synapse modules that a 3PID association has been created.
await self._third_party_rules.on_add_user_third_party_identifier(
user_id, medium, address
)
# Deprecated method for informing Synapse modules that a 3PID association
# has successfully been created.
await self._third_party_rules.on_threepid_bind(user_id, medium, address) await self._third_party_rules.on_threepid_bind(user_id, medium, address)
async def delete_threepid( async def delete_local_threepid(
self, user_id: str, medium: str, address: str, id_server: Optional[str] = None self, user_id: str, medium: str, address: str
) -> bool: ) -> None:
"""Attempts to unbind the 3pid on the identity servers and deletes it """Deletes an association between a third-party ID and a user ID from the local
from the local database. database. This method does not unbind the association from any identity servers.
If `medium` is 'email' and a pusher is associated with this third-party ID, the
pusher will also be deleted.
Args: Args:
user_id: ID of user to remove the 3pid from. user_id: ID of user to remove the 3pid from.
medium: The medium of the 3pid being removed: "email" or "msisdn". medium: The medium of the 3pid being removed: "email" or "msisdn".
address: The 3pid address to remove. address: The 3pid address to remove.
id_server: Use the given identity server when unbinding
any threepids. If None then will attempt to unbind using the
identity server specified when binding (if known).
Returns:
Returns True if successfully unbound the 3pid on
the identity server, False if identity server doesn't support the
unbind API.
""" """
# 'Canonicalise' email addresses as per above # 'Canonicalise' email addresses as per above
if medium == "email": if medium == "email":
address = canonicalise_email(address) address = canonicalise_email(address)
result = await self.hs.get_identity_handler().try_unbind_threepid( await self.store.user_delete_threepid(user_id, medium, address)
user_id, medium, address, id_server
# Inform Synapse modules that a 3PID association has been deleted.
await self._third_party_rules.on_remove_user_third_party_identifier(
user_id, medium, address
) )
await self.store.user_delete_threepid(user_id, medium, address)
if medium == "email": if medium == "email":
await self.store.delete_pusher_by_app_id_pushkey_user_id( await self.store.delete_pusher_by_app_id_pushkey_user_id(
app_id="m.email", pushkey=address, user_id=user_id app_id="m.email", pushkey=address, user_id=user_id
) )
return result
async def hash(self, password: str) -> str: async def hash(self, password: str) -> str:
"""Computes a secure hash of password. """Computes a secure hash of password.

View File

@ -100,26 +100,28 @@ class DeactivateAccountHandler:
# unbinding # unbinding
identity_server_supports_unbinding = True identity_server_supports_unbinding = True
# Retrieve the 3PIDs this user has bound to an identity server # Attempt to unbind any known bound threepids to this account from identity
threepids = await self.store.user_get_bound_threepids(user_id) # server(s).
bound_threepids = await self.store.user_get_bound_threepids(user_id)
for threepid in threepids: for threepid in bound_threepids:
try: try:
result = await self._identity_handler.try_unbind_threepid( result = await self._identity_handler.try_unbind_threepid(
user_id, threepid["medium"], threepid["address"], id_server user_id, threepid["medium"], threepid["address"], id_server
) )
identity_server_supports_unbinding &= result
except Exception: except Exception:
# Do we want this to be a fatal error or should we carry on? # Do we want this to be a fatal error or should we carry on?
logger.exception("Failed to remove threepid from ID server") logger.exception("Failed to remove threepid from ID server")
raise SynapseError(400, "Failed to remove threepid from ID server") raise SynapseError(400, "Failed to remove threepid from ID server")
await self.store.user_delete_threepid(
identity_server_supports_unbinding &= result
# Remove any local threepid associations for this account.
local_threepids = await self.store.user_get_threepids(user_id)
for threepid in local_threepids:
await self._auth_handler.delete_local_threepid(
user_id, threepid["medium"], threepid["address"] user_id, threepid["medium"], threepid["address"]
) )
# Remove all 3PIDs this user has bound to the homeserver
await self.store.user_delete_threepids(user_id)
# delete any devices belonging to the user, which will also # delete any devices belonging to the user, which will also
# delete corresponding access tokens. # delete corresponding access tokens.
await self._device_handler.delete_all_devices_for_user(user_id) await self._device_handler.delete_all_devices_for_user(user_id)

View File

@ -64,9 +64,11 @@ from synapse.events.third_party_rules import (
CHECK_EVENT_ALLOWED_CALLBACK, CHECK_EVENT_ALLOWED_CALLBACK,
CHECK_THREEPID_CAN_BE_INVITED_CALLBACK, CHECK_THREEPID_CAN_BE_INVITED_CALLBACK,
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK, CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK,
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK,
ON_CREATE_ROOM_CALLBACK, ON_CREATE_ROOM_CALLBACK,
ON_NEW_EVENT_CALLBACK, ON_NEW_EVENT_CALLBACK,
ON_PROFILE_UPDATE_CALLBACK, ON_PROFILE_UPDATE_CALLBACK,
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK,
ON_THREEPID_BIND_CALLBACK, ON_THREEPID_BIND_CALLBACK,
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK,
) )
@ -357,6 +359,12 @@ class ModuleApi:
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK
] = None, ] = None,
on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None, on_threepid_bind: Optional[ON_THREEPID_BIND_CALLBACK] = None,
on_add_user_third_party_identifier: Optional[
ON_ADD_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = None,
on_remove_user_third_party_identifier: Optional[
ON_REMOVE_USER_THIRD_PARTY_IDENTIFIER_CALLBACK
] = None,
) -> None: ) -> None:
"""Registers callbacks for third party event rules capabilities. """Registers callbacks for third party event rules capabilities.
@ -373,6 +381,8 @@ class ModuleApi:
on_profile_update=on_profile_update, on_profile_update=on_profile_update,
on_user_deactivation_status_changed=on_user_deactivation_status_changed, on_user_deactivation_status_changed=on_user_deactivation_status_changed,
on_threepid_bind=on_threepid_bind, on_threepid_bind=on_threepid_bind,
on_add_user_third_party_identifier=on_add_user_third_party_identifier,
on_remove_user_third_party_identifier=on_remove_user_third_party_identifier,
) )
def register_presence_router_callbacks( def register_presence_router_callbacks(

View File

@ -304,13 +304,20 @@ class UserRestServletV2(RestServlet):
# remove old threepids # remove old threepids
for medium, address in del_threepids: for medium, address in del_threepids:
try: try:
await self.auth_handler.delete_threepid( # Attempt to remove any known bindings of this third-party ID
user_id, medium, address, None # and user ID from identity servers.
await self.hs.get_identity_handler().try_unbind_threepid(
user_id, medium, address, id_server=None
) )
except Exception: except Exception:
logger.exception("Failed to remove threepids") logger.exception("Failed to remove threepids")
raise SynapseError(500, "Failed to remove threepids") raise SynapseError(500, "Failed to remove threepids")
# Delete the local association of this user ID and third-party ID.
await self.auth_handler.delete_local_threepid(
user_id, medium, address
)
# add new threepids # add new threepids
current_time = self.hs.get_clock().time_msec() current_time = self.hs.get_clock().time_msec()
for medium, address in add_threepids: for medium, address in add_threepids:

View File

@ -768,7 +768,9 @@ class ThreepidDeleteRestServlet(RestServlet):
user_id = requester.user.to_string() user_id = requester.user.to_string()
try: try:
ret = await self.auth_handler.delete_threepid( # Attempt to remove any known bindings of this third-party ID
# and user ID from identity servers.
ret = await self.hs.get_identity_handler().try_unbind_threepid(
user_id, body.medium, body.address, body.id_server user_id, body.medium, body.address, body.id_server
) )
except Exception: except Exception:
@ -783,6 +785,11 @@ class ThreepidDeleteRestServlet(RestServlet):
else: else:
id_server_unbind_result = "no-support" id_server_unbind_result = "no-support"
# Delete the local association of this user ID and third-party ID.
await self.auth_handler.delete_local_threepid(
user_id, body.medium, body.address
)
return 200, {"id_server_unbind_result": id_server_unbind_result} return 200, {"id_server_unbind_result": id_server_unbind_result}

View File

@ -1002,19 +1002,6 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
desc="user_delete_threepid", desc="user_delete_threepid",
) )
async def user_delete_threepids(self, user_id: str) -> None:
"""Delete all threepid this user has bound
Args:
user_id: The user id to delete all threepids of
"""
await self.db_pool.simple_delete(
"user_threepids",
keyvalues={"user_id": user_id},
desc="user_delete_threepids",
)
async def add_user_bound_threepid( async def add_user_bound_threepid(
self, user_id: str, medium: str, address: str, id_server: str self, user_id: str, medium: str, address: str, id_server: str
) -> None: ) -> None:

View File

@ -369,10 +369,8 @@ class EmailPusherTests(HomeserverTestCase):
# disassociate the user's email address # disassociate the user's email address
self.get_success( self.get_success(
self.auth_handler.delete_threepid( self.auth_handler.delete_local_threepid(
user_id=self.user_id, user_id=self.user_id, medium="email", address="a@example.com"
medium="email",
address="a@example.com",
) )
) )

View File

@ -934,3 +934,124 @@ class ThirdPartyRulesTestCase(unittest.FederatingHomeserverTestCase):
# Check that the mock was called with the right parameters # Check that the mock was called with the right parameters
self.assertEqual(args, (user_id, "email", "foo@example.com")) self.assertEqual(args, (user_id, "email", "foo@example.com"))
def test_on_add_and_remove_user_third_party_identifier(self) -> None:
"""Tests that the on_add_user_third_party_identifier and
on_remove_user_third_party_identifier module callbacks are called
just before associating and removing a 3PID to/from an account.
"""
# Pretend to be a Synapse module and register both callbacks as mocks.
third_party_rules = self.hs.get_third_party_event_rules()
on_add_user_third_party_identifier_callback_mock = Mock(
return_value=make_awaitable(None)
)
on_remove_user_third_party_identifier_callback_mock = Mock(
return_value=make_awaitable(None)
)
third_party_rules._on_threepid_bind_callbacks.append(
on_add_user_third_party_identifier_callback_mock
)
third_party_rules._on_threepid_bind_callbacks.append(
on_remove_user_third_party_identifier_callback_mock
)
# Register an admin user.
self.register_user("admin", "password", admin=True)
admin_tok = self.login("admin", "password")
# Also register a normal user we can modify.
user_id = self.register_user("user", "password")
# Add a 3PID to the user.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{
"threepids": [
{
"medium": "email",
"address": "foo@example.com",
},
],
},
access_token=admin_tok,
)
# Check that the mocked add callback was called with the appropriate
# 3PID details.
self.assertEqual(channel.code, 200, channel.json_body)
on_add_user_third_party_identifier_callback_mock.assert_called_once()
args = on_add_user_third_party_identifier_callback_mock.call_args[0]
self.assertEqual(args, (user_id, "email", "foo@example.com"))
# Now remove the 3PID from the user
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{
"threepids": [],
},
access_token=admin_tok,
)
# Check that the mocked remove callback was called with the appropriate
# 3PID details.
self.assertEqual(channel.code, 200, channel.json_body)
on_remove_user_third_party_identifier_callback_mock.assert_called_once()
args = on_remove_user_third_party_identifier_callback_mock.call_args[0]
self.assertEqual(args, (user_id, "email", "foo@example.com"))
def test_on_remove_user_third_party_identifier_is_called_on_deactivate(
self,
) -> None:
"""Tests that the on_remove_user_third_party_identifier module callback is called
when a user is deactivated and their third-party ID associations are deleted.
"""
# Pretend to be a Synapse module and register both callbacks as mocks.
third_party_rules = self.hs.get_third_party_event_rules()
on_remove_user_third_party_identifier_callback_mock = Mock(
return_value=make_awaitable(None)
)
third_party_rules._on_threepid_bind_callbacks.append(
on_remove_user_third_party_identifier_callback_mock
)
# Register an admin user.
self.register_user("admin", "password", admin=True)
admin_tok = self.login("admin", "password")
# Also register a normal user we can modify.
user_id = self.register_user("user", "password")
# Add a 3PID to the user.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{
"threepids": [
{
"medium": "email",
"address": "foo@example.com",
},
],
},
access_token=admin_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Now deactivate the user.
channel = self.make_request(
"PUT",
"/_synapse/admin/v2/users/%s" % user_id,
{
"deactivated": True,
},
access_token=admin_tok,
)
# Check that the mocked remove callback was called with the appropriate
# 3PID details.
self.assertEqual(channel.code, 200, channel.json_body)
on_remove_user_third_party_identifier_callback_mock.assert_called_once()
args = on_remove_user_third_party_identifier_callback_mock.call_args[0]
self.assertEqual(args, (user_id, "email", "foo@example.com"))