#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv)## Copyright (C) 2013 Red Hat, Inc.# SPDX-License-Identifier: LGPL-2.1-or-laterimport datetimeimport osimport testlibfrom machine import testvmgood_password = "tqymuVh.ZfZnPยง9Wr=LM3JyG5yx"SHADOW_UTILS_WITHOUT_EXPIRE = ["arch", "debian-testing", "fedora-45"]def getUserAddDetails(machine: testvm.Machine) -> dict[str, str]:useradd_defaults: dict[str, str] = {}for line in machine.execute("useradd -D").splitlines():key, value = line.split("=")useradd_defaults[key] = valuereturn useradd_defaultsdef performUserAction(browser: testlib.Browser, user: str, action: str) -> None:browser.click(f"#accounts-list tr[data-row-id='{user}'] button.pf-v6-c-menu-toggle")browser.wait_visible(f"#accounts-list tr[data-row-id='{user}'] button.pf-v6-c-menu-toggle[aria-expanded=true]")browser.click(f".pf-v6-c-menu button.pf-v6-c-menu__item:contains({action})")def performGroupAction(browser: testlib.Browser, group: str, action: str) -> None:browser.click(f"#groups-list tbody tr[data-row-id='{group}'] button.pf-v6-c-menu-toggle")browser.click(f".pf-v6-c-menu button.pf-v6-c-menu__item:contains({action})")def createUser(browser: testlib.Browser,machine: testvm.Machine,user_name: str,real_name: str,locked: bool,force_password_change: bool,password: str | None = None,custom_home_dir: str | None = None,default_shell: str | None = None,custom_shell: str | None = None,uid: str | None = None,expected_uid: str | None = None,verify_created: bool = True,run_assert_pixels: bool = False,) -> None:if default_shell is None:default_shell = getUserAddDetails(machine)["SHELL"] or "/bin/bash"browser.click('#accounts-create')browser.wait_visible('#accounts-create-dialog')if run_assert_pixels:browser.assert_pixels("#accounts-create-dialog", "accounts-create-dialog")browser.set_input_text('#accounts-create-user-name', user_name)browser.set_input_text('#accounts-create-real-name', real_name)if password:browser.set_input_text('#accounts-create-password-pw1', password)browser.set_input_text('#accounts-create-password-pw2', password)if locked:browser.click('#accounts-create-locked')browser.wait_visible('#accounts-create-locked:checked')else:browser.wait_visible('#account-use-password:checked')browser.set_checked('#accounts-create-force-password-change', force_password_change)browser.wait_visible('#accounts-create-locked:not(:checked)')if expected_uid is not None:browser.wait_visible(f'#accounts-create-user-uid[value="{expected_uid}"]')if uid is not None:browser.set_input_text('#accounts-create-user-uid', uid)if custom_home_dir is not None:browser.set_input_text('#accounts-create-user-home-dir', custom_home_dir)else:default_home_dir = getUserAddDetails(machine)["HOME"]expected_home_dir = default_home_dir + "/" + user_namebrowser.wait_visible(f"#accounts-create-user-home-dir[value='{expected_home_dir}']")if custom_shell:browser.select_from_dropdown("#accounts-create-user-shell", custom_shell)else:browser.wait_visible(f"#accounts-create-user-shell[data-selected='{default_shell}']")browser.click('#accounts-create-dialog button.apply')if verify_created:browser.wait_not_present('#accounts-create-dialog')browser.wait_in_text('#accounts-list', real_name)@testlib.nondestructive@testlib.skipImage("TODO: fix for OpenSUSE", "opensuse-tumbleweed")class TestAccounts(testlib.MachineCase):def testBasic(self) -> None:b = self.browserm = self.machine# Clean out the relevant logfilesm.execute("truncate -s0 /var/log/{[bw]tmp{,.db},lastlog} /run/utmp")m.execute("rm -f /var/lib/lastlog/lastlog2.db /var/lib/wtmpdb/wtmp.db")self.login_and_go("/users")# Add a user externallym.execute("useradd anton")m.execute("echo anton:foobar | chpasswd")with b.wait_timeout(30):b.wait_in_text('#accounts-list', "anton")# FIXME: rtl test was flaky, debug it and remove the skipb.assert_pixels("#users-page", "users-page", ignore=["td[data-label='Last active']", "button:contains('more...')"], skip_layouts=["rtl"])# There is only one badge and it is for adminb.wait_text('#current-account-badge', 'Your account')b.wait_js_cond('document.querySelector("#current-account-badge").previousSibling.getAttribute("href") === "#/admin"')# The current account is the first in the listb.wait_visible("#accounts-list > tbody :first-child #current-account-badge")# Set a real nameb.go("#/anton")b.wait_visible("#account-locked:not([disabled])")b.wait_text("#account-user-name", "anton")b.wait_text("#account-title", "anton")b.wait_text("#account-last-login", "Never")b.assert_pixels("#users-page", "user-detail-page")b.wait_not_attr("#account-delete", "disabled", "disabled")b.set_input_text('#account-real-name', "") # Check that we can delete the name before setting it upb.set_input_text('#account-real-name', 'foo:bar')b.wait_in_text('#account-real-name-helper', 'The full name must not contain colons.')b.set_input_text('#account-real-name', "Anton Arbitrary")b.wait_not_present('#account-real-name-helper')b.wait_visible('#account-real-name:not([disabled])')b.wait_text("#account-title", "Anton Arbitrary")b.wait_text("#account-home-dir", os.path.join(getUserAddDetails(m)["HOME"] + "/anton"))b.wait_text("#account-shell", getUserAddDetails(m)["SHELL"])self.assertIn(":Anton Arbitrary:", m.execute("grep anton /etc/passwd"))# Add some other GECOS fieldsb.set_input_text('#account-real-name', "Anton Arbitrary,1,123")b.wait_visible('#account-real-name:not([disabled])')self.assertIn(":Anton Arbitrary,1,123:", m.execute("grep anton /etc/passwd"))# Table title only shows real name, no other GECOS fieldsb.wait_text("#account-title", "Anton Arbitrary")# On the overview page it also shows only real nameb.go("/users")b.wait_text('#accounts-list td[data-label="Full name"]:contains("Anton")', "Anton Arbitrary")b.go("/users/#anton")# Delete itb.click('#account-delete')b.wait_visible('#account-confirm-delete-dialog')b.click('#account-confirm-delete-dialog button.apply')b.wait_not_present('#account-confirm-delete-dialog')b.wait_visible("#accounts")b.wait_not_in_text('#accounts-list', "Anton Arbitrary")m.execute("useradd --create-home barney")m.execute("echo barney:foobar | chpasswd")m.execute("runuser -u barney -- touch /home/barney/test.txt")m.execute("test -f /home/barney/test.txt")with b.wait_timeout(30):b.wait_in_text('#accounts-list', "barney")performUserAction(b, 'barney', 'Delete')b.set_checked("#account-confirm-delete-files", val=True)b.click('#account-confirm-delete-dialog button.apply')b.wait_not_present('#account-confirm-delete-dialog')m.execute("! test -e /home/barney/test.txt")b.wait_not_in_text('#accounts-list', "barney")# Attempt a real name with a colonb.click('#accounts-create')b.wait_visible('#accounts-create-dialog')b.set_input_text('#accounts-create-real-name', "Col:n Colon") # This should failb.set_input_text('#accounts-create-password-pw1', good_password)b.set_input_text('#accounts-create-password-pw2', good_password)b.click('#accounts-create-dialog button.apply')b.wait_in_text("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-error", "The full name must not contain colons.")b.click('#accounts-create-dialog button.cancel')b.wait_visible("#accounts")# Check root userb.go("#/root")b.wait_text("#account-user-name", "root")# some operations are not allowed for root userb.wait_visible("#account-delete[disabled]")b.wait_visible("#account-real-name[disabled]")b.wait_visible("#account-logout[disabled]")b.wait_visible("#account-locked:not(:checked)")# check home directory and shell for rootb.wait_text("#account-home-dir", "/root")b.wait_in_text("#account-shell", "/bin/bash") # can be /usr/bin/bash or /bin/bash# root account should not be locked by default on our imagesself.assertIn(m.execute("passwd -S root").split()[1], ["P", "PS"])# now lock accountb.set_checked("#account-locked", val=True)b.wait(lambda: m.execute("passwd -S root").split()[1] in ["L", "LK"])# Check admin userb.go("#/admin")b.wait_text("#account-user-name", "admin")# delete/logout are disabled for the current userb.wait_visible("#account-delete[disabled]")b.wait_visible("#account-logout[disabled]")# go back to accounts overview, check pf-v6-c-breadcrumbb.click("#account .pf-v6-c-breadcrumb a")b.wait_visible("#accounts-create")# Create a user from the UIself.sed_file('s@^SHELL=.*$@SHELL=/bin/true@', '/etc/default/useradd')b.click('#accounts-create')b.wait_visible('#accounts-create-dialog')b.set_input_text('#accounts-create-user-name', "Berta")b.set_input_text('#accounts-create-real-name', "Berta Bestimmt")b.set_input_text('#accounts-create-password-pw1', "foo")b.wait_visible("#accounts-create-password-meter.danger")b.set_input_text('#accounts-create-password-pw1', good_password)b.wait_visible("#accounts-create-password-meter.success")# Test password show/hide functionalityb.wait_visible('#accounts-create-password-pw1[type="password"]')b.click("#accounts-create-password-pw1-group button[aria-label='Show password']")b.wait_visible('#accounts-create-password-pw1[type="text"]')b.click("#accounts-create-password-pw1-group button[aria-label='Hide password']")b.wait_visible('#accounts-create-password-pw1[type="password"]')# Test confirmation password show/hide functionalityb.wait_visible('#accounts-create-password-pw2[type="password"]')b.click("#accounts-create-password-pw2-group button[aria-label='Show confirmation password']")b.wait_visible('#accounts-create-password-pw2[type="text"]')b.click("#accounts-create-password-pw2-group button[aria-label='Hide confirmation password']")b.wait_visible('#accounts-create-password-pw2[type="password"]')# wrong password confirmationb.set_input_text('#accounts-create-password-pw2', good_password + 'b')b.click('#accounts-create-dialog button.apply')b.wait_in_text("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-error", "The passwords do not match")b.wait_not_present('#accounts-create-dialog button.pf-m-warning')# too long passwordlong_password = "2a02-x!h4a" * 30b.set_input_text('#accounts-create-password-pw1', long_password)b.set_input_text('#accounts-create-password-pw2', long_password)b.click('#accounts-create-dialog button.apply')b.wait_in_text("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning", "Password is longer than 256 characters")b.wait_not_present('#accounts-create-dialog button.pf-m-warning')# changing input clears the error messageb.set_input_text('#accounts-create-password-pw1', "test")b.set_input_text('#accounts-create-password-pw2', "test")b.wait_not_present("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning")# correct password confirmationb.set_input_text('#accounts-create-password-pw1', good_password)b.set_input_text('#accounts-create-password-pw2', good_password)b.click('#accounts-create-dialog button.apply')b.wait_not_present("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning")b.wait_not_present('#accounts-create-dialog')b.wait_in_text('#accounts-list', "Berta Bestimmt")# Check home directoryhome_dir = m.execute("getent passwd Berta | cut -f6 -d:").strip()self.assertTrue(home_dir.endswith("/Berta"))self.assertEqual(m.execute(f"stat -c '%U' {home_dir}").strip(), "Berta")# Check that we set up shell configured in /etc/default/useraddshell = m.execute("getent passwd Berta | cut -f7 -d:").strip()self.assertEqual(shell, '/bin/true')# Delete it externallym.execute("userdel Berta")b.wait_not_in_text('#accounts-list', "Berta Bestimmt")b.logout()b.login_and_go("/users")createUser(browser=b,machine=m,user_name="robert",real_name="Robert Robertson",password=good_password,locked=False,force_password_change=True,default_shell="/bin/true",run_assert_pixels=True)# Test actions in kebab menu# disable passwordperformUserAction(b, 'robert', 'Lock account')b.click("#account-confirm-lock-dialog footer .pf-m-danger.apply")b.wait_not_present("#account-confirm-lock-dialog")# lock option is now disabledb.click("#accounts-list tbody tr[data-row-id='robert'] button.pf-v6-c-menu-toggle")b.wait_visible(".pf-v6-c-menu__list-item.pf-m-disabled:contains('Lock account')")b.click("#accounts-list tbody tr[data-row-id='robert'] button.pf-v6-c-menu-toggle")# change is visible on details pageperformUserAction(b, 'robert', 'Edit user')b.wait_in_text('#account-title', 'Robert Robertson')b.wait_visible('#account-locked:checked')b.click("#account-locked")b.go("/users")# In fedora-core userdel for this user consistently fails# userdel: user robert is currently used by process *if not m.ostree_image:performUserAction(b, 'robert', 'Delete account')b.click("#account-confirm-delete-dialog footer button.pf-m-danger.apply")b.wait_not_in_text('#accounts-list', "Robert Robertson")# test logout other usercreateUser(browser=b,machine=m,user_name="paul",real_name="Paul Robertson",password=good_password,locked=False,force_password_change=False,custom_shell="/bin/bash", # CoreOS defaults to /bin/true)b2 = self.new_browser(m)b2.login_and_go("/system", user="paul", password=good_password)b.wait_in_text("#accounts-list tbody tr:contains('paul')", "Logged in")performUserAction(b, 'paul', 'Log user out')b.click("#account-confirm-logout-dialog footer .pf-m-primary")b2.switch_to_top()b2.wait_in_text(".curtains-ct h1", "Disconnected")# HACK: lastlog does not record sessions from sshif not m.ws_container:today = b.eval_js("Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format()")b.wait_in_text("#accounts-list tbody tr:contains('paul')", today)self.allow_journal_messages("Password quality check failed:")self.allow_journal_messages("The password is a palindrome")self.allow_journal_messages("passwd: user.*does not exist")self.allow_journal_messages("passwd: Unknown user name '.*'.")self.allow_journal_messages("lastlog: Unknown user or range: anton")# when sed'ing, there is a short time when the config file does not existself.allow_journal_messages(".*libuser initialization error: .*/etc/default/useradd.*: No such file or directory")def testFilterAndSort(self) -> None:b = self.browserm = self.machineself.login_and_go("/users")createUser(browser=b,machine=m,user_name="robert",real_name="Robert Robertson",password=good_password,locked=False,force_password_change=True,)# Check Robert's groupsb.wait_not_present("#accounts-list tbody tr[data-row-id='robert'] td[data-label='Group'] .pf-v6-c-label:contains('users')")if not m.ostree_image: # Users group does not exist in coreos imagem.execute("/usr/bin/gpasswd -a robert users")b.wait_in_text("#accounts-list tbody tr[data-row-id='robert'] td[data-label='Group'] .pf-v6-c-label.pf-m-blue:contains('users')", "users")m.execute(f"/usr/bin/gpasswd -a robert {m.get_admin_group()}")b.wait_in_text("#accounts-list tbody tr[data-row-id='robert'] td[data-label='Group'] .pf-v6-c-label.pf-m-yellow", m.get_admin_group())m.execute(f"/usr/bin/gpasswd -d robert {m.get_admin_group()}")b.wait_not_present("#accounts-list tbody tr[data-row-id='robert'] td[data-label='Group'] .pf-v6-c-label.pf-m-yellow")# test filtersb.set_input_text("#accounts-filter input", "root")b.wait_in_text("#accounts-list tbody:first-of-type td[data-label='Username']", "root")b.set_input_text("#accounts-filter input", "rOBeRt")b.wait_in_text("#accounts-list tbody:first-of-type td[data-label='Username']", "robert")uid = "1000"if "debian" in m.image or "ubuntu" in m.image:uid = "1001"b.set_input_text("#accounts-filter input", uid)b.wait_in_text("#accounts-list tbody:first-of-type td[data-label='ID']", uid)b.set_input_text("#accounts-filter input", "spooky")b.wait_visible("#accounts div.pf-v6-c-empty-state")# clear text filtersb.click("#accounts-filter button[aria-label='Reset']")b.wait_not_present("#accounts div.pf-v6-c-empty-state")# clear via empty-state actionb.set_input_text("#accounts-filter input", "spooky")b.wait_visible("#accounts div.pf-v6-c-empty-state")b.click("div.pf-v6-c-empty-state button.pf-m-link")b.wait_not_present("#accounts div.pf-v6-c-empty-state")b.eval_js("""function getTextColumn(query_selector) {const values = [];document.querySelectorAll(query_selector).forEach(node => values.push(node.innerText));return values;}""")def check_column_sort(query_selector: str, invert: bool = False) -> None:# current account is always in the first rowb.wait_in_text("#accounts-list tbody:first-of-type td[data-label='Username']", "admin")values = b.eval_js(f"getTextColumn(\"{query_selector}\")")for i in range(2, len(values)):if values[i].isnumeric():value_current = int(values[i])value_prev = int(values[i - 1])else:value_current = values[i].lower()value_prev = values[i - 1].lower()if (invert):b.wait(lambda: value_prev > value_current)else:b.wait(lambda: value_prev < value_current)# robert should be in users groupif not m.ostree_image: # Users group does not exist in coreos imageb.set_input_text("#accounts-filter input", "users")names = b.eval_js("getTextColumn(\"[data-label='Username'] a\")")b.wait(lambda: "robert" in names)b.click("#accounts-filter button[aria-label='Reset']")# check alphabetical order of Usernamecheck_column_sort("[data-label='Username'] a")b.wait_visible("#accounts-list > thead > tr > th:nth-child(1) > button")b.click("#accounts-list > thead > tr > th:nth-child(1) > button")check_column_sort("[data-label='Username'] a", invert=True)# sort by full nameb.click("#accounts-list > thead > tr > th:nth-child(2) > button")check_column_sort("[data-label='Full name']")b.click("#accounts-list > thead > tr > th:nth-child(2) > button")check_column_sort("[data-label='Full name']", invert=True)# sort by IDb.click("#accounts-list > thead > tr > th:nth-child(3) > button")check_column_sort("[data-label='ID']", invert=True)b.click("#accounts-list > thead > tr > th:nth-child(3) > button")check_column_sort("[data-label='ID']")def testUserPasswords(self) -> None:b = self.browserm = self.machine# Clean out the relevant logfilesm.execute("truncate -s0 /var/log/{[bw]tmp{,.db},lastlog} /run/utmp")m.execute("rm -f /var/lib/lastlog/lastlog2.db /var/lib/wtmpdb/wtmp.db")self.login_and_go("/users")# Create a locked user with weak passwordself.sed_file('s/^SHELL=.*$/SHELL=/', '/etc/default/useradd')self.allow_journal_messages(".*required to change your password immediately.*")self.allow_journal_messages(".*user account or password has expired.*")createUser(browser=b,machine=m,user_name="js",real_name="Jussi Senior",locked=True,force_password_change=False,verify_created=False,)# Check Confirm password is not validated until it's at least the same length as the first password# Once they are the same length, it should valitate the Confirm password after each keystrokeb.set_input_text('#accounts-create-password-pw1', "foobar")b.set_input_text('#accounts-create-password-pw2', "bar")b.wait_not_present("#accounts-create-password-pw2-helper")b.set_input_text('#accounts-create-password-pw2', "foobarfoo")b.wait_in_text("#accounts-create-password-pw2-helper", "The passwords do not match")b.set_input_text('#accounts-create-password-pw2', "bar")b.wait_in_text("#accounts-create-password-pw2-helper", "The passwords do not match")b.set_input_text('#accounts-create-password-pw2', "foobar")b.wait_not_present("#accounts-create-password-pw2-helper")b.click('#accounts-create-dialog button.cancel')b.wait_not_present('#account-set-password-dialog')createUser(browser=b,machine=m,user_name="js",real_name="Jussi Senior",locked=True,force_password_change=False,verify_created=False,)# Check Confirm password is not validated until the form is submitted# Once it is submitted, it should valitate the Confirm password after each keystrokeb.set_input_text('#accounts-create-password-pw1', "foobar")b.set_input_text('#accounts-create-password-pw2', "bar")b.wait_not_present("#accounts-create-password-pw2-helper")b.click('#accounts-create-dialog button.apply')b.wait_in_text("#accounts-create-password-pw2-helper", "The passwords do not match")b.set_input_text('#accounts-create-password-pw2', "barfoo")b.wait_in_text("#accounts-create-password-pw2-helper", "The passwords do not match")b.set_input_text('#accounts-create-password-pw2', "foobarfoo")b.wait_in_text("#accounts-create-password-pw2-helper", "The passwords do not match")b.set_input_text('#accounts-create-password-pw2', "foobar")b.wait_not_present("#accounts-create-password-pw2-helper")b.click('#accounts-create-dialog button.cancel')b.wait_not_present('#account-set-password-dialog')createUser(browser=b,machine=m,user_name="jussi",real_name="Jussi Junior",password="foo",locked=True,force_password_change=False,verify_created=False,)# Password is weak, lets change it to another weak - this should still not acceptb.wait_in_text("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning", "Password quality check failed:")b.wait_visible('#accounts-create-dialog button.pf-m-warning')b.set_input_text('#accounts-create-password-pw1', "bar")b.set_input_text('#accounts-create-password-pw2', "bar")b.wait_not_present("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning")b.wait_not_present('#accounts-create-dialog button.pf-m-warning')b.click('#accounts-create-dialog button.apply')# Password is weak, confirm button should be disabled after first clickb.wait_in_text("#accounts-create-dialog .pf-v6-c-form__helper-text .pf-m-warning", "Password quality check failed:")b.wait_visible("button.apply:disabled")# Lets confirm the weak password nowb.click('#accounts-create-dialog button.pf-m-warning')b.wait_not_present('#accounts-create-dialog')b.wait_in_text('#accounts-list', "Jussi Junior")def is_locked() -> bool:return m.execute("passwd -S jussi | cut -d' ' -f2").strip() in ["L", "LK"]def is_admin() -> bool:return "jussi" in m.execute(f"getent group {m.get_admin_group()}")admin_role_sel = '#account-groups-form-group'b.wait(lambda: "jussi" in m.execute("grep jussi /etc/passwd"))b.wait(lambda: not is_admin())b.wait(is_locked)# Check that by default we set up `/bin/bash`shell = m.execute("getent passwd jussi | cut -f7 -d:").strip()self.assertEqual(shell, '/bin/bash')# Unlock it and make it an adminb.go("#/jussi")b.wait_text("#account-user-name", "jussi")b.wait_visible("#account-locked:checked")b.set_checked('#account-locked', val=False)b.wait(lambda: not is_locked())b.wait_not_present(admin_role_sel + f" .pf-v6-c-label:contains(':{m.get_admin_group()}')")b.click("#account-groups > button")b.click(f"body .pf-v6-c-menu li:contains('{m.get_admin_group()}') > button")b.wait(is_admin)b.wait_not_present("#account-groups-helper")# Login as jussi and change role admin for itselfb.logout()b.login_and_go("/users", user="jussi", password="bar")# There is only one badge and it is for jussib.wait_text('#current-account-badge', 'Your account')b.wait_js_cond('document.querySelector("#current-account-badge").previousSibling.getAttribute("href") === "#/jussi"')# The current account is the first in the listb.wait_visible("#accounts-list > tbody:first-of-type #current-account-badge")# Use [x] button on the group label to remove the account from groupb.go("#/jussi")b.wait_text("#account-user-name", "jussi")b.wait_in_text(f"{admin_role_sel} #account-groups .pf-v6-c-label-group", m.get_admin_group())b.wait_not_present("#account-groups-helper")b.click(f"#account-groups .pf-v6-c-label__actions button[aria-label='Close {m.get_admin_group()}']")b.wait(lambda: not is_admin())b.wait_not_in_text(f"{admin_role_sel} #account-groups .pf-v6-c-label-group", m.get_admin_group())if not m.ostree_image: # User is not shown as logged in when logged in through Cockpitb.wait_visible("#account-groups-helper")m.execute(f"/usr/bin/gpasswd -a jussi {m.get_admin_group()}")with b.wait_timeout(20):b.wait_in_text(f"{admin_role_sel} #account-groups .pf-v6-c-label-group", m.get_admin_group())# Cannot lock the current accountb.wait_visible("#account-locked[disabled]")b.go("#/admin")b.wait_text("#account-user-name", "admin")b.wait_in_text(f"{admin_role_sel} #account-groups .pf-v6-c-label-group", m.get_admin_group())b.wait_not_present("#account-groups-helper")b.logout()b.login_and_go("/users")# Change the password of this accountb.go("#/jussi")b.wait_text("#account-user-name", "jussi")b.click('#account-set-password')b.wait_visible('#account-set-password-dialog')# weak passwordb.set_input_text("#account-set-password-pw1", 'a')b.set_input_text("#account-set-password-pw2", 'a')b.wait_visible("#account-set-password-meter.danger")b.click('#account-set-password-dialog button.apply')b.wait_in_text("#account-set-password-dialog .pf-v6-c-form__helper-text .pf-m-warning", "Password quality check failed:")b.wait_visible('#account-set-password-dialog button.pf-m-warning')# password mismatchb.set_input_text("#account-set-password-pw1", good_password + 'a')b.set_input_text("#account-set-password-pw2", good_password + 'b')b.click('#account-set-password-dialog button.apply')b.wait_in_text("#account-set-password-dialog .pf-v6-c-form__helper-text .pf-m-error", "The passwords do not match")b.wait_not_present('#account-set-password-dialog button.pf-m-warning')# too long passwordlong_password = "2a02-x!h4a" * 30b.set_input_text('#account-set-password-pw1', long_password)b.set_input_text('#account-set-password-pw2', long_password)b.wait_not_present("#account-set-password-dialog .pf-v6-c-form__helper-text .pf-m-warning")b.click('#account-set-password-dialog button.apply')b.wait_in_text("#account-set-password-dialog .pf-v6-c-form__helper-text .pf-m-warning", "Password is longer than 256 characters")b.wait_not_present('#account-set-password-dialog button.pf-m-warning')good_password_2 = "cEwghLYยงX9R&m8RLwk4Xfed9Bw="# Now set to something validb.set_input_text("#account-set-password-pw1", good_password_2)b.set_input_text("#account-set-password-pw2", good_password_2)b.wait_visible("#account-set-password-meter.success")b.wait_not_present("#account-set-password-dialog .pf-v6-c-form__helper-text .pf-m-warning")b.click('#account-set-password-dialog button.apply')b.wait_not_present('#account-set-password-dialog')# incomplete passwd entry; fixed in PR #13384m.execute('echo "damaged:x:1234:1234:Damaged" >> /etc/passwd')# Logout and login with the new passwordb.relogin(path="/users", user="jussi", password=good_password_2)b.go("/users")b.enter_page("/users")b.wait_in_text('#accounts-list', "damaged")b.click('#accounts-list td[data-label="Username"] a[href="#/damaged"]')b.wait_in_text("#account-title", "Damaged")if not m.ws_container: # User is not shown as logged in when logged in through Cockpitb.go("#/admin")b.wait_visible("#account-logout[disabled]")(year, month) = m.execute("date +'%Y %b'").strip().split()# Log in as "admin" and the open details in other browser should updateb2 = self.new_browser(m)b2.login_and_go("/system")b.wait_text("#account-last-login", "Logged in")b.wait_visible("#account-logout:not(:disabled)")# Now log out and it should update againb2.logout()b.wait_in_text("#account-last-login", year)b.wait_in_text("#account-last-login", month)b.wait_visible("#account-logout[disabled]")# Terminate sessionb2.login_and_go("/system")b.wait_text("#account-last-login", "Logged in")b.click("#account-details button:contains('Terminate session')")b.wait_in_text("#account-last-login", year)b.wait_in_text("#account-last-login", month)b.wait_visible("#account-logout[disabled]")# Create an account and force password change on first loginb.go('/users')createUser(browser=b,machine=m,user_name="robert",real_name="Robert Robertson",password=good_password,locked=False,force_password_change=True,)# Login as robert and check if password change is requiredb.logout()# login in second window to check if last login is updated in accounts listif not m.ws_container: # User is not shown as logged in when logged in through Cockpitb2.login_and_go("/users")b2.wait_in_text("#accounts-list tbody tr[data-row-id='robert'] td[data-label='Last active']", "Never logged in")# With the ws container this happens over ssh and we need# ChallengeResponseAuthentication (or# KbdInteractiveAuthentication, it's new name) to be allowed.# Without that, OpenSSH will do the required password change# without PAM and by running "/bin/passwd" instead.#if m.ws_container:self.restore_dir("/etc/ssh", restart_unit=self.sshd_service)m.execute("sed -i 's/.*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication yes/' ""/etc/ssh/sshd_config $(ls /etc/ssh/sshd_config.d/* 2>/dev/null || true)")m.execute("sed -i 's/.*KbdInteractiveAuthentication.*/KbdInteractiveAuthentication yes/' ""/etc/ssh/sshd_config $(ls /etc/ssh/sshd_config.d/* 2>/dev/null || true)")m.execute(self.restart_sshd)b.wait_visible("#login")b.wait_not_visible("#conversation-group")b.try_login(user="robert", password=good_password)b.wait_visible('#conversation-input')b.set_val('#conversation-input', good_password)b.click('#login-button')# Set new passwordb.wait_in_text('#conversation-prompt', "New password:")b.set_val('#conversation-input', good_password_2)b.click('#login-button')# Confirm new passwordb.wait_in_text('#conversation-prompt', "Retype new password:")b.set_val('#conversation-input', good_password_2)b.click('#login-button')b.wait_visible('#content')def testCustomUID(self) -> None:b = self.browserm = self.machineself.login_and_go("/users")# Test custom UIDcreateUser(browser=b,machine=m,user_name="bob",real_name="Bob Bobson",password=good_password,locked=False,force_password_change=False,uid="1500",verify_created=True,)b.wait_text("#accounts-list tr[data-row-id='bob'] td[data-label='ID']", "1500")# Test dialog correctly predicts the next available UIDcreateUser(browser=b,machine=m,user_name="john",real_name="John Johnson",password=good_password,locked=False,force_password_change=False,expected_uid="1501",verify_created=True,)b.wait_text("#accounts-list tr[data-row-id='john'] td[data-label='ID']", "1501")# Test creation of users with the same UIDcreateUser(browser=b,machine=m,user_name="jack",real_name="Jack Jackson",password=good_password,locked=False,force_password_change=False,uid="1501",verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-uid-helper", "already used")b.wait_visible("button.apply:disabled")b.click("button:contains('Create account with non-unique UID')")b.wait_not_present("#accounts-create-dialog")b.wait_in_text("#accounts-list", "Jack Jackson")b.wait_text("#accounts-list tr[data-row-id='jack'] td[data-label='ID']", "1501")b.wait_text("#accounts-list tr[data-row-id='john'] td[data-label='ID']", "1501")# No UID specified -> useradd chooses UID for uscreateUser(browser=b,machine=m,user_name="nouidspecified",real_name="NoUID Specified",password=good_password,locked=False,force_password_change=False,uid="",verify_created=True,)# UID cannot be lower than UID_MINcreateUser(browser=b,machine=m,user_name="failedfailson",real_name="Failed Failson",password=good_password,locked=False,force_password_change=False,uid="1",verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-uid-helper", "lower than")b.click("#accounts-create-dialog button.cancel")b.wait_not_present("#accounts-create-dialog")# UID cannot be higher than UID_MAXcreateUser(browser=b,machine=m,user_name="failedfailson",real_name="Failed Failson",password=good_password,locked=False,force_password_change=False,uid="9999999",verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-uid-helper", "higher than")b.click("#accounts-create-dialog button.cancel")b.wait_not_present("#accounts-create-dialog")# UID must be a positive integercreateUser(browser=b,machine=m,user_name="failedfailson",real_name="Failed Failson",password=good_password,locked=False,force_password_change=False,uid="abc",verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-uid-helper", "positive integer")b.click("#accounts-create-dialog button.cancel")b.wait_not_present("#accounts-create-dialog")def testCustomUserProperties(self) -> None:b = self.browserm = self.machineself.login_and_go("/users")# Test custom home directorycustom_dir_path = "/home/mycustomdir"createUser(browser=b,machine=m,user_name="jussi",real_name="Jussi Junior",password=good_password,locked=False,custom_home_dir=custom_dir_path,force_password_change=False,verify_created=True,)b.go("#/jussi")b.wait_text("#account-home-dir", custom_dir_path)m.execute(f"test -d {custom_dir_path}")m.execute("! test -d /home/jussi")b.go("/users")# Check assigning a file as home directory failsm.execute("touch /home/isfile")createUser(browser=b,machine=m,user_name="file",real_name="File Filerson",password=good_password,locked=False,custom_home_dir="/home/isfile",force_password_change=False,verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-home-dir-helper", "existing file")b.wait_visible("button.apply:disabled")b.click("button.cancel")b.wait_not_present("#accounts-create-dialog")b.go("/users")# Check assigning existing home directory to a new userm.execute("mkdir /home/existingdir")createUser(browser=b,machine=m,user_name="stealer",real_name="Stealer OfHomeDirectison",password=good_password,locked=False,custom_home_dir="/home/existingdir",force_password_change=False,verify_created=False,)b.wait_visible("#accounts-create-dialog")b.wait_in_text("#accounts-create-user-home-dir-helper", "already exists")b.wait_visible("button.apply:disabled")self.assertEqual(m.execute("stat -c '%U %G' /home/existingdir").rstrip(), "root root")b.click("button:contains('Create and change ownership of home directory')")b.wait_not_present("#accounts-create-dialog")b.wait_in_text("#accounts-list", "Stealer OfHomeDirectison")# Verify that ownership of home directory was changed to the new userself.assertEqual(m.execute("stat -c '%U %G' /home/existingdir").rstrip(), "stealer stealer")default_shell = getUserAddDetails(m)["SHELL"]custom_shell = "/bin/sh"if default_shell == custom_shell:custom_shell = "/bin/bash"createUser(browser=b,machine=m,user_name="robert",real_name="Robert Robertson",password=good_password,locked=False,custom_shell=custom_shell,force_password_change=False,verify_created=True,)b.go("#/robert")b.wait_text("#account-shell", custom_shell)def testUnprivileged(self) -> None:m = self.machineb = self.browsernew_password = "tqymuVh.Zf5"new_password_2 = "cEwghLYX"m.execute("useradd antoine; echo antoine:foobar | chpasswd")self.login_and_go("/users", user="antoine", superuser=False)b.go("#/antoine")b.wait_text("#account-user-name", "antoine")b.wait_visible('#account-set-password:enabled')b.click('#account-set-password')b.wait_visible('#account-set-password-dialog')b.set_input_text("#account-set-password-old", "foobar")b.set_input_text("#account-set-password-pw1", new_password)b.set_input_text("#account-set-password-pw2", new_password)b.click('#account-set-password-dialog button.apply')b.wait_not_present('#account-set-password-dialog')# Logout and login with the new passwordb.logout()b.open("/users")b.wait_visible("#login")b.set_val("#login-user-input", "antoine")b.set_val("#login-password-input", new_password)b.click('#login-button')b.wait_visible('#content')# --mindays is being deprecated in "chage" utility, see# https://github.com/shadow-maint/shadow/pull/1482 for deprecation of --mindays and# https://github.com/shadow-maint/shadow/pull/1432 for full plan what is being deprecated## This will eventually show up in other distributions tooif m.image in SHADOW_UTILS_WITHOUT_EXPIRE:return# Set minimum age to disallow changing it immediately againm.execute("chage --mindays 7 antoine")b.enter_page("/users")b.go("#/antoine")b.wait_text("#account-user-name", "antoine")b.wait_visible('#account-set-password:enabled')b.click('#account-set-password')b.wait_visible('#account-set-password-dialog')b.set_input_text("#account-set-password-old", new_password)b.set_input_text("#account-set-password-pw1", new_password_2)b.set_input_text("#account-set-password-pw2", new_password_2)b.click('#account-set-password-dialog button.apply')b.wait_in_text("#account-set-password-dialog .pf-v6-c-modal-box__body", "must wait longer")@testlib.skipWsContainer("ssh root login not allowed")def testRootLogin(self) -> None:m = self.machineb = self.browsernew_password = "tqymuVh.Zf5"# this test uses quick logouts; async preloads cause "ReferenceError: cockpit is not defined"self.disable_preload("packagekit", "playground", "systemd")m.execute("useradd anton; echo anton:foobar | chpasswd")self.enable_root_login()self.login_and_go("/users", user="root", superuser=False)# test this on root and a normal user accountfor user in ["anton", "root"]:b.go("#/" + user)b.wait_text("#account-user-name", user)b.wait_visible('#account-set-password:enabled')b.click('#account-set-password')b.wait_visible('#account-set-password-dialog')b.wait_visible("#account-set-password-pw1")# root does not need to know old passwordb.wait_not_present("#account-set-password-old")b.set_input_text("#account-set-password-pw1", new_password)b.set_input_text("#account-set-password-pw2", new_password)b.click('#account-set-password-dialog button.apply')b.wait_not_present('#account-set-password-dialog')b.logout()# Logout and login with the new passwordfor user in ["anton", "root"]:b.login_and_go("/users", user=user, password=new_password)b.logout()def accountExpiryInfo(self, account: str, field: str) -> str | None:for line in self.machine.execute(f"LC_ALL=C chage -l {account}").split("\n"):if line.startswith(field):_, _, value = line.partition(":")return value.strip()return Nonedef testExpire(self) -> None:m = self.machineb = self.browserm.execute("useradd scruffy -s /bin/bash -c Scruffy")m.execute("echo scruffy:foobar | chpasswd")self.login_and_go("/users#/scruffy")b.wait_text("#account-user-name", "scruffy")# Try to expire the accountb.wait_text("#account-expiration-text", "Never expire account")self.assertEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")b.click("#account-expiration-button")b.wait_visible("#account-expiration")b.click("#account-expiration-expires")# Try an invalid dateb.set_input_text("#account-expiration-input input", "blah")b.click("#account-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_text("#account-expiration .pf-v6-c-form__helper-text .pf-m-error", "Invalid expiration date")# Now a valid date 30 days in the futurewhen = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30)b.set_input_text("#account-expiration-input input", when.isoformat().split("T")[0])b.click("#account-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_not_present("#account-expiration")b.wait_in_text("#account-expiration-text", "Expire account on")self.assertNotEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")# Now try and change it backb.click("#account-expiration-button")b.wait_visible("#account-expiration")b.click("#account-expiration-never")b.click("#account-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_not_present("#account-expiration")b.wait_text("#account-expiration-text", "Never expire account")self.assertEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")# Try to expire a passwordb.wait_text("#password-expiration-text", "Never expire password")# The default "never expire" value depends on PASS_MAX_DAYS in login.defs which varies per distro and is unset in Arch Linux.EXPIRE_NEVER_VALUE = m.execute("awk '/^PASS_MAX_DAYS/ {print $2}' /etc/login.defs").strip() or "-1"self.assertEqual(self.accountExpiryInfo("scruffy", "Maximum number of days between password change"), EXPIRE_NEVER_VALUE)b.click("#password-expiration-button")b.wait_visible("#password-expiration")b.click("#password-expiration-expires")# Try an invalid numberb.set_input_text("#password-expiration-input", "-3")b.click("#password-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_text("#password-expiration .pf-v6-c-form__helper-text .pf-m-error", "Invalid number of days")# Expire password every 30 daysb.set_input_text("#password-expiration-input", "30")b.click("#password-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_not_present("#password-expiration")b.wait_in_text("#password-expiration-text", "Require password change on")self.assertNotEqual(self.accountExpiryInfo("scruffy", "Password expires"), "never")# Now try and change it backb.click("#password-expiration-button")b.wait_visible("#password-expiration")b.click("#password-expiration-never")b.click("#password-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_not_present("#password-expiration")b.wait_text("#password-expiration-text", "Never expire password")self.assertEqual(self.accountExpiryInfo("scruffy", "Password expires"), "never")self.assertEqual(self.accountExpiryInfo("scruffy", "Maximum number of days between password change"), "-1")# Now change it to expire againb.click("#password-expiration-button")b.wait_visible("#password-expiration")b.click("#password-expiration-expires")b.set_input_text("#password-expiration-input", "30")b.click("#password-expiration .pf-v6-c-modal-box__footer button:contains('Change')")b.wait_not_present("#password-expiration")b.logout()# HACK: https://github.com/cockpit-project/cockpit/issues/20262self.login_and_go("/users#/scruffy", user="scruffy", superuser=False)b.wait_text("#account-user-name", "scruffy")b.wait_text("#account-expiration-text", "Never expire account")b.wait_visible("#account-expiration-button[disabled]")b.wait_in_text("#password-expiration-text", "Require password change on")b.wait_visible("#password-expiration-button[disabled]")# Lastly force a password changeb.logout()self.login_and_go("/users#/scruffy")b.wait_text("#account-user-name", "scruffy")b.click("#password-reset-button")b.wait_visible("#password-reset")b.click("#password-reset .pf-v6-c-modal-box__footer button:contains('Reset')")b.wait_not_present("password-reset")b.wait_in_text("#password-expiration-text", "Password must be changed")self.assertEqual(self.accountExpiryInfo("scruffy", "Password expires"), "password must be changed")@testlib.skipWsContainer("User is not shown as logged in with cockpit/ws")def testAccountLogs(self) -> None:b = self.browserm = self.machine# Clean out the relevant logfilesm.execute("truncate -s0 /var/log/{[bw]tmp{,.db},lastlog} /run/utmp")m.execute("rm -f /var/lib/lastlog/lastlog2.db /var/lib/wtmpdb/wtmp.db")# First login: no entries yetself.login_and_go("/users#/admin")# just the header, nothing elseb.wait_text("#account-logs", "Login history")self.assertFalse(b.is_present("#account-logs tr"))b.logout()year = m.execute("date +%Y").strip()# second login: one entry from the first oneself.login_and_go("/users#/admin")# Header + one line of loginsb.wait_count("#account-logs tr", 2)started = b.text("#account-logs [data-label='Started']")ended = b.text("#account-logs [data-label='Ended']")self.assertIn(year, started)self.assertIn(year, ended)self.assertGreaterEqual(ended, started)# this is the correct IP for our CI, and we don't run this on tmtb.wait_text("#account-logs [data-label='From']", "::ffff:172.27.0.2")def testGroups(self) -> None:b = self.browserm = self.machinedef selectGroupFromMenu(group: str, enabled: bool) -> None:if enabled:testlib.wait(lambda: "testgroup0" not in m.execute("groups admin"))else:testlib.wait(lambda: "testgroup0" in m.execute("groups admin"))b.wait_not_present(".pf-v6-c-menu")b.click("#account-groups > button")b.click(f".pf-v6-c-menu li:contains('{group}') button")if enabled:b.wait_in_text(".pf-v6-c-label-group__list", group)b.wait_not_present(f".pf-v6-c-select__menu li:contains('{group}') button")testlib.wait(lambda: "testgroup0" in m.execute("groups admin"))else:b.wait_not_in_text(".pf-v6-c-label-group__list", group)b.wait_not_present(f".pf-v6-c-select__menu li:contains('{group}') button")testlib.wait(lambda: "testgroup0" not in m.execute("groups admin"))m.execute("groupadd testgroup0")m.execute("useradd anton")self.login_and_go("/users")# Groups filter is only visible in the expanded viewb.wait_not_present("#groups-filter")b.click("#groups-view-toggle")b.wait_visible('#groups-list td[data-label="Group name"]:contains("testgroup0")')# Check group filter in expanded mode and the filter clear buttonb.set_input_text("#groups-filter input", "casablanca")b.wait_not_present('#groups-list td[data-label="Group name"]:contains("testgroup0")')b.click("#groups-filter button")b.wait_val("#groups-filter input", "")b.wait_visible('#groups-list td[data-label="Group name"]:contains("testgroup0")')# FIXME: rtl test was flaky, debug it and remove the skipb.assert_pixels("#groups-list tr:contains('testgroup0')", "group-row", skip_layouts=["rtl"])# Delete itperformGroupAction(b, 'testgroup0', 'Delete group')b.wait_text("#group-confirm-delete-dialog footer .pf-v6-c-button.apply", "Delete")b.click("#group-confirm-delete-dialog footer .pf-v6-c-button.apply")b.wait_not_present('#group-confirm-delete-dialog')b.wait_not_in_text('#groups-list', "testgroup0")# Add testgroup0 backm.execute("groupadd testgroup0")# Groups used as primary need force deletionperformGroupAction(b, 'anton', 'Delete group')b.wait_text("#group-confirm-delete-dialog footer .pf-v6-c-button.apply", "Force delete")b.assert_pixels("#group-confirm-delete-dialog", "group-delete-dialog")b.click("#group-confirm-delete-dialog footer .pf-v6-c-button.apply")b.wait_not_present('#account-confirm-delete-dialog')b.wait(lambda: "#/" == b.eval_js('window.location.hash'))b.wait_not_in_text('#groups-list', "anton")b.click('#accounts-list td[data-label="Username"] a[href="#/admin"]')# Existing groups appear in labelsb.wait_in_text(".pf-v6-c-label-group__list", "admin")b.wait_in_text(".pf-v6-c-label-group__list", m.get_admin_group())# Primary group cannot be remove but others have a remove buttonb.wait_in_text("#account-groups .pf-v6-c-label-group", "admin")b.wait_not_present("#account-groups button[aria-label='Close user']")close_button = f"#account-groups button[aria-label='Close {m.get_admin_group()}']"b.wait_visible(close_button)# Clicking on the close button removes the groupb.click(close_button)b.wait_not_in_text("#account-groups .pf-v6-c-label-group", m.get_admin_group())b.wait_not_present(".pf-v6-c-select__menu")# Add admin to the testgroup0 groupselectGroupFromMenu("testgroup0", enabled=True)# Check that changes ar persistent after reloadb.reload()b.enter_page("/users")b.wait_in_text(".pf-v6-c-label-group__list", "testgroup0")# Clicking on a used groups in the menu will remove itselectGroupFromMenu("testgroup0", enabled=False)# Clicking on the undo button will add the removed group backb.click("#group-undo-btn")b.wait_in_text(".pf-v6-c-label-group__list", "testgroup0")m.execute("/usr/bin/gpasswd -d admin testgroup0")# Clicking on the undo button will remove the added group backb.reload()b.enter_page("/users")selectGroupFromMenu("testgroup0", enabled=True)b.click("#group-undo-btn")b.wait_not_in_text(".pf-v6-c-label-group__list", "testgroup0")testlib.wait(lambda: "testgroup0" not in m.execute("groups admin"))def testGroupCreate(self) -> None:b = self.browserself.login_and_go("/users")# Create new groupb.click("#groups-create")b.set_input_text("#groups-create-name", "titan")b.wait_not_val("#groups-create-id", "")b.click("#groups-create-dialog button.pf-m-primary")b.wait_not_present("#groups-create-dialog")b.wait_visible('#groups-list tr[data-row-id="titan"]')# Validation check for duplicate group name and idb.click("#groups-create")b.set_input_text("#groups-create-name", "titan")b.set_input_text("#groups-create-id", "0")b.click("#groups-create-dialog button.pf-m-primary")b.wait_in_text("#groups-create-name-helper", "A group with this name already exists")b.set_input_text("#groups-create-name", "titans")b.click("#groups-create-dialog button.pf-m-primary")b.wait_in_text("#groups-create-dialog .pf-v6-c-alert", "GID '0' already exists")# Validation check for chars used in group name and valid group IDb.set_input_text("#groups-create-name", "titan@1000")b.set_input_text("#groups-create-id", "12f")b.click("#groups-create-dialog button.pf-m-primary")b.wait_in_text("#groups-create-name-helper", "Group name can only consist of letters")b.wait_in_text("#groups-create-id-helper", "Group ID must be positive integer")b.set_input_text("#groups-create-id", "-12")b.wait_not_present("#groups-create-id-helper")b.click("#groups-create-dialog button.pf-m-primary")b.wait_in_text("#groups-create-id-helper", "Group ID must be positive integer")# Validate no name and no groupb.set_input_text("#groups-create-name", "")b.set_input_text("#groups-create-id", "")b.click("#groups-create-dialog button.pf-m-primary")b.wait_in_text("#groups-create-name-helper", "No group name specified")b.wait_in_text("#groups-create-id-helper", "No ID specified")# Create new group with custom IDb.set_input_text("#groups-create-name", "saturn")b.set_input_text("#groups-create-id", "1234")b.click("#groups-create-dialog button.pf-m-primary")b.wait_not_present("#groups-create-dialog")b.wait_visible('#groups-list td[data-label="Group name"]:contains("saturn")')def testGroupRename(self) -> None:b = self.browserm = self.machinem.execute("groupadd titan; useradd uranus")self.login_and_go("/users")b.click("button:contains('more...')")b.wait_visible('#groups-list tr[data-row-id="titan"]')performGroupAction(b, "titan", "Rename group")b.set_input_text("#group-confirm-rename-dialog #group-name", "foo bar")b.click("#group-confirm-rename-dialog .apply")b.wait_in_text("#group-name-helper", "Group name can only consist of letters")b.set_input_text("#group-confirm-rename-dialog #group-name", "")b.click("#group-confirm-rename-dialog .apply")b.wait_in_text("#group-name-helper", "Group name cannot be empty")b.set_input_text("#group-confirm-rename-dialog #group-name", "phoebe")b.wait_not_present("#group-name-helper")b.click("#group-confirm-rename-dialog .apply")b.wait_not_present("#group-confirm-rename-dialog")self.assertIn("phoebe", m.execute("getent group"))self.assertNotIn("titan", m.execute("getent group"))# Rename and delete actions are available only for user created groupsb.wait_not_present(f"#groups-list tbody tr[data-row-id='{m.get_admin_group()}'] button.pf-v6-c-menu-toggle")def testChangeShell(self) -> None:b = self.browserm = self.machineself.login_and_go("/users")# Add a user externallym.execute("useradd anton")m.execute("echo anton:foobar | chpasswd")with b.wait_timeout(30):b.wait_in_text('#accounts-list', "anton")b.go("#/anton")b.wait_text("#account-shell", getUserAddDetails(m)["SHELL"])b.click("#change-shell-button")b.wait_visible("#shell-dialog")new_shell = "/bin/sh"b.select_from_dropdown("#edit-user-shell", new_shell)b.click('#shell-dialog button.apply')b.wait_text("#account-shell", new_shell)if __name__ == '__main__':testlib.test_main()