From e04ae48637455663c79a02a32f644daeb269d6fc Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Fri, 20 Mar 2020 17:16:46 +0530 Subject: [PATCH] users: Add component for managing users and groups - Handle groups needed by an app. - Handle reserved usernames for an app. - Updated documentation - Updated unit tests Tests performed: - Reserved usernames: ez-ipupd, ejabberd, Debian-minetest, mldonkey, monkeysphere, mumble-server, privoxy, quasselcore, radicale, debian-tor, debian-transmission - Reserved usernames checks should work in the following forms: - Create user - Update user - First boot user creation - Full list of available groups should appear in following cases: - Create user form - Update user form - Full list of groups should get created in Django DB during: - Update user form display - First boot form save - When updating the last admin user, the 'admin' group choice is checked and disabled. - Following groups show up (sorted by group name): - bit-torrent: Download files using BitTorrent applications - git-access: Read-write access to Git repositories - i2p: Manage I2P application - wiki: View and edit wiki applications - minidlna: Media streaming server - ed2k: Download files using eDonkey applications - freedombox-share: Access to the private shares - web-search: Search the web - syncthing: Administer Syncthing application - feed-reader: Read and subscribe to news feeds - admin: Access to all services and system settings - Directory validation form checks for write permissions for following apps: - deluge with debian-deluged user - transmission with debian-transmission user - Sharing app should show all the groups in add/edit share forms - The following apps should get added to share group during setup: debian-transmission debian-deluged - Unit tests pass - Functional tests for users and groups pass - Test that an app (example syncthing) provides the necessary permissions to users in that group (but not in admin group). Signed-off-by: Joseph Nuthalapati [sunil: Fix i18n of group descriptions] [sunil: Update developer documentation] [sunil: Separate out cosmetic changes] [sunil: Fix component ID for mumble] [sunil: sharing: Remove unneeded dependency on users app] [sunil: Implement better API for getting groups in component] [sunil: Fix incorrect regression change ttrss app] [sunil: Make iterating over gourps more readable] [sunil: Improve tests, drop single use fixtures] [sunil: Simplify test_view.py fixture] Signed-off-by: Sunil Mohan Adapa Tested-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- doc/dev/tutorial/components.rst | 14 +++- plinth/modules/deluge/__init__.py | 24 ++++--- plinth/modules/deluge/forms.py | 12 ++-- plinth/modules/dynamicdns/__init__.py | 7 +- plinth/modules/ejabberd/__init__.py | 7 +- plinth/modules/gitweb/__init__.py | 13 ++-- plinth/modules/i2p/__init__.py | 14 ++-- plinth/modules/ikiwiki/__init__.py | 11 +-- plinth/modules/minetest/__init__.py | 8 ++- plinth/modules/minidlna/__init__.py | 28 ++++---- plinth/modules/mldonkey/__init__.py | 17 +++-- plinth/modules/monkeysphere/__init__.py | 7 +- plinth/modules/mumble/__init__.py | 7 +- plinth/modules/privoxy/__init__.py | 7 +- plinth/modules/quassel/__init__.py | 7 +- plinth/modules/radicale/__init__.py | 7 +- plinth/modules/samba/__init__.py | 14 ++-- plinth/modules/searx/__init__.py | 14 ++-- plinth/modules/sharing/forms.py | 4 +- plinth/modules/syncthing/__init__.py | 14 ++-- plinth/modules/tor/__init__.py | 7 +- plinth/modules/transmission/__init__.py | 23 ++++--- plinth/modules/transmission/forms.py | 15 +++-- plinth/modules/ttrss/__init__.py | 14 ++-- plinth/modules/users/__init__.py | 15 +++-- plinth/modules/users/components.py | 56 ++++++++++++++++ plinth/modules/users/forms.py | 44 +++++------- plinth/modules/users/tests/test_components.py | 67 +++++++++++++++++++ plinth/modules/users/tests/test_group.py | 18 ----- plinth/modules/users/tests/test_views.py | 15 +++-- 30 files changed, 344 insertions(+), 166 deletions(-) create mode 100644 plinth/modules/users/components.py create mode 100644 plinth/modules/users/tests/test_components.py delete mode 100644 plinth/modules/users/tests/test_group.py diff --git a/doc/dev/tutorial/components.rst b/doc/dev/tutorial/components.rst index c22b3eaaf..5886fcf23 100644 --- a/doc/dev/tutorial/components.rst +++ b/doc/dev/tutorial/components.rst @@ -163,11 +163,19 @@ with the FreedomBox framework in ``__init.py__``. .. code-block:: python3 - group = ('bit-torrent', 'Download files using BitTorrent applications') + from plinth.modules.users.components import UsersAndGroups - def init(): + class TransmissionApp(app_module.App): ... - register_group(group) + + def __init__(self): + ... + + groups = { 'bit-torrent': _('Download files using BitTorrent applications') } + users_and_groups = UsersAndGroups('users-and-groups-transmission', + groups=groups) + self.add(users_and_groups) + Then in the Apache configuration snippet, we can mandate that only users of this group (and, of course, admin users) should be allowed to access our app. In the diff --git a/plinth/modules/deluge/__init__.py b/plinth/modules/deluge/__init__.py index 7ca3add71..b70bc8fb7 100644 --- a/plinth/modules/deluge/__init__.py +++ b/plinth/modules/deluge/__init__.py @@ -11,7 +11,8 @@ from plinth import frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import add_user_to_share_group, register_group +from plinth.modules.users import add_user_to_share_group +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -27,12 +28,10 @@ _description = [ 'change it immediately after enabling this service.') ] -group = ('bit-torrent', _('Download files using BitTorrent applications')) - -reserved_usernames = ['debian-deluged'] - app = None +SYSTEM_USER = 'debian-deluged' + class DelugeApp(app_module.App): """FreedomBox app for Deluge.""" @@ -42,6 +41,11 @@ class DelugeApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = { + 'bit-torrent': _('Download files using BitTorrent applications') + } + info = app_module.Info(app_id=self.app_id, version=version, name=_('Deluge'), icon_filename='deluge', short_description=_('BitTorrent Web Client'), @@ -59,7 +63,7 @@ class DelugeApp(app_module.App): url='/deluge', icon=info.icon_filename, clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-deluge', info.name, @@ -78,12 +82,16 @@ class DelugeApp(app_module.App): listen_ports=[(8112, 'tcp4')]) self.add(daemon_web) + users_and_groups = UsersAndGroups('users-and-groups-deluge', + reserved_usernames=[SYSTEM_USER], + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the Deluge module.""" global app app = DelugeApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): @@ -94,5 +102,5 @@ def setup(helper, old_version=None): """Install and configure the module.""" helper.install(managed_packages) helper.call('post', actions.superuser_run, 'deluge', ['setup']) - add_user_to_share_group(reserved_usernames[0]) + add_user_to_share_group(SYSTEM_USER) helper.call('post', app.enable) diff --git a/plinth/modules/deluge/forms.py b/plinth/modules/deluge/forms.py index a969cb301..65e766bbb 100644 --- a/plinth/modules/deluge/forms.py +++ b/plinth/modules/deluge/forms.py @@ -5,18 +5,18 @@ Forms for Deluge app. from django.utils.translation import ugettext_lazy as _ -from plinth.modules.deluge import reserved_usernames from plinth.modules.storage.forms import (DirectorySelectForm, DirectoryValidator) +from . import SYSTEM_USER + class DelugeForm(DirectorySelectForm): """Deluge configuration form""" def __init__(self, *args, **kw): - validator = DirectoryValidator(username=reserved_usernames[0], + validator = DirectoryValidator(username=SYSTEM_USER, check_creatable=True) - super(DelugeForm, self).__init__( - title=_('Download directory'), - default='/var/lib/deluged/Downloads', validator=validator, *args, - **kw) + super(DelugeForm, self).__init__(title=_('Download directory'), + default='/var/lib/deluged/Downloads', + validator=validator, *args, **kw) diff --git a/plinth/modules/dynamicdns/__init__.py b/plinth/modules/dynamicdns/__init__.py index f254f52a5..77a0d5823 100644 --- a/plinth/modules/dynamicdns/__init__.py +++ b/plinth/modules/dynamicdns/__init__.py @@ -11,6 +11,7 @@ from plinth import cfg, menu from plinth.modules.names.components import DomainType from plinth.signals import domain_added from plinth.utils import format_lazy +from plinth.modules.users.components import UsersAndGroups from .manifest import backup # noqa, pylint: disable=unused-import @@ -38,8 +39,6 @@ _description = [ 'name, they will get a response with your current IP address.') ] -reserved_usernames = ['ez-ipupd'] - app = None @@ -67,6 +66,10 @@ class DynamicDNSApp(app_module.App): can_have_certificate=True) self.add(domain_type) + users_and_groups = UsersAndGroups('users-and-groups-dynamicdns', + reserved_usernames=['ez-ipupd']) + self.add(users_and_groups) + def init(): """Initialize the module.""" diff --git a/plinth/modules/ejabberd/__init__.py b/plinth/modules/ejabberd/__init__.py index 94ee504cc..76e44f533 100644 --- a/plinth/modules/ejabberd/__init__.py +++ b/plinth/modules/ejabberd/__init__.py @@ -21,6 +21,7 @@ from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.signals import (domain_added, post_hostname_change, pre_hostname_change) from plinth.utils import format_lazy +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -45,8 +46,6 @@ _description = [ jsxc_url=reverse_lazy('jsxc:index')) ] -reserved_usernames = ['ejabberd'] - port_forwarding_info = [ ('TCP', 5222), ('TCP', 5269), @@ -110,6 +109,10 @@ class EjabberdApp(app_module.App): (5269, 'tcp6'), (5443, 'tcp4'), (5443, 'tcp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-ejabberd', + reserved_usernames=['ejabberd']) + self.add(users_and_groups) + def init(): """Initialize the ejabberd module""" diff --git a/plinth/modules/gitweb/__init__.py b/plinth/modules/gitweb/__init__.py index 484946658..c9f57d287 100644 --- a/plinth/modules/gitweb/__init__.py +++ b/plinth/modules/gitweb/__init__.py @@ -14,7 +14,7 @@ from plinth import frontpage, menu from plinth.errors import ActionError from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from .forms import is_repo_url from .manifest import (GIT_REPO_PATH, # noqa, pylint: disable=unused-import @@ -36,8 +36,6 @@ _description = [ 'Git tutorial.') ] -group = ('git-access', _('Read-write access to Git repositories')) - app = None @@ -50,6 +48,8 @@ class GitwebApp(app_module.App): """Create components for the app.""" super().__init__() + groups = {'git-access': _('Read-write access to Git repositories')} + self.repos = [] info = app_module.Info(app_id=self.app_id, version=version, @@ -69,7 +69,7 @@ class GitwebApp(app_module.App): icon=info.icon_filename, url='/gitweb/', clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-gitweb', info.name, @@ -84,6 +84,10 @@ class GitwebApp(app_module.App): 'gitweb-freedombox-auth') self.add(self.auth_webserver) + users_and_groups = UsersAndGroups('users-and-groups-gitweb', + groups=groups) + self.add(users_and_groups) + def set_shortcut_login_required(self, login_required): """Change the login_required property of shortcut.""" shortcut = self.remove('shortcut-gitweb') @@ -163,7 +167,6 @@ def init(): """Initialize the module.""" global app app = GitwebApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup': diff --git a/plinth/modules/i2p/__init__.py b/plinth/modules/i2p/__init__.py index edcbbe8af..cbdf49d14 100644 --- a/plinth/modules/i2p/__init__.py +++ b/plinth/modules/i2p/__init__.py @@ -12,7 +12,7 @@ from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall from plinth.modules.i2p.resources import FAVORITES -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -35,8 +35,6 @@ _description = [ 'configuration process.') ] -group = ('i2p', _('Manage I2P application')) - port_forwarding_info = [ ('TCP', 4444), ('TCP', 4445), @@ -60,6 +58,9 @@ class I2PApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = {'i2p': _('Manage I2P application')} + info = app_module.Info(app_id=self.app_id, version=version, name=_('I2P'), icon_filename='i2p', short_description=_('Anonymity Network'), @@ -77,7 +78,7 @@ class I2PApp(app_module.App): icon=info.icon_filename, url='/i2p/', clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-i2p-web', info.name, @@ -97,12 +98,15 @@ class I2PApp(app_module.App): listen_ports=[(7657, 'tcp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-i2p', + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the module.""" global app app = I2PApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/ikiwiki/__init__.py b/plinth/modules/ikiwiki/__init__.py index b2e3c5494..4e8e738c3 100644 --- a/plinth/modules/ikiwiki/__init__.py +++ b/plinth/modules/ikiwiki/__init__.py @@ -11,7 +11,7 @@ from plinth import app as app_module from plinth import cfg, frontpage, menu from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from plinth.utils import format_lazy from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -36,8 +36,6 @@ _description = [ users_url=reverse_lazy('users:index')) ] -group = ('wiki', _('View and edit wiki applications')) - app = None @@ -49,6 +47,7 @@ class IkiwikiApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + info = app_module.Info(app_id=self.app_id, version=version, name=_('ikiwiki'), icon_filename='ikiwiki', short_description=_('Wiki and Blog'), @@ -71,6 +70,11 @@ class IkiwikiApp(app_module.App): urls=['https://{host}/ikiwiki']) self.add(webserver) + groups = {'wiki': _('View and edit wiki applications')} + users_and_groups = UsersAndGroups('users-and-groups-ikiwiki', + groups=groups) + self.add(users_and_groups) + def add_shortcut(self, site, title): """Add an ikiwiki shortcut to frontpage.""" shortcut = frontpage.Shortcut('shortcut-ikiwiki-' + site, title, @@ -101,7 +105,6 @@ def init(): """Initialize the ikiwiki module.""" global app app = IkiwikiApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/minetest/__init__.py b/plinth/modules/minetest/__init__.py index 4fc1fd5ee..c8d7d373a 100644 --- a/plinth/modules/minetest/__init__.py +++ b/plinth/modules/minetest/__init__.py @@ -12,6 +12,7 @@ from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.firewall.components import Firewall from plinth.utils import format_lazy +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -43,8 +44,6 @@ _description = [ port_forwarding_info = [('UDP', 30000)] -reserved_usernames = ['Debian-minetest'] - CONFIG_FILE = '/etc/minetest/minetest.conf' AUG_PATH = '/files' + CONFIG_FILE + '/.anon' @@ -87,6 +86,11 @@ class MinetestApp(app_module.App): listen_ports=[(30000, 'udp4')]) self.add(daemon) + users_and_groups = UsersAndGroups( + 'users-and-groups-minetest', + reserved_usernames=['Debian-minetest']) + self.add(users_and_groups) + def init(): """Initialize the module.""" diff --git a/plinth/modules/minidlna/__init__.py b/plinth/modules/minidlna/__init__.py index b74d68f76..778b971b5 100644 --- a/plinth/modules/minidlna/__init__.py +++ b/plinth/modules/minidlna/__init__.py @@ -9,7 +9,7 @@ from plinth import actions, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa @@ -30,8 +30,6 @@ _description = [ 'such as PS3 and Xbox 360) or applications such as totem and Kodi.') ] -group = ('minidlna', _('Media streaming server')) - app = None @@ -42,6 +40,9 @@ class MiniDLNAApp(app_module.App): def __init__(self): """Initialize the app components""" super().__init__() + + groups = {'minidlna': _('Media streaming server')} + info = app_module.Info(app_id=self.app_id, version=version, name='minidlna', icon_filename='minidlna', short_description=_('Simple Media Server'), @@ -60,16 +61,12 @@ class MiniDLNAApp(app_module.App): is_external=False) webserver = Webserver('webserver-minidlna', 'minidlna-freedombox', urls=['http://localhost:8200/']) - shortcut = frontpage.Shortcut( - 'shortcut-minidlna', - info.name, - short_description=info.short_description, - description=info.description, - icon=info.icon_filename, - url='/_minidlna/', - login_required=True, - allowed_groups=[group[0]], - ) + shortcut = frontpage.Shortcut('shortcut-minidlna', info.name, + short_description=info.short_description, + description=info.description, + icon=info.icon_filename, + url='/_minidlna/', login_required=True, + allowed_groups=list(groups)) daemon = Daemon('daemon-minidlna', managed_services[0]) self.add(menu_item) @@ -78,12 +75,15 @@ class MiniDLNAApp(app_module.App): self.add(shortcut) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-minidlna', + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the module.""" global app app = MiniDLNAApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/mldonkey/__init__.py b/plinth/modules/mldonkey/__init__.py index 7edd0de5b..d6572ef2f 100644 --- a/plinth/modules/mldonkey/__init__.py +++ b/plinth/modules/mldonkey/__init__.py @@ -11,7 +11,7 @@ from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from plinth.utils import format_lazy from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -35,10 +35,6 @@ _description = [ 'directory.'), box_name=cfg.box_name) ] -reserved_usernames = ['mldonkey'] - -group = ('ed2k', _('Download files using eDonkey applications')) - app = None @@ -50,6 +46,9 @@ class MLDonkeyApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = {'ed2k': _('Download files using eDonkey applications')} + info = app_module.Info( app_id=self.app_id, version=version, name=_('MLDonkey'), icon_filename='mldonkey', @@ -66,7 +65,7 @@ class MLDonkeyApp(app_module.App): 'shortcut-mldonkey', info.name, short_description=info.short_description, icon=info.icon_filename, url='/mldonkey/', login_required=True, clients=info.clients, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcuts) firewall = Firewall('firewall-mldonkey', info.name, @@ -81,12 +80,16 @@ class MLDonkeyApp(app_module.App): listen_ports=[(4080, 'tcp4')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-mldonkey', + reserved_usernames=['mldonkey'], + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the MLDonkey module.""" global app app = MLDonkeyApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/monkeysphere/__init__.py b/plinth/modules/monkeysphere/__init__.py index 05063519b..5a16a383e 100644 --- a/plinth/modules/monkeysphere/__init__.py +++ b/plinth/modules/monkeysphere/__init__.py @@ -7,6 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from plinth import app as app_module from plinth import menu +from plinth.modules.users.components import UsersAndGroups from .manifest import backup # noqa, pylint: disable=unused-import @@ -33,8 +34,6 @@ _description = [ 'website.') ] -reserved_usernames = ['monkeysphere'] - app = None @@ -57,6 +56,10 @@ class MonkeysphereApp(app_module.App): advanced=True) self.add(menu_item) + users_and_groups = UsersAndGroups('users-and-groups-monkeysphere', + reserved_usernames=['monkeysphere']) + self.add(users_and_groups) + def init(): """Initialize the monkeysphere module.""" diff --git a/plinth/modules/mumble/__init__.py b/plinth/modules/mumble/__init__.py index dc0b3ad32..94c4896b7 100644 --- a/plinth/modules/mumble/__init__.py +++ b/plinth/modules/mumble/__init__.py @@ -10,6 +10,7 @@ from plinth import app as app_module from plinth import frontpage, menu from plinth.daemon import Daemon from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -27,8 +28,6 @@ _description = [ 'from your desktop and Android devices are available.') ] -reserved_usernames = ['mumble-server'] - port_forwarding_info = [ ('TCP', 64738), ('UDP', 64738), @@ -73,6 +72,10 @@ class MumbleApp(app_module.App): (64738, 'udp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-mumble', + reserved_usernames=['mumble-server']) + self.add(users_and_groups) + def init(): """Initialize the Mumble module.""" diff --git a/plinth/modules/privoxy/__init__.py b/plinth/modules/privoxy/__init__.py index 40ee6c082..2848ad32b 100644 --- a/plinth/modules/privoxy/__init__.py +++ b/plinth/modules/privoxy/__init__.py @@ -13,6 +13,7 @@ from plinth.daemon import Daemon from plinth.modules.apache.components import diagnose_url from plinth.modules.firewall.components import Firewall from plinth.utils import format_lazy +from plinth.modules.users.components import UsersAndGroups from .manifest import backup # noqa, pylint: disable=unused-import @@ -39,8 +40,6 @@ _description = [ box_name=_(cfg.box_name)), ] -reserved_usernames = ['privoxy'] - app = None @@ -78,6 +77,10 @@ class PrivoxyApp(app_module.App): listen_ports=[(8118, 'tcp4'), (8118, 'tcp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-privoxy', + reserved_usernames=['privoxy']) + self.add(users_and_groups) + def diagnose(self): """Run diagnostics and return the results.""" results = super().diagnose() diff --git a/plinth/modules/quassel/__init__.py b/plinth/modules/quassel/__init__.py index 319e81cb7..90f1f4cfc 100644 --- a/plinth/modules/quassel/__init__.py +++ b/plinth/modules/quassel/__init__.py @@ -16,6 +16,7 @@ from plinth.modules import names from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.utils import format_lazy +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -43,8 +44,6 @@ _description = [ 'are available.'), ] -reserved_usernames = ['quasselcore'] - port_forwarding_info = [('TCP', 4242)] app = None @@ -95,6 +94,10 @@ class QuasselApp(app_module.App): listen_ports=[(4242, 'tcp4'), (4242, 'tcp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-quasselcore', + reserved_usernames=['quasselcore']) + self.add(users_and_groups) + def init(): """Initialize the quassel module.""" diff --git a/plinth/modules/radicale/__init__.py b/plinth/modules/radicale/__init__.py index 3ab67c2b3..e7d7d1a21 100644 --- a/plinth/modules/radicale/__init__.py +++ b/plinth/modules/radicale/__init__.py @@ -17,6 +17,7 @@ from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Uwsgi, Webserver from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups from plinth.utils import format_lazy, Version from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -39,8 +40,6 @@ _description = [ 'contacts, which must be done using a separate client.'), ] -reserved_usernames = ['radicale'] - logger = logging.getLogger(__name__) CONFIG_FILE = '/etc/radicale/config' @@ -91,6 +90,10 @@ class RadicaleApp(app_module.App): daemon = RadicaleDaemon('daemon-radicale', managed_services[0]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-radicale', + reserved_usernames=['radicale']) + self.add(users_and_groups) + class RadicaleWebserver(Webserver): """Webserver enable/disable behavior specific for radicale.""" diff --git a/plinth/modules/samba/__init__.py b/plinth/modules/samba/__init__.py index 48cff4d64..e6f9376af 100644 --- a/plinth/modules/samba/__init__.py +++ b/plinth/modules/samba/__init__.py @@ -17,7 +17,7 @@ from plinth import app as app_module from plinth import frontpage, menu from plinth.daemon import Daemon from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from plinth.utils import format_lazy from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -44,8 +44,6 @@ _description = [ 'own private space.'), ] -group = ('freedombox-share', _('Access to the private shares')) - app = None @@ -57,6 +55,9 @@ class SambaApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = {'freedombox-share': _('Access to the private shares')} + info = app_module.Info(app_id=self.app_id, version=version, name=_('Samba'), icon_filename='samba', short_description=_('File Sharing'), @@ -74,7 +75,7 @@ class SambaApp(app_module.App): short_description=info.short_description, icon=info.icon_filename, description=info.description, configure_url=reverse_lazy('samba:index'), clients=info.clients, - login_required=True, allowed_groups=[group[0]]) + login_required=True, allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-samba', info.name, ports=['samba']) @@ -92,12 +93,15 @@ class SambaApp(app_module.App): self.add(daemon_nmbd) + users_and_groups = UsersAndGroups('users-and-groups-samba', + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the module.""" global app app = SambaApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/searx/__init__.py b/plinth/modules/searx/__init__.py index 4f0276d66..18ff6f50f 100644 --- a/plinth/modules/searx/__init__.py +++ b/plinth/modules/searx/__init__.py @@ -12,7 +12,7 @@ from plinth import app as app_module from plinth import frontpage, menu from plinth.modules.apache.components import Uwsgi, Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from .manifest import (PUBLIC_ACCESS_SETTING_FILE, # noqa, pylint: disable=unused-import backup, clients) @@ -28,8 +28,6 @@ _description = [ 'It stores no cookies by default.') ] -group = ('web-search', _('Search the web')) - manual_page = 'Searx' app = None @@ -43,6 +41,9 @@ class SearxApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = {'web-search': _('Search the web')} + info = app_module.Info(app_id=self.app_id, version=version, name=_('Searx'), icon_filename='searx', short_description=_('Web Search'), @@ -60,7 +61,7 @@ class SearxApp(app_module.App): short_description=info.short_description, icon=info.icon_filename, url='/searx/', clients=info.clients, login_required=(not is_public_access_enabled()), - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-searx', info.name, @@ -78,6 +79,10 @@ class SearxApp(app_module.App): uwsgi = Uwsgi('uwsgi-searx', 'searx') self.add(uwsgi) + users_and_groups = UsersAndGroups('users-and-groups-searx', + groups=groups) + self.add(users_and_groups) + def set_shortcut_login_required(self, login_required): """Change the login_required property of shortcut.""" shortcut = self.remove('shortcut-searx') @@ -101,7 +106,6 @@ def init(): """Initialize the module.""" global app app = SearxApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/sharing/forms.py b/plinth/modules/sharing/forms.py index fc74fa2b9..29b067e8c 100644 --- a/plinth/modules/sharing/forms.py +++ b/plinth/modules/sharing/forms.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from plinth.modules import sharing -from plinth.modules.users.forms import get_group_choices +from plinth.modules.users.components import UsersAndGroups class AddShareForm(forms.Form): @@ -29,6 +29,7 @@ class AddShareForm(forms.Form): 'Make files in this folder available to anyone with the link.')) groups = forms.MultipleChoiceField( + choices=UsersAndGroups.get_group_choices, widget=forms.CheckboxSelectMultiple, required=False, label=_('User groups that can read the files in the share'), help_text=_( @@ -38,7 +39,6 @@ class AddShareForm(forms.Form): def __init__(self, *args, **kwargs): """Initialize the form with extra request argument.""" super().__init__(*args, **kwargs) - self.fields['groups'].choices = get_group_choices() self.fields['name'].widget.attrs.update({'autofocus': 'autofocus'}) def clean_name(self): diff --git a/plinth/modules/syncthing/__init__.py b/plinth/modules/syncthing/__init__.py index ac43cc1a1..f1a8a01ec 100644 --- a/plinth/modules/syncthing/__init__.py +++ b/plinth/modules/syncthing/__init__.py @@ -11,7 +11,7 @@ from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from plinth.utils import format_lazy from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -37,8 +37,6 @@ _description = [ 'users belonging to the "admin" group.'), box_name=_(cfg.box_name)), ] -group = ('syncthing', _('Administer Syncthing application')) - app = None @@ -50,6 +48,9 @@ class SyncthingApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + self.groups = {'syncthing': _('Administer Syncthing application')} + info = app_module.Info(app_id=self.app_id, version=version, name=_('Syncthing'), icon_filename='syncthing', short_description=_('File Synchronization'), @@ -67,7 +68,7 @@ class SyncthingApp(app_module.App): icon=info.icon_filename, url='/syncthing/', clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(self.groups)) self.add(shortcut) firewall = Firewall('firewall-syncthing-web', info.name, @@ -85,12 +86,15 @@ class SyncthingApp(app_module.App): daemon = Daemon('daemon-syncthing', managed_services[0]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-syncthing', + groups=self.groups) + self.add(users_and_groups) + def init(): """Initialize the module.""" global app app = SyncthingApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/tor/__init__.py b/plinth/modules/tor/__init__.py index 72d03e591..6c55029f2 100644 --- a/plinth/modules/tor/__init__.py +++ b/plinth/modules/tor/__init__.py @@ -15,6 +15,7 @@ from plinth.modules.apache.components import diagnose_url from plinth.modules.firewall.components import Firewall from plinth.modules.names.components import DomainType from plinth.signals import domain_added, domain_removed +from plinth.modules.users.components import UsersAndGroups from . import utils from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -38,8 +39,6 @@ _description = [ 'Tor Browser.') ] -reserved_usernames = ['debian-tor'] - app = None @@ -82,6 +81,10 @@ class TorApp(app_module.App): (9040, 'tcp6'), (9053, 'udp4'), (9053, 'udp6')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-tor', + reserved_usernames=['debian-tor']) + self.add(users_and_groups) + def diagnose(self): """Run diagnostics and return the results.""" results = super().diagnose() diff --git a/plinth/modules/transmission/__init__.py b/plinth/modules/transmission/__init__.py index eac87db5f..29bf1367c 100644 --- a/plinth/modules/transmission/__init__.py +++ b/plinth/modules/transmission/__init__.py @@ -13,7 +13,8 @@ from plinth import frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import add_user_to_share_group, register_group +from plinth.modules.users import add_user_to_share_group +from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -29,12 +30,10 @@ _description = [ 'BitTorrent is not anonymous.'), ] -reserved_usernames = ['debian-transmission'] - -group = ('bit-torrent', _('Download files using BitTorrent applications')) - app = None +SYSTEM_USER = 'debian-transmission' + class TransmissionApp(app_module.App): """FreedomBox app for Transmission.""" @@ -44,6 +43,10 @@ class TransmissionApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = { + 'bit-torrent': _('Download files using BitTorrent applications') + } info = app_module.Info(app_id=self.app_id, version=version, name=_('Transmission'), icon_filename='transmission', @@ -61,7 +64,7 @@ class TransmissionApp(app_module.App): 'shortcut-transmission', info.name, short_description=info.short_description, icon=info.icon_filename, url='/transmission', clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-transmission', info.name, @@ -76,12 +79,16 @@ class TransmissionApp(app_module.App): listen_ports=[(9091, 'tcp4')]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-transmission', + reserved_usernames=[SYSTEM_USER], + groups=groups) + self.add(users_and_groups) + def init(): """Initialize the Transmission module.""" global app app = TransmissionApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): @@ -99,5 +106,5 @@ def setup(helper, old_version=None): helper.call('post', actions.superuser_run, 'transmission', ['merge-configuration'], input=json.dumps(new_configuration).encode()) - add_user_to_share_group(reserved_usernames[0], managed_services[0]) + add_user_to_share_group(SYSTEM_USER, managed_services[0]) helper.call('post', app.enable) diff --git a/plinth/modules/transmission/forms.py b/plinth/modules/transmission/forms.py index 30a081cee..20a1abf46 100644 --- a/plinth/modules/transmission/forms.py +++ b/plinth/modules/transmission/forms.py @@ -5,18 +5,19 @@ FreedomBox app for configuring Transmission. from django.utils.translation import ugettext_lazy as _ -from plinth.modules.transmission import reserved_usernames from plinth.modules.storage.forms import (DirectorySelectForm, DirectoryValidator) +from . import SYSTEM_USER + class TransmissionForm(DirectorySelectForm): """Transmission configuration form""" def __init__(self, *args, **kw): - validator = DirectoryValidator( - username=reserved_usernames[0], check_creatable=True) - super(TransmissionForm, self).__init__( - title=_('Download directory'), - default='/var/lib/transmission-daemon/downloads', - validator=validator, *args, **kw) + validator = DirectoryValidator(username=SYSTEM_USER, + check_creatable=True) + super(TransmissionForm, + self).__init__(title=_('Download directory'), + default='/var/lib/transmission-daemon/downloads', + validator=validator, *args, **kw) diff --git a/plinth/modules/ttrss/__init__.py b/plinth/modules/ttrss/__init__.py index 1fdc95917..d60155ed2 100644 --- a/plinth/modules/ttrss/__init__.py +++ b/plinth/modules/ttrss/__init__.py @@ -12,7 +12,7 @@ from plinth import cfg, frontpage, menu from plinth.daemon import Daemon from plinth.modules.apache.components import Webserver from plinth.modules.firewall.components import Firewall -from plinth.modules.users import register_group +from plinth.modules.users.components import UsersAndGroups from plinth.utils import Version, format_lazy from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -39,8 +39,6 @@ _description = [ '/tt-rss-app for connecting.')) ] -group = ('feed-reader', _('Read and subscribe to news feeds')) - app = None @@ -52,6 +50,9 @@ class TTRSSApp(app_module.App): def __init__(self): """Create components for the app.""" super().__init__() + + groups = {'feed-reader': _('Read and subscribe to news feeds')} + info = app_module.Info(app_id=self.app_id, version=version, name=_('Tiny Tiny RSS'), icon_filename='ttrss', short_description=_('News Feed Reader'), @@ -69,7 +70,7 @@ class TTRSSApp(app_module.App): icon=info.icon_filename, url='/tt-rss', clients=info.clients, login_required=True, - allowed_groups=[group[0]]) + allowed_groups=list(groups)) self.add(shortcut) firewall = Firewall('firewall-ttrss', info.name, @@ -83,6 +84,10 @@ class TTRSSApp(app_module.App): daemon = Daemon('daemon-ttrss', managed_services[0]) self.add(daemon) + users_and_groups = UsersAndGroups('users-and-groups-ttrss', + groups=groups) + self.add(users_and_groups) + def enable(self): """Enable components and API access.""" super().enable() @@ -93,7 +98,6 @@ def init(): """Initialize the module.""" global app app = TTRSSApp() - register_group(group) setup_helper = globals()['setup_helper'] if setup_helper.get_state() != 'needs-setup' and app.is_enabled(): diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index 84e702a31..ddb27f584 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -14,6 +14,8 @@ from plinth import cfg, menu from plinth.daemon import Daemon from plinth.utils import format_lazy +from .components import UsersAndGroups + version = 3 is_essential = True @@ -45,9 +47,6 @@ _description = [ box_name=_(cfg.box_name)) ] -# All FreedomBox user groups -groups = dict() - app = None @@ -75,6 +74,12 @@ class UsersApp(app_module.App): listen_ports=[(389, 'tcp4'), (389, 'tcp6')]) self.add(daemon) + # Add the admin group + groups = {'admin': _('Access to all services and system settings')} + users_and_groups = UsersAndGroups('users-and-groups-admin', + groups=groups) + self.add(users_and_groups) + def diagnose(self): """Run diagnostics and return the results.""" results = super().diagnose() @@ -129,10 +134,6 @@ def remove_group(group): actions.superuser_run('users', options=['remove-group', group]) -def register_group(group): - groups[group[0]] = group[1] - - def get_last_admin_user(): """If there is only one admin user return its name else return None.""" output = actions.superuser_run('users', ['get-group-users', 'admin']) diff --git a/plinth/modules/users/components.py b/plinth/modules/users/components.py new file mode 100644 index 000000000..1d79e412b --- /dev/null +++ b/plinth/modules/users/components.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +App component to manage users and groups. +""" + +import itertools + +from plinth import app + + +class UsersAndGroups(app.FollowerComponent): + """Component to manage users and groups of an app.""" + + # Class variable to hold a list of user groups for apps + _all_components = set() + + def __init__(self, component_id, reserved_usernames=[], groups={}): + """Store reserved_usernames and groups of the app. + + 'reserved_usernames' is a list of operating system user names that the + app uses. It is not permitted to create a FreedomBox user with one of + these names. + + 'groups' is a dictionary of the following format: {"group_name": "A + localized string describing what permissions are offered to the users + of this group"}. + + """ + super().__init__(component_id) + + self.reserved_usernames = reserved_usernames + self.groups = groups + + self._all_components.add(self) + + @classmethod + def get_groups(cls): + """Return a set of all groups.""" + all_groups = itertools.chain(*(component.groups.keys() + for component in cls._all_components)) + return set(all_groups) + + @classmethod + def get_group_choices(cls): + """Return list of groups that can be used as form choices.""" + all_groups = itertools.chain(*(component.groups.items() + for component in cls._all_components)) + choices = [(group, f'{description} ({group})') + for group, description in set(all_groups)] + return sorted(choices, key=lambda g: g[0]) + + @classmethod + def is_username_reserved(cls, username): + """Returns whether the given username is reserved or not.""" + return any((username in component.reserved_usernames + for component in cls._all_components)) diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index 96ef0e4f0..19ea922af 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -3,7 +3,6 @@ import pwd import re -import plinth.forms from django import forms from django.contrib import auth, messages from django.contrib.auth.forms import SetPasswordForm, UserCreationForm @@ -13,23 +12,17 @@ from django.core.exceptions import ValidationError from django.utils.deconstruct import deconstructible from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy -from plinth import actions, module_loader + +import plinth.forms +from plinth import actions from plinth.errors import ActionError -from plinth.modules import first_boot, users +from plinth.modules import first_boot from plinth.modules.security import set_restricted_access from plinth.translation import set_language from plinth.utils import is_user_admin from . import get_last_admin_user - - -def get_group_choices(): - """Return localized group description and group name in one string.""" - admin_group = ('admin', _('Access to all services and system settings')) - users.register_group(admin_group) - choices = [(k, ('{} ({})'.format(users.groups[k], k))) - for k in users.groups] - return sorted(choices, key=lambda g: g[0]) +from .components import UsersAndGroups class ValidNewUsernameCheckMixin(object): @@ -40,8 +33,8 @@ class ValidNewUsernameCheckMixin(object): username = self.cleaned_data['username'] if self.instance.username != username and \ not self.is_valid_new_username(): - raise ValidationError( - _('Username is taken or is reserved.'), code='invalid') + raise ValidationError(_('Username is taken or is reserved.'), + code='invalid') return username @@ -52,10 +45,8 @@ class ValidNewUsernameCheckMixin(object): if username.lower() in existing_users: return False - for module_name, module in module_loader.loaded_modules.items(): - for reserved_username in getattr(module, 'reserved_usernames', []): - if username.lower() == reserved_username.lower(): - return False + if UsersAndGroups.is_username_reserved(username.lower()): + return False return True @@ -88,10 +79,9 @@ class CreateUserForm(ValidNewUsernameCheckMixin, """ username = USERNAME_FIELD groups = forms.MultipleChoiceField( - choices=get_group_choices(), label=ugettext_lazy('Permissions'), - required=False, widget=forms.CheckboxSelectMultiple, - help_text=ugettext_lazy( - 'Select which services should be available to the new ' + choices=UsersAndGroups.get_group_choices, + label=ugettext_lazy('Permissions'), required=False, + widget=forms.CheckboxSelectMultiple, help_text=ugettext_lazy( 'user. The user will be able to log in to services that ' 'support single sign-on through LDAP, if they are in the ' 'appropriate group.

