Merge commit '01af446be7cbd63b38f2fe35c2c9a25fac4fdef8' as 'modules/restricted-guests/synapse'
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# ignore all files except the build folder
|
||||
*
|
||||
!/synapse_guest_module/*.py
|
||||
@@ -0,0 +1,45 @@
|
||||
# @nordeck/synapse-guest-module
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 5539790: Guest users are now only allowed in Ask to Join rooms
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- a05ce45: First stable release.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 344b7bf: Update the repository name
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 27680cd: Provide a dedicated registration endpoint to not interfere with the original
|
||||
registration endpoint.
|
||||
- 997f47b: Deactivate guest users after a configured expiration time.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 00c92e0: Don't include guest users in the search results.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b43b066: Disable certain homeserver-wide actions (create room, invite user) for guest users.
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add initial version.
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /src
|
||||
ADD synapse_guest_module /src/synapse_guest_module
|
||||
|
||||
CMD ["cp", "-r", "/src/synapse_guest_module", "/modules"]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Synapse Guest Module
|
||||
|
||||
A [pluggable synapse module](https://matrix-org.github.io/synapse/latest/modules/index.html) to restrict the actions of guests.
|
||||
|
||||
**Features:**
|
||||
|
||||
1. Provides an endpoint that creates temporary users with a same pattern (default: `guest-[randomstring]`).
|
||||
2. The temporary users have a mandatory displayname suffix (default: ` (Guest)`) that they can't remove from their profile.
|
||||
3. The temporary users are limited in what they can do (examples: create room, invite users).
|
||||
4. The temporary users won't be returned by the user directory search results.
|
||||
5. The temporary users are disabled after an expiration timeout (default: `24 hours`).
|
||||
|
||||
## Synapse configuration
|
||||
|
||||
This modules requires that the homeserver has the following configuration in their `homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
# Required so Element is able to show the room preview where the user can login.
|
||||
allow_guest_access: true
|
||||
```
|
||||
|
||||
## Module installation
|
||||
|
||||
Copy the `synapse_guest_module` folder into the python modules path.
|
||||
This can also be achieved by the [`PYTHONPATH` environment variable](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH).
|
||||
|
||||
Add module configuration into `modules` section of `homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
- module: synapse_guest_module.GuestModule
|
||||
config: {}
|
||||
```
|
||||
|
||||
## Module configuration
|
||||
|
||||
The module provides (optional) configuration options:
|
||||
|
||||
- `user_id_prefix` - the prefix of the usernames that are created by this module. Default: `guest-`.
|
||||
- `display_name_suffix` - the suffix added to the display name of guest users. Default: ` (Guest)`.
|
||||
- `enable_user_reaper` - if true, the module disables all users that are older than the configured expiration time. Default: `true`.
|
||||
- `user_expiration_seconds` - the expiration time in seconds when a guest user expires after their creation. Default: `86400` (=24 hours).
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
- module: synapse_guest_module.GuestModule
|
||||
config:
|
||||
# Use a german suffix
|
||||
display_name_suffix: ' (Gast)'
|
||||
```
|
||||
|
||||
## Production installation
|
||||
|
||||
The module is not published to a python registry, but we provide a docker container that can be used as an `initContainer` in Kubernetes:
|
||||
|
||||
```diff
|
||||
apiVersion: apps/v1
|
||||
kind: "StatefulSet"
|
||||
metadata:
|
||||
name: synapse
|
||||
spec:
|
||||
# ...
|
||||
template:
|
||||
spec:
|
||||
+ # The init container copies the module to he `synapse-modules` volume
|
||||
+ initContainers:
|
||||
+ - image: ghcr.io/nordeck/synapse-guest-module:<version>
|
||||
+ name: install-guest-module
|
||||
+ volumeMounts:
|
||||
+ - mountPath: /modules
|
||||
+ name: synapse-modules
|
||||
containers:
|
||||
- name: "synapse"
|
||||
image: "matrixdotorg/synapse:v1.87.0"
|
||||
+ env:
|
||||
+ # Tell python to read the modules from the `/modules` directory
|
||||
+ - name: PYTHONPATH
|
||||
+ value: /modules
|
||||
+ volumeMounts:
|
||||
+ # Mount the `synapse-modules` volume
|
||||
+ - mountPath: /modules
|
||||
+ name: synapse-modules
|
||||
# ...
|
||||
+ # Use a local volume to store the module
|
||||
+ volumes:
|
||||
+ - emptyDir:
|
||||
+ medium: Memory
|
||||
+ sizeLimit: 50Mi
|
||||
+ name: synapse-modules
|
||||
# ...
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
[mypy]
|
||||
strict = true
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nordeck/synapse-guest-module",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "A synapse module to restrict the actions of guests",
|
||||
"author": "Nordeck IT + Consulting GmbH",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"clean": "echo \"Nothing to clean\"",
|
||||
"build": "echo \"Nothing to build\"",
|
||||
"docker:build": "docker build -t nordeck/synapse-guest-module -f Dockerfile .",
|
||||
"tsc": "echo \"Nothing to tsc\"",
|
||||
"lint": "echo \"Nothing to lint\"",
|
||||
"types:py": "node ./scripts/run_in_venv.js tox -e check_types",
|
||||
"lint:py": "node ./scripts/run_in_venv.js tox -e check_codestyle",
|
||||
"lint:fix": "node ./scripts/run_in_venv.js tox -e fix_codestyle",
|
||||
"test": "node ./scripts/run_in_venv.js tox -e py",
|
||||
"depcheck": "echo \"Nothing to check\"",
|
||||
"package": "yarn docker:build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
known_first_party = [
|
||||
"synapse_guest_module",
|
||||
"tests"
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// A script to run a command in a python virtual environment that is stored in
|
||||
// `<repo-root>/.venv`. If the virtual environment doesn't exist yet, it will
|
||||
// be created.
|
||||
//
|
||||
// Example: $ node ./run_in_venv.js python --version
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const child_process = require('child_process');
|
||||
|
||||
const cwd = path.resolve(__dirname, '..');
|
||||
const venvPath = path.resolve(__dirname, '../../../.venv');
|
||||
const venvRelativeToCwd = path.relative(cwd, venvPath);
|
||||
|
||||
function run(command) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = child_process.spawn(
|
||||
`source ${venvRelativeToCwd}/bin/activate && ${command}`,
|
||||
[],
|
||||
{ cwd, stdio: 'inherit', shell: true },
|
||||
);
|
||||
|
||||
proc.on('close', (code) => {
|
||||
console.log('command terminated:', code);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(venvPath) || !fs.lstatSync(venvPath).isDirectory()) {
|
||||
child_process.execSync(`python3 -m venv ${venvRelativeToCwd}`, { cwd });
|
||||
await run('pip install tox');
|
||||
await run('pip install -e ."[dev]"');
|
||||
}
|
||||
|
||||
const command = process.argv.slice(2).join(' ');
|
||||
|
||||
await run(command);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,48 @@
|
||||
[metadata]
|
||||
name = synapse_guest_module
|
||||
description = A synapse module to restrict the actions of guests
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
version = 2.0.0
|
||||
|
||||
classifiers =
|
||||
License :: OSI Approved :: Apache Software License
|
||||
|
||||
|
||||
[options]
|
||||
packages =
|
||||
synapse_guest_module
|
||||
python_requires = >= 3.7
|
||||
install_requires =
|
||||
attrs
|
||||
|
||||
|
||||
[options.package_data]
|
||||
synapse_guest_module = py.typed
|
||||
|
||||
|
||||
[options.extras_require]
|
||||
dev =
|
||||
# for tests
|
||||
matrix-synapse
|
||||
tox
|
||||
twisted
|
||||
aiounittest
|
||||
# for type checking
|
||||
mypy == 1.10.0
|
||||
pydantic == 2.4.2
|
||||
# for linting
|
||||
black == 22.3.0
|
||||
flake8 == 4.0.1
|
||||
isort == 5.9.3
|
||||
|
||||
|
||||
[flake8]
|
||||
# see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes
|
||||
# for error codes. The ones we ignore are:
|
||||
# W503: line break before binary operator
|
||||
# W504: line break after binary operator
|
||||
# E203: whitespace before ':' (which is contrary to pep8?)
|
||||
# E501: Line too long (black enforces this for us)
|
||||
# (this is a subset of those ignored in Synapse)
|
||||
ignore=W503,W504,E203,E501
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from synapse_guest_module.guest_module import GuestModule
|
||||
|
||||
__all__ = ["GuestModule"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import attr
|
||||
|
||||
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
class GuestModuleConfig:
|
||||
user_id_prefix: str
|
||||
display_name_suffix: str
|
||||
enable_user_reaper: bool
|
||||
user_expiration_seconds: int
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Literal, Tuple, Union
|
||||
|
||||
from synapse.module_api import (
|
||||
NOT_SPAM,
|
||||
ModuleApi,
|
||||
ProfileInfo,
|
||||
UserProfile,
|
||||
errors,
|
||||
run_as_background_process,
|
||||
)
|
||||
from synapse.module_api.errors import ConfigError
|
||||
from synapse.types import UserID
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.guest_registration_servlet import GuestRegistrationServlet
|
||||
from synapse_guest_module.guest_user_reaper import GuestUserReaper
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class GuestModule:
|
||||
def __init__(self, config: GuestModuleConfig, api: ModuleApi):
|
||||
self._api = api
|
||||
self._config = config
|
||||
|
||||
self.registration_servlet = GuestRegistrationServlet(config, api)
|
||||
self._api.register_web_resource(
|
||||
"/_synapse/client/register_guest", self.registration_servlet
|
||||
)
|
||||
self._api.register_third_party_rules_callbacks(
|
||||
on_profile_update=self.profile_update
|
||||
)
|
||||
self._api.register_spam_checker_callbacks(
|
||||
user_may_create_room=self.callback_user_may_create_room,
|
||||
user_may_invite=self.callback_user_may_invite,
|
||||
user_may_join_room=self.callback_user_may_join_room,
|
||||
check_username_for_spam=self.callback_check_username_for_spam,
|
||||
)
|
||||
|
||||
# Start the user reaper
|
||||
self.reaper = GuestUserReaper(api, config)
|
||||
if config.enable_user_reaper:
|
||||
run_as_background_process(
|
||||
"guest_module_reaper_bg_task",
|
||||
self.reaper.run,
|
||||
bg_start_span=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_config(config: Dict[str, Any]) -> GuestModuleConfig:
|
||||
"""Parse the module configuration"""
|
||||
|
||||
user_id_prefix = config.get("user_id_prefix", "guest-")
|
||||
if not isinstance(user_id_prefix, str):
|
||||
raise ConfigError("Config option 'user_id_prefix' must be a string")
|
||||
|
||||
display_name_suffix = config.get("display_name_suffix", " (Guest)")
|
||||
if not isinstance(display_name_suffix, str):
|
||||
raise ConfigError("Config option 'display_name_suffix' must be a string")
|
||||
|
||||
enable_user_reaper = config.get("enable_user_reaper", True)
|
||||
if not isinstance(enable_user_reaper, bool):
|
||||
raise ConfigError("Config option 'enable_user_reaper' must be a bool")
|
||||
|
||||
user_expiration_seconds = config.get(
|
||||
"user_expiration_seconds",
|
||||
24 * 60 * 60,
|
||||
)
|
||||
if not isinstance(user_expiration_seconds, int):
|
||||
raise ConfigError(
|
||||
"Config option 'user_expiration_seconds' must be a number"
|
||||
)
|
||||
|
||||
return GuestModuleConfig(
|
||||
user_id_prefix,
|
||||
display_name_suffix,
|
||||
enable_user_reaper,
|
||||
user_expiration_seconds,
|
||||
)
|
||||
|
||||
async def profile_update(
|
||||
self,
|
||||
user_id: str,
|
||||
new_profile: ProfileInfo,
|
||||
by_admin: bool,
|
||||
deactivation: bool,
|
||||
) -> None:
|
||||
"""Is called whenever a profile is updated. We check that a guest user
|
||||
always contains the configured suffix (default ` (Guest)`) and add it if
|
||||
it is missing.
|
||||
"""
|
||||
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
|
||||
if user_is_guest:
|
||||
new_profile_display_name = (
|
||||
"" if new_profile.display_name is None else new_profile.display_name
|
||||
)
|
||||
guest_display_name_not_valid = not new_profile_display_name.endswith(
|
||||
self._config.display_name_suffix
|
||||
)
|
||||
if guest_display_name_not_valid:
|
||||
user_id_1 = UserID.from_string(user_id)
|
||||
guest_display_name = (
|
||||
new_profile_display_name.strip() + self._config.display_name_suffix
|
||||
)
|
||||
await self._api.set_displayname(user_id_1, guest_display_name)
|
||||
|
||||
async def callback_user_may_create_room(
|
||||
self,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""Returns whether this user is allowed to create a room. Guest users
|
||||
should not be able to do that.
|
||||
"""
|
||||
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
|
||||
return not user_is_guest
|
||||
|
||||
async def callback_user_may_invite(
|
||||
self,
|
||||
inviter: str,
|
||||
invitee: str,
|
||||
room_id: str,
|
||||
) -> bool:
|
||||
"""Returns whether this user is allowed to invite someone into a room.
|
||||
Guest users should not be able to to that.
|
||||
"""
|
||||
user_is_guest = inviter.startswith("@" + self._config.user_id_prefix)
|
||||
return not user_is_guest
|
||||
|
||||
async def callback_user_may_join_room(
|
||||
self, user_id: str, room_id: str, is_invited: bool
|
||||
) -> Union[
|
||||
Literal["NOT_SPAM"], errors.Codes, Tuple[errors.Codes, Dict[str, Any]], bool
|
||||
]:
|
||||
"""Returns whether this user is allowed to join a room. Guest users
|
||||
should only be able to do that if the room is Ask to Join (knock).
|
||||
"""
|
||||
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
|
||||
if not user_is_guest or is_invited:
|
||||
return NOT_SPAM
|
||||
|
||||
join_rules_events = await self._api.get_state_events_in_room(
|
||||
room_id, [("m.room.join_rules", None)]
|
||||
)
|
||||
if join_rules_events is None or len(list(join_rules_events)) == 0:
|
||||
return errors.Codes.BAD_STATE
|
||||
|
||||
for event in join_rules_events:
|
||||
join_rule = event.get("content", {})
|
||||
is_knock = join_rule.get("join_rule").startswith("knock")
|
||||
if user_is_guest and is_knock:
|
||||
return NOT_SPAM
|
||||
|
||||
return errors.Codes.FORBIDDEN
|
||||
|
||||
async def callback_check_username_for_spam(self, user_profile: UserProfile) -> bool:
|
||||
"""Returns whether this user should appear in the user directory. Since
|
||||
we prefer to not invite guests into normal rooms, we hide them here.
|
||||
"""
|
||||
user_is_guest = user_profile["user_id"].startswith(
|
||||
"@" + self._config.user_id_prefix
|
||||
)
|
||||
return user_is_guest
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from synapse.module_api import (
|
||||
DirectServeJsonResource,
|
||||
ModuleApi,
|
||||
parse_json_object_from_request,
|
||||
)
|
||||
from twisted.web.server import Request
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class GuestRegistrationServlet(DirectServeJsonResource):
|
||||
"""The `POST /_synapse/client/register_guest` endpoints provides an endpoint
|
||||
to register a new guest user. It requires the `displayname` property and
|
||||
returns an object that matches the `AccountAuthInfo` of the
|
||||
`@matrix-org/react-sdk-module-api`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GuestModuleConfig,
|
||||
api: ModuleApi,
|
||||
):
|
||||
super().__init__()
|
||||
self._api = api
|
||||
self._config = config
|
||||
|
||||
async def _async_render_POST(self, request: Request) -> Tuple[int, Dict[str, Any]]:
|
||||
"""On POST requests, generate a new username for a guest, check that it
|
||||
doesn't exist yet, append the suffix to the displayname, create the user,
|
||||
create a device, and return the session data to the caller.
|
||||
"""
|
||||
|
||||
json_dict = parse_json_object_from_request(request)
|
||||
|
||||
displayname = json_dict.get("displayname")
|
||||
if not isinstance(displayname, str) or len(displayname.strip()) == 0:
|
||||
return 400, {"msg": "You must provide a 'displayname' as a string"}
|
||||
|
||||
# make sure the regex is unique
|
||||
for _ in range(10):
|
||||
random_string = "".join(
|
||||
secrets.choice(string.ascii_lowercase + string.digits)
|
||||
for _ in range(32)
|
||||
)
|
||||
|
||||
localpart = self._config.user_id_prefix + random_string
|
||||
|
||||
# make sure the user-id does not exist yet
|
||||
if await self._api.check_user_exists(
|
||||
self._api.get_qualified_user_id(localpart)
|
||||
):
|
||||
continue
|
||||
|
||||
logger.info("Register guest with user %s", localpart)
|
||||
user_id = await self._api.register_user(
|
||||
localpart, displayname.strip() + self._config.display_name_suffix
|
||||
)
|
||||
|
||||
device_id, access_token, _, _ = await self._api.register_device(user_id)
|
||||
|
||||
logger.debug("Registered user %s", user_id)
|
||||
|
||||
res = {
|
||||
"userId": user_id,
|
||||
"deviceId": device_id,
|
||||
"accessToken": access_token,
|
||||
"homeserverUrl": self._api.public_baseurl,
|
||||
}
|
||||
|
||||
return 201, res
|
||||
|
||||
return 500, {"msg": "Internal error: Could not find a free username"}
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class GuestUserReaper:
|
||||
def __init__(self, api: ModuleApi, config: GuestModuleConfig):
|
||||
self._api = api
|
||||
self._config = config
|
||||
self.reaper_user = f"{config.user_id_prefix}reaper"
|
||||
|
||||
async def run(self) -> None:
|
||||
logger.info("User cleanup job started")
|
||||
|
||||
await self._api.sleep(5.0) # Wait for Synapse to start properly
|
||||
|
||||
while True:
|
||||
logger.debug("Run deactivation loop")
|
||||
|
||||
try:
|
||||
await self.deactivate_expired_guest_users()
|
||||
except Exception as e:
|
||||
logger.error("Error in the user deactivation: %s", e)
|
||||
|
||||
await self._api.sleep(60.0)
|
||||
|
||||
async def deactivate_expired_guest_users(self) -> None:
|
||||
"""Deactivate all users that are older than the specified expiration
|
||||
interval. This uses the admin API to disable the user.
|
||||
"""
|
||||
|
||||
def get_expired_users(txn: LoggingTransaction) -> List[str]:
|
||||
sql = """
|
||||
SELECT name
|
||||
FROM users
|
||||
WHERE name != ?
|
||||
AND name LIKE ?
|
||||
AND deactivated = 0
|
||||
AND creation_ts < ?;
|
||||
"""
|
||||
|
||||
# date operations are database-specific (postgres, sqlite, ...)
|
||||
expire_ts_seconds = int(time.time() - self._config.user_expiration_seconds)
|
||||
txn.execute(
|
||||
sql,
|
||||
(
|
||||
self._api.get_qualified_user_id(self.reaper_user),
|
||||
f"@{self._config.user_id_prefix}%:{self._api.server_name}",
|
||||
expire_ts_seconds,
|
||||
),
|
||||
)
|
||||
expired_users_rows = txn.fetchall()
|
||||
|
||||
return [row[0] for row in expired_users_rows]
|
||||
|
||||
expired_users: List[str] = await self._api.run_db_interaction(
|
||||
"guest_module_get_expired_users",
|
||||
get_expired_users,
|
||||
)
|
||||
|
||||
if len(expired_users) > 0:
|
||||
logger.info("Deactivate %d users", len(expired_users))
|
||||
|
||||
token = await self.get_admin_token()
|
||||
|
||||
for user_id in expired_users:
|
||||
logger.debug("Deactivate user %s", user_id)
|
||||
|
||||
url = f"http://localhost:8008/_synapse/admin/v1/deactivate/{user_id}"
|
||||
|
||||
try:
|
||||
await self._api.http_client.post_json_get_json(
|
||||
uri=url,
|
||||
post_json={},
|
||||
headers={"Authorization": ["Bearer {}".format(token)]},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to delete user "%s": %s', user_id, e)
|
||||
|
||||
async def get_admin_token(self) -> str:
|
||||
"""Create a new admin user in synapse so the module can call the admin
|
||||
api. If no user or login session exists, we create new ones.
|
||||
"""
|
||||
|
||||
def get_access_token_txn(txn: LoggingTransaction) -> str | None:
|
||||
tokens = DatabasePool.simple_select_onecol_txn(
|
||||
txn,
|
||||
table="access_tokens",
|
||||
keyvalues={
|
||||
"user_id": self._api.get_qualified_user_id(self.reaper_user),
|
||||
},
|
||||
retcol="token",
|
||||
)
|
||||
|
||||
if len(tokens) > 0 and isinstance(tokens[0], str):
|
||||
return tokens[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
token: str | None = await self._api.run_db_interaction(
|
||||
"guest_module_get_access_token",
|
||||
get_access_token_txn,
|
||||
)
|
||||
|
||||
if token is not None:
|
||||
return token
|
||||
|
||||
if not await self._api.check_user_exists(self.reaper_user):
|
||||
logger.info(
|
||||
'Register new administrator user "%s"',
|
||||
self.reaper_user,
|
||||
)
|
||||
|
||||
await self._api.register_user(self.reaper_user, admin=True)
|
||||
|
||||
logger.info('Register new device for administrator user "%s"', self.reaper_user)
|
||||
|
||||
_, access_token, _, _ = await self._api.register_device(
|
||||
self._api.get_qualified_user_id(self.reaper_user)
|
||||
)
|
||||
|
||||
return access_token
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import sqlite3
|
||||
from asyncio import Future
|
||||
from typing import Any, Awaitable, Callable, Tuple, TypeVar
|
||||
from unittest.mock import Mock
|
||||
|
||||
from synapse.http.client import SimpleHttpClient
|
||||
from synapse.module_api import ModuleApi
|
||||
|
||||
from synapse_guest_module import GuestModule
|
||||
|
||||
RV = TypeVar("RV")
|
||||
TV = TypeVar("TV")
|
||||
|
||||
|
||||
class SQLiteStore:
|
||||
"""In-memory SQLite store. We can't just use a run_db_interaction function that opens
|
||||
its own connection, since we need to use the same connection for all queries in a
|
||||
test.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
|
||||
async def run_db_interaction(
|
||||
self, desc: str, f: Callable[..., RV], *args: Any, **kwargs: Any
|
||||
) -> RV:
|
||||
cur = CursorWrapper(self.conn.cursor())
|
||||
try:
|
||||
res = f(cur, *args, **kwargs)
|
||||
self.conn.commit()
|
||||
return res
|
||||
except Exception:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class CursorWrapper:
|
||||
"""Wrapper around a SQLite cursor."""
|
||||
|
||||
def __init__(self, cursor: sqlite3.Cursor) -> None:
|
||||
self.cur = cursor
|
||||
|
||||
def execute(self, sql: str, args: Any) -> None:
|
||||
self.cur.execute(sql, args)
|
||||
|
||||
@property
|
||||
def rowcount(self) -> Any:
|
||||
return self.cur.rowcount
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self.cur.fetchone()
|
||||
|
||||
def fetchall(self) -> Any:
|
||||
return self.cur.fetchall()
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
return self.cur.__iter__()
|
||||
|
||||
def __next__(self) -> Any:
|
||||
return self.cur.__next__()
|
||||
|
||||
|
||||
def make_awaitable(result: TV) -> Awaitable[TV]:
|
||||
"""
|
||||
Makes an awaitable, suitable for mocking an `async` function.
|
||||
This uses Futures as they can be awaited multiple times so can be returned
|
||||
to multiple callers.
|
||||
This function has been copied directly from Synapse's tests code.
|
||||
"""
|
||||
future = Future() # type: ignore
|
||||
future.set_result(result)
|
||||
return future
|
||||
|
||||
|
||||
def get_qualified_user_id(username: str) -> str:
|
||||
return f"@{username}:matrix.local"
|
||||
|
||||
|
||||
async def register_user(localpart: str, admin: bool = False) -> str:
|
||||
return f"@{localpart}:matrix.local"
|
||||
|
||||
|
||||
def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
store = SQLiteStore()
|
||||
_setup_db(store.conn)
|
||||
|
||||
client = Mock(spec=SimpleHttpClient)
|
||||
client.post_json_get_json.return_value = make_awaitable(None)
|
||||
|
||||
# Create a mock based on the ModuleApi spec, but override some mocked functions
|
||||
# because some capabilities are needed for running the tests.
|
||||
module_api = Mock(spec=ModuleApi)
|
||||
module_api.http_client = client
|
||||
module_api.server_name = "matrix.local"
|
||||
module_api.public_baseurl = "https://matrix.local:1234/"
|
||||
module_api.run_db_interaction.side_effect = store.run_db_interaction
|
||||
module_api.get_qualified_user_id.side_effect = get_qualified_user_id
|
||||
module_api.check_user_exists.return_value = make_awaitable(False)
|
||||
module_api.register_user.side_effect = register_user
|
||||
module_api.register_device.return_value = make_awaitable(
|
||||
("DEVICEID", "syn_registered_token", None, None)
|
||||
)
|
||||
|
||||
# If necessary, give parse_config some configuration to parse.
|
||||
config = GuestModule.parse_config(
|
||||
{
|
||||
"enable_user_reaper": False,
|
||||
}
|
||||
)
|
||||
|
||||
module = GuestModule(config, module_api)
|
||||
|
||||
return module, module_api, store
|
||||
|
||||
|
||||
def _setup_db(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("CREATE TABLE access_tokens(user_id text, token text)")
|
||||
conn.execute(
|
||||
"CREATE TABLE users(name text, deactivated smallint, creation_ts bigint)"
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import aiounittest
|
||||
from synapse.module_api import ProfileInfo, UserProfile
|
||||
from synapse.module_api.errors import ConfigError
|
||||
from synapse.types import UserID
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.guest_module import GuestModule
|
||||
from tests import create_module
|
||||
|
||||
|
||||
class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
async def test_parse_config_empty(self) -> None:
|
||||
config = GuestModule.parse_config({})
|
||||
|
||||
self.assertEqual(
|
||||
config,
|
||||
GuestModuleConfig(
|
||||
user_id_prefix="guest-",
|
||||
display_name_suffix=" (Guest)",
|
||||
enable_user_reaper=True,
|
||||
user_expiration_seconds=24 * 60 * 60,
|
||||
),
|
||||
)
|
||||
|
||||
async def test_parse_config_custom(self) -> None:
|
||||
config = GuestModule.parse_config(
|
||||
{
|
||||
"user_id_prefix": "tmp-",
|
||||
"display_name_suffix": " (Temporary)",
|
||||
"enable_user_reaper": False,
|
||||
"user_expiration_seconds": 100,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
config,
|
||||
GuestModuleConfig(
|
||||
user_id_prefix="tmp-",
|
||||
display_name_suffix=" (Temporary)",
|
||||
enable_user_reaper=False,
|
||||
user_expiration_seconds=100,
|
||||
),
|
||||
)
|
||||
|
||||
async def test_parse_config_fail_user_id_prefix(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ConfigError, "Config option 'user_id_prefix' must be a string"
|
||||
):
|
||||
GuestModule.parse_config(
|
||||
{
|
||||
"user_id_prefix": 1234,
|
||||
}
|
||||
)
|
||||
|
||||
async def test_parse_config_fail_display_name_suffix(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ConfigError, "Config option 'display_name_suffix' must be a string"
|
||||
):
|
||||
GuestModule.parse_config(
|
||||
{
|
||||
"display_name_suffix": 1234,
|
||||
}
|
||||
)
|
||||
|
||||
async def test_parse_config_fail_enable_user_reaper(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ConfigError, "Config option 'enable_user_reaper' must be a bool"
|
||||
):
|
||||
GuestModule.parse_config(
|
||||
{
|
||||
"enable_user_reaper": "False",
|
||||
}
|
||||
)
|
||||
|
||||
async def test_parse_config_fail_user_expiration_seconds(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ConfigError, "Config option 'user_expiration_seconds' must be a number"
|
||||
):
|
||||
GuestModule.parse_config(
|
||||
{
|
||||
"user_expiration_seconds": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async def test_profile_update_no_guest(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@my-user:matrix.local",
|
||||
ProfileInfo(display_name="My User", avatar_url=None),
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
module_api.set_displayname.assert_not_called()
|
||||
|
||||
async def test_profile_update_guest_keep(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@guest-asdf:matrix.local",
|
||||
ProfileInfo(display_name="My User (Guest)", avatar_url=None),
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
module_api.set_displayname.assert_not_called()
|
||||
|
||||
async def test_profile_update_guest_add_and_trim(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@guest-asdf:matrix.local",
|
||||
ProfileInfo(display_name="My User ", avatar_url=None),
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
module_api.set_displayname.assert_awaited_once_with(
|
||||
UserID.from_string("@guest-asdf:matrix.local"),
|
||||
"My User (Guest)",
|
||||
)
|
||||
|
||||
async def test_callback_user_may_create_room_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_user_may_create_room(
|
||||
"@my-user:matrix.local",
|
||||
)
|
||||
|
||||
self.assertTrue(allow)
|
||||
|
||||
async def test_callback_user_may_create_room_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_user_may_create_room(
|
||||
"@guest-asdf:matrix.local",
|
||||
)
|
||||
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_user_may_invite_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_user_may_invite(
|
||||
"@my-user:matrix.local",
|
||||
"@inviter:matrix.local",
|
||||
"!room:matrix.local",
|
||||
)
|
||||
|
||||
self.assertTrue(allow)
|
||||
|
||||
async def test_callback_user_may_invite_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_user_may_invite(
|
||||
"@guest-asdf:matrix.local",
|
||||
"@inviter:matrix.local",
|
||||
"!room:matrix.local",
|
||||
)
|
||||
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_check_username_for_spam_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_check_username_for_spam(
|
||||
UserProfile(
|
||||
user_id="@my-user:matrix.local",
|
||||
display_name=None,
|
||||
avatar_url=None,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_check_username_for_spam_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
allow = await module.callback_check_username_for_spam(
|
||||
UserProfile(
|
||||
user_id="@guest-asdf:matrix.local",
|
||||
display_name=None,
|
||||
avatar_url=None,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(allow)
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import io
|
||||
from typing import cast
|
||||
from unittest.mock import ANY
|
||||
|
||||
import aiounittest
|
||||
from twisted.web.server import Request
|
||||
from twisted.web.test.requesthelper import DummyRequest
|
||||
|
||||
from tests import create_module, make_awaitable
|
||||
|
||||
|
||||
class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
async def test_async_render_POST_missing_displayname(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b"{}")
|
||||
|
||||
status, response = await module.registration_servlet._async_render_POST(request)
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(
|
||||
response, {"msg": "You must provide a 'displayname' as a string"}
|
||||
)
|
||||
|
||||
async def test_async_render_POST_empty_displayname(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":" "}')
|
||||
|
||||
status, response = await module.registration_servlet._async_render_POST(request)
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(
|
||||
response, {"msg": "You must provide a 'displayname' as a string"}
|
||||
)
|
||||
|
||||
async def test_async_render_POST_no_free_username(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":"My Name"}')
|
||||
|
||||
module_api.check_user_exists.return_value = make_awaitable(True)
|
||||
|
||||
status, response = await module.registration_servlet._async_render_POST(request)
|
||||
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(
|
||||
response, {"msg": "Internal error: Could not find a free username"}
|
||||
)
|
||||
|
||||
self.assertEqual(module_api.check_user_exists.call_count, 10)
|
||||
|
||||
async def test_async_render_POST_success(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":"My Name "}')
|
||||
|
||||
status, response = await module.registration_servlet._async_render_POST(request)
|
||||
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
self.assertRegex(response.pop("userId"), r"^@guest-[A-Za-z0-9]+:matrix.local$")
|
||||
self.assertDictEqual(
|
||||
response,
|
||||
{
|
||||
"accessToken": "syn_registered_token",
|
||||
"deviceId": "DEVICEID",
|
||||
"homeserverUrl": "https://matrix.local:1234/",
|
||||
# "userId" was already checked by self.assertRegex and was removed from the object
|
||||
},
|
||||
)
|
||||
|
||||
module_api.register_user.assert_called_with(ANY, "My Name (Guest)")
|
||||
@@ -0,0 +1,128 @@
|
||||
# Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import time
|
||||
from unittest.mock import call
|
||||
|
||||
import aiounittest
|
||||
|
||||
from tests import create_module, make_awaitable
|
||||
|
||||
|
||||
class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
async def test_get_admin_token_register(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
token = await module.reaper.get_admin_token()
|
||||
|
||||
module_api.check_user_exists.assert_called_with("guest-reaper")
|
||||
module_api.register_user.assert_called_with("guest-reaper", admin=True)
|
||||
module_api.register_device.assert_called_with("@guest-reaper:matrix.local")
|
||||
|
||||
self.assertEqual(token, "syn_registered_token")
|
||||
|
||||
async def test_get_admin_token_create_device(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
|
||||
module_api.check_user_exists.return_value = make_awaitable(True)
|
||||
|
||||
token = await module.reaper.get_admin_token()
|
||||
|
||||
module_api.check_user_exists.assert_called_with("guest-reaper")
|
||||
module_api.register_user.assert_not_called()
|
||||
module_api.register_device.assert_called_with("@guest-reaper:matrix.local")
|
||||
|
||||
self.assertEqual(token, "syn_registered_token")
|
||||
|
||||
async def test_get_admin_token_read_from_db(self) -> None:
|
||||
module, module_api, store = create_module()
|
||||
|
||||
store.conn.execute(
|
||||
"INSERT INTO access_tokens VALUES ('@guest-reaper:matrix.local', 'syn_db_token')"
|
||||
)
|
||||
|
||||
module_api.check_user_exists.return_value = make_awaitable(True)
|
||||
|
||||
token = await module.reaper.get_admin_token()
|
||||
|
||||
module_api.check_user_exists.assert_not_called()
|
||||
module_api.register_user.assert_not_called()
|
||||
module_api.register_device.assert_not_called()
|
||||
|
||||
self.assertEqual(token, "syn_db_token")
|
||||
|
||||
async def test_deactivate_expired_guest_users_success(self) -> None:
|
||||
module, module_api, store = create_module()
|
||||
|
||||
now = int(time.time())
|
||||
store.conn.executemany(
|
||||
"INSERT INTO users VALUES (?, ?, ?)",
|
||||
[
|
||||
["@user-1:matrix.local", 0, 0],
|
||||
["@guest-reaper:matrix.local", 0, 0],
|
||||
["@guest-active:matrix.local", 0, now],
|
||||
["@guest-deactivated:matrix.local", 1, 0],
|
||||
["@guest-old-1:matrix.local", 0, 0],
|
||||
["@guest-old-2:matrix.local", 0, 0],
|
||||
],
|
||||
)
|
||||
|
||||
await module.reaper.deactivate_expired_guest_users()
|
||||
|
||||
self.assertEqual(module_api.http_client.post_json_get_json.await_count, 2)
|
||||
|
||||
module_api.http_client.post_json_get_json.assert_has_awaits(
|
||||
[
|
||||
call(
|
||||
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-1:matrix.local",
|
||||
post_json={},
|
||||
headers={"Authorization": ["Bearer syn_registered_token"]},
|
||||
),
|
||||
call(
|
||||
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-2:matrix.local",
|
||||
post_json={},
|
||||
headers={"Authorization": ["Bearer syn_registered_token"]},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def test_deactivate_expired_guest_users_with_failure(self) -> None:
|
||||
module, module_api, store = create_module()
|
||||
|
||||
store.conn.executemany(
|
||||
"INSERT INTO users VALUES (?, ?, ?)",
|
||||
[
|
||||
["@guest-old-1:matrix.local", 0, 0],
|
||||
["@guest-old-2:matrix.local", 0, 0],
|
||||
],
|
||||
)
|
||||
|
||||
module_api.http_client.post_json_get_json.side_effect = Exception("")
|
||||
|
||||
await module.reaper.deactivate_expired_guest_users()
|
||||
|
||||
module_api.http_client.post_json_get_json.assert_has_awaits(
|
||||
[
|
||||
call(
|
||||
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-1:matrix.local",
|
||||
post_json={},
|
||||
headers={"Authorization": ["Bearer syn_registered_token"]},
|
||||
),
|
||||
call(
|
||||
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-2:matrix.local",
|
||||
post_json={},
|
||||
headers={"Authorization": ["Bearer syn_registered_token"]},
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
[tox]
|
||||
envlist = py, check_codestyle, check_types
|
||||
|
||||
# required for PEP 517 (pyproject.toml-style) builds
|
||||
isolated_build = true
|
||||
|
||||
[testenv:py]
|
||||
|
||||
extras = dev
|
||||
|
||||
commands =
|
||||
python -m twisted.trial tests
|
||||
|
||||
[testenv:check_codestyle]
|
||||
|
||||
extras = dev
|
||||
|
||||
commands =
|
||||
flake8 synapse_guest_module tests
|
||||
black --check --diff synapse_guest_module tests
|
||||
isort --check-only --diff synapse_guest_module tests
|
||||
|
||||
[testenv:fix_codestyle]
|
||||
|
||||
extras = dev
|
||||
|
||||
commands =
|
||||
black synapse_guest_module tests
|
||||
isort synapse_guest_module tests
|
||||
|
||||
|
||||
[testenv:check_types]
|
||||
|
||||
extras = dev
|
||||
|
||||
commands =
|
||||
mypy synapse_guest_module tests
|
||||
Reference in New Issue
Block a user