Implement reserved username handling for users, LDAP and Keycloak

Add end-to-end support for reserved usernames and tighten CAPTCHA / Keycloak logic.

Changes:

- Makefile: rename EXTRA_USERS → RESERVED_USERNAMES and pass it as --reserved-usernames to the users defaults generator.

- cli/build/defaults/users.py: propagate  flag into generated users, add --reserved-usernames CLI option and mark listed accounts as reserved.

- Add reserved_users filter plugin with  and  helpers for Ansible templates and tasks.

- Add unit tests for reserved_users filters and the new reserved-usernames behaviour in the users defaults generator.

- group_vars/all/00_general.yml: harden RECAPTCHA_ENABLED / HCAPTCHA_ENABLED checks with default('') and explicit > 0 length checks.

- svc-db-openldap: introduce OPENLDAP_PROVISION_* flags, add OPENLDAP_PROVISION_RESERVED and OPERNLDAP_USERS to optionally exclude reserved users from provisioning.

- svc-db-openldap templates/tasks: switch role/group LDIF and user import loops to use OPERNLDAP_USERS instead of the full users dict.

- networks: assign dedicated subnet for web-app-roulette-wheel.

- web-app-keycloak vars: compute KEYCLOAK_RESERVED_USERNAMES_LIST and KEYCLOAK_RESERVED_USERNAMES_REGEX from users | reserved_usernames.

- web-app-keycloak user profile template: inject reserved-username regex into username validation pattern and improve error message, fix SSH public key attribute usage and add component name field.

- web-app-keycloak update/_update.yml: strip subComponents from component payloads before update and disable async/poll for easier debugging.

- web-app-keycloak tasks/main.yml: guard cleanup include with MODE_CLEANUP and keep reCAPTCHA update behind KEYCLOAK_RECAPTCHA_ENABLED.

- user/users defaults: mark system/service accounts (root, daemon, mail, admin, webmaster, etc.) as reserved so they cannot be chosen as login names.

- svc-prx-openresty vars: simplify OPENRESTY_CONTAINER lookup by dropping unused default parameter.

- sys-ctl-rpr-btrfs-balancer: simplify main.yml by removing the extra block wrapper.

- sys-daemon handlers: quote handler name for consistency.

Context: change set discussed and refined in ChatGPT on 2025-11-29 (Infinito.Nexus reserved usernames & Keycloak user profile flow). See conversation: https://chatgpt.com/share/692b21f5-5d98-800f-8e15-1ded49deddc9
This commit is contained in:
2025-11-29 17:40:45 +01:00
parent 3b3725cbd1
commit 26dfab147d
20 changed files with 400 additions and 36 deletions

View File

