diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index 1e2380ad..5503d935 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -76,8 +76,9 @@ _applications_nextcloud_oidc_flavor: >- False, 'oidc_login' if applications - | get_app_conf('web-app-nextcloud','features.ldap',False, True) - else 'sociallogin' + | get_app_conf('web-app-nextcloud','features.ldap',False, True, True) + else 'sociallogin', + True ) }} diff --git a/module_utils/config_utils.py b/module_utils/config_utils.py index c47147df..b54f48ec 100644 --- a/module_utils/config_utils.py +++ b/module_utils/config_utils.py @@ -24,7 +24,7 @@ class ConfigEntryNotSetError(AppConfigKeyError): pass -def get_app_conf(applications, application_id, config_path, strict=True, default=None): +def get_app_conf(applications, application_id, config_path, strict=True, default=None, skip_missing_app=False): # Path to the schema file for this application schema_path = os.path.join('roles', application_id, 'schema', 'main.yml') @@ -133,6 +133,9 @@ def get_app_conf(applications, application_id, config_path, strict=True, default try: obj = applications[application_id] except KeyError: + if skip_missing_app: + # Simply return default instead of failing + return default if default is not None else False raise AppConfigKeyError( f"Application ID '{application_id}' not found in applications dict.\n" f"path_trace: {path_trace}\n" diff --git a/roles/web-app-nextcloud/config/main.yml b/roles/web-app-nextcloud/config/main.yml index a11f0b37..225017a9 100644 --- a/roles/web-app-nextcloud/config/main.yml +++ b/roles/web-app-nextcloud/config/main.yml @@ -91,7 +91,7 @@ docker: mem_reservation: "128m" mem_limit: "512m" pids_limit: 256 - enabled: "{{ applications | get_app_conf('web-app-nextcloud', 'features.oidc', False) }}" # Activate OIDC for Nextcloud + enabled: "{{ applications | get_app_conf('web-app-nextcloud', 'features.oidc', False, True, True) }}" # Activate OIDC for Nextcloud # floavor decides which OICD plugin should be used. # Available options: oidc_login, sociallogin # @see https://apps.nextcloud.com/apps/oidc_login @@ -194,7 +194,7 @@ plugins: enabled: false fileslibreofficeedit: # Nextcloud LibreOffice integration: allows online editing of documents with LibreOffice (https://apps.nextcloud.com/apps/fileslibreofficeedit) - enabled: "{{ not (applications | get_app_conf('web-app-nextcloud', 'plugins.richdocuments.enabled', False, True)) }}" + enabled: "{{ not (applications | get_app_conf('web-app-nextcloud', 'plugins.richdocuments.enabled', False, True, True)) }}" forms: # Nextcloud forms: facilitates creation of forms and surveys (https://apps.nextcloud.com/apps/forms) enabled: true @@ -292,13 +292,13 @@ plugins: # enabled: false twofactor_nextcloud_notification: # Nextcloud two-factor notification: sends notifications for two-factor authentication events (https://apps.nextcloud.com/apps/twofactor_nextcloud_notification) - enabled: "{{ not applications | get_app_conf('web-app-nextcloud', 'features.oidc', False, True) }}" # Deactivate 2FA if oidc is active + enabled: "{{ not applications | get_app_conf('web-app-nextcloud', 'features.oidc', False, True, True) }}" # Deactivate 2FA if oidc is active twofactor_totp: # Nextcloud two-factor TOTP: provides time-based one-time password authentication (https://apps.nextcloud.com/apps/twofactor_totp) - enabled: "{{ not applications | get_app_conf('web-app-nextcloud', 'features.oidc', False, True) }}" # Deactivate 2FA if oidc is active + enabled: "{{ not applications | get_app_conf('web-app-nextcloud', 'features.oidc', False, True, True) }}" # Deactivate 2FA if oidc is active user_ldap: # Nextcloud user LDAP: integrates LDAP for user management and authentication (https://apps.nextcloud.com/apps/user_ldap) - enabled: "{{ applications | get_app_conf('web-app-nextcloud', 'features.ldap', False, True) }}" + enabled: "{{ applications | get_app_conf('web-app-nextcloud', 'features.ldap', False, True, True) }}" user_directory: enabled: true # Enables the LDAP User Directory Search user_oidc: diff --git a/tests/unit/module_utils/test_config_utils.py b/tests/unit/module_utils/test_config_utils.py new file mode 100644 index 00000000..1bf51ad8 --- /dev/null +++ b/tests/unit/module_utils/test_config_utils.py @@ -0,0 +1,125 @@ +import os +import shutil +import tempfile +import unittest + +from module_utils.config_utils import ( + get_app_conf, + AppConfigKeyError, + ConfigEntryNotSetError, +) + +class TestGetAppConf(unittest.TestCase): + def setUp(self): + # Isolate working directory so that schema files can be discovered + self._cwd = os.getcwd() + self.tmpdir = tempfile.mkdtemp(prefix="cfgutilstest_") + os.chdir(self.tmpdir) + + # Minimal schema structure: + # roles/web-app-demo/schema/main.yml + os.makedirs(os.path.join("roles", "web-app-demo", "schema"), exist_ok=True) + with open(os.path.join("roles", "web-app-demo", "schema", "main.yml"), "w") as f: + f.write( + # Defines 'features.defined_but_unset' in schema (without a value in applications), + # plus 'features.oidc' and 'features.nested.list' + "features:\n" + " oidc: {}\n" + " defined_but_unset: {}\n" + " nested:\n" + " list:\n" + " - {}\n" + ) + + # Example configuration with actual values + self.applications = { + "web-app-demo": { + "features": { + "oidc": True, + "nested": { + "list": ["first", "second"] + } + } + } + } + + def tearDown(self): + os.chdir(self._cwd) + shutil.rmtree(self.tmpdir, ignore_errors=True) + + # --- Tests --- + + def test_missing_app_with_skip_missing_app_returns_default_true(self): + """If app ID is missing and skip_missing_app=True, it should return the default (True).""" + apps = {"some-other-app": {}} + val = get_app_conf(apps, "web-app-nextcloud", "features.oidc", + strict=True, default=True, skip_missing_app=True) + self.assertTrue(val) + + def test_missing_app_with_skip_missing_app_returns_default_false(self): + """If app ID is missing and skip_missing_app=True, it should return the default (False).""" + apps = {"svc-bkp-rmt-2-loc": {}} + val = get_app_conf(apps, "web-app-nextcloud", "features.oidc", + strict=True, default=False, skip_missing_app=True) + self.assertFalse(val) + + def test_missing_app_without_skip_missing_app_and_strict_true_raises(self): + """Missing app ID without skip_missing_app and strict=True should raise.""" + apps = {} + with self.assertRaises(AppConfigKeyError): + get_app_conf(apps, "web-app-nextcloud", "features.oidc", + strict=True, default=True, skip_missing_app=False) + + def test_missing_app_without_skip_missing_app_and_strict_false_raises(self): + apps = {} + with self.assertRaises(AppConfigKeyError): + get_app_conf(apps, "web-app-nextcloud", "features.oidc", + strict=False, default=True, skip_missing_app=False) + + def test_existing_app_returns_expected_value(self): + """Existing app and key should return the configured value.""" + val = get_app_conf(self.applications, "web-app-demo", "features.oidc", + strict=True, default=False, skip_missing_app=False) + self.assertTrue(val) + + def test_nested_list_index_access(self): + """Accessing list indices should work correctly.""" + val0 = get_app_conf(self.applications, "web-app-demo", "features.nested.list[0]", + strict=True, default=None, skip_missing_app=False) + val1 = get_app_conf(self.applications, "web-app-demo", "features.nested.list[1]", + strict=True, default=None, skip_missing_app=False) + self.assertEqual(val0, "first") + self.assertEqual(val1, "second") + + def test_schema_defined_but_unset_raises_in_strict_mode(self): + """Schema-defined but unset value should raise in strict mode.""" + with self.assertRaises(ConfigEntryNotSetError): + get_app_conf(self.applications, "web-app-demo", "features.defined_but_unset", + strict=True, default=False, skip_missing_app=False) + + def test_schema_defined_but_unset_strict_false_returns_default(self): + """Schema-defined but unset value should return default when strict=False.""" + val = get_app_conf(self.applications, "web-app-demo", "features.defined_but_unset", + strict=False, default=True, skip_missing_app=False) + self.assertTrue(val) + + def test_invalid_key_format_raises(self): + """Invalid key format in path should raise AppConfigKeyError.""" + with self.assertRaises(AppConfigKeyError): + get_app_conf(self.applications, "web-app-demo", "features.nested.list[not-an-int]", + strict=True, default=None, skip_missing_app=False) + + def test_index_out_of_range_respects_strict(self): + """Out-of-range index should respect strict parameter.""" + # strict=False returns default + val = get_app_conf(self.applications, "web-app-demo", "features.nested.list[99]", + strict=False, default="fallback", skip_missing_app=False) + self.assertEqual(val, "fallback") + # strict=True raises + with self.assertRaises(AppConfigKeyError): + get_app_conf(self.applications, "web-app-demo", "features.nested.list[99]", + strict=True, default=None, skip_missing_app=False) + + +if __name__ == "__main__": + unittest.main()