Users in the admin group ' @@ -109,7 +99,6 @@ class CreateUserForm(ValidNewUsernameCheckMixin, """Initialize the form with extra request argument.""" self.request = request super(CreateUserForm, self).__init__(*args, **kwargs) - self.fields['groups'].choices = get_group_choices() self.fields['username'].widget.attrs.update({ 'autofocus': 'autofocus', 'autocapitalize': 'none', @@ -175,7 +164,7 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, def __init__(self, request, username, *args, **kwargs): """Initialize the form with extra request argument.""" - group_choices = dict(get_group_choices()) + group_choices = dict(UsersAndGroups.get_group_choices()) for group in group_choices: Group.objects.get_or_create(name=group) @@ -313,9 +302,8 @@ class UserChangePasswordForm(SetPasswordForm): """Initialize the form with extra request argument.""" self.request = request super(UserChangePasswordForm, self).__init__(*args, **kwargs) - self.fields['new_password1'].widget.attrs.update({ - 'autofocus': 'autofocus' - }) + self.fields['new_password1'].widget.attrs.update( + {'autofocus': 'autofocus'}) def save(self, commit=True): """Save the user model and change LDAP password as well.""" @@ -366,7 +354,7 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm): _('Failed to add new user to admin group.')) # Create initial Django groups - for group_choice in get_group_choices(): + for group_choice in UsersAndGroups.get_group_choices(): auth.models.Group.objects.get_or_create(name=group_choice[0]) admin_group = auth.models.Group.objects.get(name='admin') diff --git a/plinth/modules/users/tests/test_components.py b/plinth/modules/users/tests/test_components.py new file mode 100644 index 000000000..2ab2e0276 --- /dev/null +++ b/plinth/modules/users/tests/test_components.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Tests for the UsersAndGroups app component. +""" + +import pytest + +from ..components import UsersAndGroups + + +@pytest.fixture(autouse=True) +def fixture_empty_components(): + """Remove all components from the global list before every test.""" + UsersAndGroups._all_components = set() + + +def test_create_users_and_groups_component(): + """Test initialization of users and groups component.""" + component = UsersAndGroups('simple-component') + assert component.groups == {} + assert component.reserved_usernames == [] + assert len(component._all_components) == 1 + assert component in component._all_components + + groups = {'test-group1', 'Test description'} + component = UsersAndGroups('another-component', groups=groups, + reserved_usernames=['test-user1']) + assert component.groups == groups + assert component.reserved_usernames == ['test-user1'] + assert len(component._all_components) == 2 + assert component in component._all_components + + +def test_get_groups(): + """Test getting all the groups. + + Test that: + 1. Group names are unique + 2. All components have the same global set of groups + + """ + UsersAndGroups('component-with-no-groups') + UsersAndGroups('component-with-one-group', + groups={'group1': 'description1'}) + UsersAndGroups('component-with-groups', groups={ + 'group1': 'description1', + 'group2': 'description2' + }) + + assert UsersAndGroups.get_groups() == {'group1', 'group2'} + assert UsersAndGroups.get_group_choices() == [ + ('group1', 'description1 (group1)'), + ('group2', 'description2 (group2)') + ] + + +def test_check_username_reservation(): + """Test username reservations by multiple components.""" + UsersAndGroups('complex-component', + reserved_usernames=['username1', 'username2'], + groups={'somegroup', 'some description'}) + assert not UsersAndGroups.is_username_reserved('something') + assert UsersAndGroups.is_username_reserved('username1') + + assert not UsersAndGroups.is_username_reserved('username3') + UsersAndGroups('temp-component', reserved_usernames=['username3']) + assert UsersAndGroups.is_username_reserved('username3') diff --git a/plinth/modules/users/tests/test_group.py b/plinth/modules/users/tests/test_group.py deleted file mode 100644 index 4d6cf141a..000000000 --- a/plinth/modules/users/tests/test_group.py +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Test module to exercise group registration. - -It is recommended to run this module with root privileges in a virtual machine. -""" - -from plinth.modules import users - - -def test_register_group(): - """Test for multi addition of same group""" - users.groups = dict() # reset groups - group = ('TestGroup', 'Group for testing') - users.register_group(group) - users.register_group(group) - assert len(users.groups) == 1 - return users.groups diff --git a/plinth/modules/users/tests/test_views.py b/plinth/modules/users/tests/test_views.py index b0e0401b2..bc59deb80 100644 --- a/plinth/modules/users/tests/test_views.py +++ b/plinth/modules/users/tests/test_views.py @@ -13,6 +13,8 @@ from django.core.exceptions import PermissionDenied from plinth import module_loader from plinth.modules.users import views +from ..components import UsersAndGroups + # For all tests, plinth.urls instead of urls configured for testing, and # django database pytestmark = [pytest.mark.urls('plinth.urls'), pytest.mark.django_db] @@ -43,15 +45,16 @@ def action_run(action, options, **kwargs): @pytest.fixture(autouse=True) def module_patch(): """Patch users module.""" - loaded_modules = [('minetest', - Mock(reserved_usernames=['Debian-minetest']))] pwd_users = [Mock(pw_name='root'), Mock(pw_name='plinth')] - with patch('pwd.getpwall', return_value=pwd_users),\ - patch('plinth.actions.superuser_run', side_effect=action_run),\ - patch('plinth.module_loader.loaded_modules.items', - return_value=loaded_modules): + UsersAndGroups._all_components = set() + UsersAndGroups('test-users-and-groups', + groups={'admin': 'The admin group'}) + UsersAndGroups('users-and-groups-minetest', + reserved_usernames=['debian-minetest']) + with patch('pwd.getpwall', return_value=pwd_users),\ + patch('plinth.actions.superuser_run', side_effect=action_run): yield