@@ -11,7 +11,7 @@ INCLUDE_GROUPS := $(shell python3 main.py meta categories invokable -s "-" --no-
INCLUDES_OUT_DIR := ./tasks/groups
# Compute extra users as before
EXTRA_USERS := $(shell \
RESERVED_USERNAMES := $(shell \
find $(ROLES_DIR) -maxdepth 1 -type d -printf '%f\n' \
| sed -E 's/.*-//' \
| grep -E -x '[a-z0-9]+' \
@@ -50,7 +50,7 @@ messy-build: dockerignore
python3 $(USERS_SCRIPT) \
--roles-dir $(ROLES_DIR) \
--output $(USERS_OUT) \
--extra-users "$(EXTRA_USERS)"
--reserved-usernames "$(RESERVED_USERNAMES)"
@echo "✅ Users defaults written to $(USERS_OUT)\n"
@echo "🔧 Generating applications defaults → $(APPLICATIONS_OUT)"

View File

@@ -70,6 +70,7 @@ def build_users(defs, primary_domain, start_id, become_pwd):
description = overrides.get('description')
roles = overrides.get('roles', [])
password = overrides.get('password', become_pwd)
reserved = overrides.get('reserved', False)
# Determine UID and GID
if 'uid' in overrides:
@@ -89,6 +90,9 @@ def build_users(defs, primary_domain, start_id, become_pwd):
if description is not None:
entry['description'] = description
if reserved:
entry['reserved'] = reserved
users[key] = entry
# Ensure uniqueness of usernames and emails
@@ -180,8 +184,8 @@ def parse_args():
help='Starting UID/GID number (default: 1001).'
)
parser.add_argument(
'--extra-users', '-e',
help='Comma-separated list of additional usernames to include.',
'--reserved-usernames', '-e',
help='Comma-separated list of usernames to reserve.',
default=None
)
return parser.parse_args()
@@ -198,17 +202,21 @@ def main():
print(f"Error merging user definitions: {e}", file=sys.stderr)
sys.exit(1)
# Add extra users if specified
if args.extra_users:
for name in args.extra_users.split(','):
# Add reserved/ users if specified
if args.reserved_usernames:
for name in args.reserved_usernames.split(','):
user_key = name.strip()
if not user_key:
continue
if user_key in definitions:
print(f"Warning: extra user '{user_key}' already defined; skipping.", file=sys.stderr)
print(
f"Warning: reserved user '{user_key}' already defined; skipping (not changing existing definition).",
file=sys.stderr
)
else:
definitions[user_key] = {}
# Mark user as reserved
definitions[user_key]["reserved"] = True
try:
users = build_users(
definitions,

View File

@@ -0,0 +1,53 @@
from ansible.errors import AnsibleFilterError
import re
def reserved_usernames(users_dict):
"""
Return a list of usernames where reserved: true.
Usernames are regex-escaped to be safely embeddable.
"""
if not isinstance(users_dict, dict):
raise AnsibleFilterError("reserved_usernames expects a dictionary.")
results = []
for _key, user in users_dict.items():
if not isinstance(user, dict):
continue
if not user.get("reserved", False):
continue
username = user.get("username")
if username:
results.append(re.escape(str(username)))
return results
def non_reserved_users(users_dict):
"""
Return a dict of users where reserved != true.
"""
if not isinstance(users_dict, dict):
raise AnsibleFilterError("non_reserved_users expects a dictionary.")
results = {}
for key, user in users_dict.items():
if not isinstance(user, dict):
continue
if user.get("reserved", False):
continue
results[key] = user
return results
class FilterModule(object):
"""User filters for extracting reserved and non-reserved subsets."""
def filters(self):
return {
"reserved_usernames": reserved_usernames,
"non_reserved_users": non_reserved_users,
}

View File

@@ -98,5 +98,10 @@ CAPTCHA:
KEY: ""
SECRET: ""
RECAPTCHA_ENABLED: "{{ CAPTCHA.RECAPTCHA.KEY | default('') | length and CAPTCHA.RECAPTCHA.SECRET | default('') | length }}"
HCAPTCHA_ENABLED: "{{ CAPTCHA.HCAPTCHA.KEY | default('') | length and CAPTCHA.HCAPTCHA.SECRET | default('') | length }}"
RECAPTCHA_ENABLED: "{{ (CAPTCHA.RECAPTCHA.KEY | default('') | length > 0)
and
(CAPTCHA.RECAPTCHA.SECRET | default('') | length > 0) }}"
HCAPTCHA_ENABLED: "{{ (CAPTCHA.HCAPTCHA.KEY | default('') | length > 0)
and
(CAPTCHA.HCAPTCHA.SECRET | default('') | length > 0) }}"

View File

@@ -122,6 +122,8 @@ defaults_networks:
subnet: 192.168.104.112/28
web-app-littlejs:
subnet: 192.168.104.128/28
web-app-roulette-wheel:
subnet: 192.168.104.144/28
# /24 Networks / 254 Usable Clients
web-app-bigbluebutton:

View File

@@ -18,12 +18,13 @@ docker:
data: "openldap_data"
features:
ldap: true
provisioning:
provision:
# Here it's possible to define what should be imported and updated.
# It doesn't make sense to let the import run everytime because its very time consuming
configuration: true # E.g. MemberOf and Hashed Password Configuration
credentials: true # Administrator Password
schemas: true # E.g. Nextcloud, Openssl
users: true # E.g. User, group and role entries
groups: true # Roles and Groups import
update: true # User Class updates
configuration: true # E.g. MemberOf and Hashed Password Configuration
credentials: true # Administrator Password
schemas: true # E.g. Nextcloud, Openssl
users: true # E.g. User, group and role entries
groups: true # Roles and Groups import
update: true # User Class updates
reserved: false # Reserved Users aren't provisioned

View File

@@ -20,7 +20,7 @@
state: present # ↳ creates but never updates
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
loop: "{{ users | dict2items }}"
loop: "{{ OPERNLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"
@@ -39,7 +39,7 @@
state: exact
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
loop: "{{ users | dict2items }}"
loop: "{{ OPERNLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"

View File

@@ -38,7 +38,7 @@
include_tasks: 01_credentials.yml
when:
- OPENLDAP_NETWORK_SWITCH_LOCAL | bool
- applications | get_app_conf(application_id, 'provisioning.credentials')
- OPENLDAP_PROVISION_CREDENTIALS | bool
- name: "create directory {{ OPENLDAP_LDIF_PATH_HOST }}{{ item }}"
file:
@@ -53,7 +53,7 @@
- configuration
loop_control:
loop_var: folder
when: applications | get_app_conf(application_id, 'provisioning.configuration')
when: OPENLDAP_PROVISION_CONFIGURATION | bool
- name: flush LDIF handlers
meta: flush_handlers
@@ -66,11 +66,11 @@
- name: "Include Schemas (if enabled)"
include_tasks: 02_schemas.yml
when: applications | get_app_conf(application_id, 'provisioning.schemas')
when: OPENLDAP_PROVISION_SCHEMAS | bool
- name: "Import LDAP Entries (if enabled)"
include_tasks: 03_users.yml
when: applications | get_app_conf(application_id, 'provisioning.users')
when: OPENLDAP_PROVISION_USERS | bool
- name: "Import LDIF Data (if enabled)"
include_tasks: _ldifs_creation.yml
@@ -78,10 +78,10 @@
- groups
loop_control:
loop_var: folder
when: applications | get_app_conf(application_id, 'provisioning.groups')
when: OPENLDAP_PROVISION_GROUPS | bool
- meta: flush_handlers
- name: "Add Objects to all users"
include_tasks: 04_update.yml
when: applications | get_app_conf(application_id, 'provisioning.update')
when: OPENLDAP_PROVISION_UPDATE | bool

View File

@@ -1,4 +1,4 @@
{% for dn, entry in (applications | build_ldap_role_entries(users, LDAP)).items() %}
{% for dn, entry in (applications | build_ldap_role_entries(OPERNLDAP_USERS, LDAP)).items() %}
dn: {{ dn }}
{% for oc in entry.objectClass %}

View File

@@ -24,4 +24,16 @@ OPENLDAP_NETWORK: "{{ applications | get_app_conf(application_id,
# Network
OPENLDAP_NETWORK_SWITCH_PUBLIC: "{{ applications | get_app_conf(application_id, 'network.public') }}"
OPENLDAP_NETWORK_SWITCH_LOCAL: "{{ applications | get_app_conf(application_id, 'network.local') }}"
OPENLDAP_NETWORK_EXPOSE_LOCAL: "{{ OPENLDAP_NETWORK_SWITCH_PUBLIC | bool or OPENLDAP_NETWORK_SWITCH_LOCAL | bool }}"
OPENLDAP_NETWORK_EXPOSE_LOCAL: "{{ OPENLDAP_NETWORK_SWITCH_PUBLIC | bool or OPENLDAP_NETWORK_SWITCH_LOCAL | bool }}"
# Provision
OPENLDAP_PROVISION_CONFIGURATION: "{{ applications | get_app_conf(application_id, 'provision.configuration') }}"
OPENLDAP_PROVISION_CREDENTIALS: "{{ applications | get_app_conf(application_id, 'provision.credentials') }}"
OPENLDAP_PROVISION_SCHEMAS: "{{ applications | get_app_conf(application_id, 'provision.schemas') }}"
OPENLDAP_PROVISION_USERS: "{{ applications | get_app_conf(application_id, 'provision.users') }}"
OPENLDAP_PROVISION_GROUPS: "{{ applications | get_app_conf(application_id, 'provision.groups') }}"
OPENLDAP_PROVISION_UPDATE: "{{ applications | get_app_conf(application_id, 'provision.update') }}"
OPENLDAP_PROVISION_RESERVED: "{{ applications | get_app_conf(application_id, 'provision.reserved') }}"
# Users to be processed by LDAP
OPERNLDAP_USERS: "{{ users if OPENLDAP_PROVISION_RESERVED else users | non_reserved_users }}"

View File

@@ -7,4 +7,4 @@ database_type: ""
# Openresty
OPENRESTY_IMAGE: "openresty/openresty"
OPENRESTY_VERSION: "alpine"
OPENRESTY_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.openresty.name', True) }}"
OPENRESTY_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.openresty.name') }}"

View File

@@ -1,3 +1,2 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: 01_core.yml
when: run_once_sys_ctl_rpr_btrfs_balancer is not defined

View File

@@ -34,7 +34,7 @@
- reload system daemon
- reexec systemd manager
- name: reload system daemon
- name: "reload system daemon"
ansible.builtin.systemd:
daemon_reload: true
become: true

View File

@@ -3,127 +3,169 @@ users:
sld:
description: "Auto Generated Account to reserve the SLD"
username: "{{ PRIMARY_DOMAIN.split('.')[0] }}"
reserved: true
tld:
description: "Auto Generated Account to reserve the TLD"
username: "{{ PRIMARY_DOMAIN.split('.')[1] if (PRIMARY_DOMAIN is defined and (PRIMARY_DOMAIN.split('.') | length) > 1) else (PRIMARY_DOMAIN ~ '_tld ') }}"
reserved: true
root:
username: root
uid: 0
gid: 0
description: "System superuser"
reserved: true
daemon:
username: daemon
description: "Daemon processes owner"
reserved: true
bin:
username: bin
description: "Owner of essential binaries"
reserved: true
sys:
username: sys
description: "System files owner"
reserved: true
sync:
username: sync
description: "Sync user for filesystem synchronization"
reserved: true
games:
username: games
description: "Games and educational software owner"
reserved: true
man:
username: man
description: "Manual pages viewer"
reserved: true
lp:
username: lp
description: "Printer spooler"
reserved: true
mail:
username: mail
description: "Mail system"
reserved: true
news:
username: news
description: "Network news system"
reserved: true
uucp:
username: uucp
description: "UUCP system"
reserved: true
proxy:
username: proxy
description: "Proxy user"
reserved: true
backup:
username: backup
description: "Backup operator"
reserved: true
list:
username: list
description: "Mailing list manager"
reserved: true
irc:
username: irc
description: "IRC services user"
reserved: true
gnats:
username: gnats
description: "GNATS bug-reporting system"
reserved: true
nobody:
username: nobody
description: "Unprivileged user"
reserved: true
messagebus:
username: messagebus
description: "D-Bus message bus system"
reserved: true
sshd:
username: sshd
description: "SSH daemon"
reserved: true
rpc:
username: rpc
description: "Rpcbind daemon"
reserved: true
ftp:
username: ftp
description: "FTP server"
reserved: true
postfix:
username: postfix
description: "Postfix mail transfer agent"
reserved: true
mysql:
username: mysql
description: "MySQL database server"
reserved: true
mongodb:
username: mongodb
description: "MongoDB database server"
reserved: true
admin:
username: admin
description: "Generic reserved username"
reserved: true
administrator:
username: administrator
reserved: true
user:
username: user
description: "Generic reserved username"
reserved: true
test:
username: test
description: "Generic reserved username"
reserved: true
guest:
username: guest
description: "Generic reserved username"
reserved: true
demo:
username: demo
description: "Generic reserved username"
reserved: true
info:
username: info
description: "Generic reserved username"
reserved: true
support:
username: support
description: "Generic reserved username"
reserved: true
helpdesk:
username: helpdesk
description: "Generic reserved username"
reserved: true
operator:
username: operator
description: "Generic reserved username"
reserved: true
staff:
username: staff
description: "Generic reserved username"
reserved: true
smtp:
username: smtp
description: "Generic reserved username"
reserved: true
imap:
username: imap
description: "Generic reserved username"
reserved: true
pop:
username: pop
description: "Generic reserved username"
reserved: true
webmaster:
username: webmaster
description: "Generic reserved username"
reserved: true
mailman:
username: mailman
description: "Generic reserved username"
reserved: true

View File

@@ -5,6 +5,7 @@
- name: "Load cleanup routine for '{{ application_id }}'"
include_tasks: 02_cleanup.yml
when: MODE_CLEANUP | bool
- name: "Load init routine for '{{ application_id }}'"
include_tasks: 03_init.yml
@@ -35,3 +36,4 @@
- name: "Load reCAPTCHA Update routines for '{{ application_id }}'"
include_tasks: update/06_recaptcha.yml
when: KEYCLOAK_RECAPTCHA_ENABLED | bool

View File

@@ -146,6 +146,19 @@
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Drop unsupported fields for components (e.g. subComponents)
when: kc_object_kind == 'component'
set_fact:
desired_obj: >-
{{
desired_obj
| dict2items
| rejectattr('key', 'equalto', 'subComponents')
| list
| items2dict
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Preserve immutable fields for client-scope
when: kc_object_kind == 'client-scope'
set_fact:
@@ -181,5 +194,5 @@
{{ desired_obj | to_json }}
JSON
{%- endif %}
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
#async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
#poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"

View File

@@ -3,7 +3,13 @@
{
"name": "username",
"displayName": "${username}",
"validations": {"length": {"min": 3, "max": 255}, "pattern": {"pattern": "^[a-z0-9]+$", "error-message": ""}},
"validations": {
"length": { "min": 3, "max": 255 },
"pattern": {
"pattern": "^(?!(?:" ~ KEYCLOAK_RESERVED_USERNAMES_REGEX | replace('\\', '\\\\') ~ ")$)[a-z0-9]+$",
"error-message": "Username is reserved or contains invalid characters. Only lowercase letters (az) and digits (09) are allowed."
}
},
"annotations": {},
"permissions": {"view": ["admin","user"], "edit": ["admin","user"]},
"multivalued": false
@@ -33,7 +39,7 @@
"multivalued": false
},
{
"name": "{{ LDAP.USER.ATTRIBUTES.SSH_PUBLIC_KEY }}",
"name": LDAP.USER.ATTRIBUTES.SSH_PUBLIC_KEY,
"displayName": "SSH Public Key",
"validations": {},
"annotations": {},
@@ -53,6 +59,7 @@
"org.keycloak.userprofile.UserProfileProvider": [
{
"providerId": "declarative-user-profile",
"name": "declarative-user-profile",
"subComponents": {},
"config": {
"kc.user.profile.config": [{{ (user_profile | to_json) | to_json }}]

View File

@@ -18,6 +18,10 @@ KEYCLOAK_DOMAIN: "{{ domains | get_domain('web-app-keycloak')
KEYCLOAK_RBAC_GROUP_CLAIM: "{{ RBAC.GROUP.CLAIM }}"
KEYCLOAK_RBAC_GROUP_NAME: "{{ RBAC.GROUP.NAME }}"
# Users
KEYCLOAK_RESERVED_USERNAMES_LIST: "{{ users | reserved_usernames }}"
KEYCLOAK_RESERVED_USERNAMES_REGEX: "{{ KEYCLOAK_RESERVED_USERNAMES_LIST | join('|') }}"
## Health
KEYCLOAK_HEALTH_ENABLED: true

View File

@@ -248,6 +248,97 @@ class TestGenerateUsers(unittest.TestCase):
finally:
shutil.rmtree(tmpdir)
def test_build_users_reserved_flag_propagated(self):
"""
Ensure that the 'reserved' flag from the definitions is copied
into the final user entries, and is not added for non-reserved users.
"""
defs = {
"admin": {"reserved": True},
"bob": {},
}
build = users.build_users(
defs=defs,
primary_domain="example.com",
start_id=1001,
become_pwd="pw",
)
# Reserved user should carry the flag
self.assertIn("reserved", build["admin"])
self.assertTrue(build["admin"]["reserved"])
# Non-reserved user should not have the flag at all
self.assertNotIn("reserved", build["bob"])
def test_cli_reserved_usernames_flag_sets_reserved_field(self):
"""
Verify that --reserved-usernames marks given usernames as reserved
in the generated YAML, and that existing definitions are preserved
(only 'reserved' is added).
"""
import tempfile
import subprocess
from pathlib import Path
tmpdir = Path(tempfile.mkdtemp())
try:
roles_dir = tmpdir / "roles"
roles_dir.mkdir()
# Role with an existing user definition "admin"
(roles_dir / "role-base" / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / "role-base" / "users" / "main.yml", "w") as f:
yaml.safe_dump(
{
"users": {
"admin": {
"email": "admin@ex",
"description": "Admin from role",
}
}
},
f,
)
out_file = tmpdir / "users.yml"
script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py"
result = subprocess.run(
[
"python3",
str(script_path),
"--roles-dir",
str(roles_dir),
"--output",
str(out_file),
"--reserved-usernames",
"admin,service",
],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertTrue(out_file.exists(), "Output file was not created.")
data = yaml.safe_load(out_file.read_text())
self.assertIn("default_users", data)
users_map = data["default_users"]
# "service" was created from the reserved list and must be reserved
self.assertIn("service", users_map)
self.assertTrue(users_map["service"].get("reserved", False))
# "admin" existed before; its fields must remain unchanged,
# but it must now be marked as reserved
self.assertIn("admin", users_map)
self.assertEqual(users_map["admin"]["email"], "admin@ex")
self.assertEqual(users_map["admin"]["description"], "Admin from role")
self.assertTrue(users_map["admin"].get("reserved", False))
finally:
shutil.rmtree(tmpdir)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,125 @@
# tests/unit/filter_plugins/test_reserved_users.py
import os
import sys
import unittest
# Ensure that the filter_plugins directory is importable
CURRENT_DIR = os.path.dirname(__file__)
REPO_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", ".."))
FILTER_PLUGINS_DIR = os.path.join(REPO_ROOT, "filter_plugins")
if FILTER_PLUGINS_DIR not in sys.path:
sys.path.insert(0, FILTER_PLUGINS_DIR)
import reserved_users # noqa: E402
from reserved_users import reserved_usernames, non_reserved_users # noqa: E402
from ansible.errors import AnsibleFilterError # type: ignore # noqa: E402
class TestReservedUsersFilters(unittest.TestCase):
def setUp(self):
# Minimal sample user dict similar to your defaults
self.users = {
"admin": {
"username": "admin",
"reserved": True,
"uid": 1001,
},
"backup": {
"username": "backup",
"reserved": True,
"uid": 1002,
},
"kevin": {
"username": "kevin",
"reserved": False,
"uid": 2001,
},
"service.user": {
"username": "service.user",
"reserved": True,
"uid": 3001,
},
"no_username_field": {
"reserved": True,
"uid": 4001,
},
"not_a_dict": "invalid",
}
# -------- reserved_usernames tests --------
def test_reserved_usernames_requires_dict(self):
with self.assertRaises(AnsibleFilterError):
reserved_usernames(["not", "a", "dict"])
def test_reserved_usernames_returns_only_reserved(self):
result = reserved_usernames(self.users)
# Escaped regex strings
self.assertIn("admin", result)
self.assertIn("backup", result)
self.assertIn("service\\.user", result)
# Non-reserved user must not be included
self.assertNotIn("kevin", result)
def test_reserved_usernames_ignores_entries_without_username(self):
result = reserved_usernames(self.users)
# "no_username_field" has no username -> must not be present
# There is no raw 'no_username_field' username at all
for item in result:
self.assertNotIn("no_username_field", item)
def test_reserved_usernames_escapes_special_chars(self):
result = reserved_usernames(self.users)
# service.user → service\.user
self.assertIn("service\\.user", result)
self.assertNotIn("service.user", result)
def test_reserved_usernames_empty_dict(self):
result = reserved_usernames({})
self.assertEqual(result, [])
# -------- non_reserved_users tests --------
def test_non_reserved_users_requires_dict(self):
with self.assertRaises(AnsibleFilterError):
non_reserved_users("not-a-dict")
def test_non_reserved_users_returns_only_non_reserved(self):
result = non_reserved_users(self.users)
# Must be a dict
self.assertIsInstance(result, dict)
# Only "kevin" is non-reserved in our sample
self.assertIn("kevin", result)
self.assertNotIn("admin", result)
self.assertNotIn("backup", result)
self.assertNotIn("service.user", result) # key is "service.user" but reserved=True
def test_non_reserved_users_ignores_non_dict_entries(self):
result = non_reserved_users(self.users)
# "not_a_dict" entry must be skipped
self.assertNotIn("not_a_dict", result)
def test_non_reserved_users_empty_dict(self):
result = non_reserved_users({})
self.assertEqual(result, {})
# -------- FilterModule registration tests --------
def test_filtermodule_registers_filters(self):
fm = reserved_users.FilterModule()
filters = fm.filters()
self.assertIn("reserved_usernames", filters)
self.assertIn("non_reserved_users", filters)
# Basic sanity: they must be callables
self.assertTrue(callable(filters["reserved_usernames"]))
self.assertTrue(callable(filters["non_reserved_users"]))
if __name__ == "__main__":
unittest.main()