#!/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-later


import datetime
import os

import testlib
from machine import testvm

good_password = "tqymuVh.ZfZnP§9Wr=LM3JyG5yx"


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] = value

    return useradd_defaults


def 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_name
        browser.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.browser
        m = self.machine

        # Clean out the relevant logfiles
        m.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 externally
        m.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 skip
        b.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 admin
        b.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 list
        b.wait_visible("#accounts-list > tbody :first-child #current-account-badge")

        # Set a real name
        b.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 up
        b.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 fields
        b.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 fields
        b.wait_text("#account-title", "Anton Arbitrary")
        # On the overview page it also shows only real name
        b.go("/users")
        b.wait_text('#accounts-list td[data-label="Full name"]:contains("Anton")', "Anton Arbitrary")
        b.go("/users/#anton")

        # Delete it
        b.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 colon
        b.click('#accounts-create')
        b.wait_visible('#accounts-create-dialog')
        b.set_input_text('#accounts-create-real-name', "Col:n Colon")  # This should fail
        b.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 user
        b.go("#/root")
        b.wait_text("#account-user-name", "root")
        # some operations are not allowed for root user
        b.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 root
        b.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 images
        self.assertIn(m.execute("passwd -S root").split()[1], ["P", "PS"])
        # now lock account
        b.set_checked("#account-locked", val=True)
        b.wait(lambda: m.execute("passwd -S root").split()[1] in ["L", "LK"])

        # Check admin user
        b.go("#/admin")
        b.wait_text("#account-user-name", "admin")
        # delete/logout are disabled for the current user
        b.wait_visible("#account-delete[disabled]")
        b.wait_visible("#account-logout[disabled]")

        # go back to accounts overview, check pf-v6-c-breadcrumb
        b.click("#account .pf-v6-c-breadcrumb a")
        b.wait_visible("#accounts-create")

        # Create a user from the UI
        self.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 functionality
        b.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 functionality
        b.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 confirmation
        b.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 password
        long_password = "2a02-x!h4a" * 30
        b.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 message
        b.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 confirmation
        b.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 directory
        home_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/useradd
        shell = m.execute("getent passwd Berta | cut -f7 -d:").strip()
        self.assertEqual(shell, '/bin/true')

        # Delete it externally
        m.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 password
        performUserAction(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 disabled
        b.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 page
        performUserAction(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 user
        createUser(
            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 ssh
        if 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 exist
        self.allow_journal_messages(".*libuser initialization error: .*/etc/default/useradd.*: No such file or directory")

    def testFilterAndSort(self) -> None:
        b = self.browser
        m = self.machine

        self.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 groups
        b.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 image
            m.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 filters
        b.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 filters
        b.click("#accounts-filter button[aria-label='Reset']")
        b.wait_not_present("#accounts div.pf-v6-c-empty-state")

        # clear via empty-state action
        b.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 row
            b.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 group
        if not m.ostree_image:  # Users group does not exist in coreos image
            b.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 Username
        check_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 name
        b.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 ID
        b.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.browser
        m = self.machine

        # Clean out the relevant logfiles
        m.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 password
        self.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 keystroke
        b.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 keystroke
        b.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 accept
        b.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 click
        b.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 now
        b.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 admin
        b.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 itself
        b.logout()
        b.login_and_go("/users", user="jussi", password="bar")

        # There is only one badge and it is for jussi
        b.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 list
        b.wait_visible("#accounts-list > tbody:first-of-type #current-account-badge")

        # Use [x] button on the group label to remove the account from group
        b.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 Cockpit
            b.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 account
        b.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 account
        b.go("#/jussi")
        b.wait_text("#account-user-name", "jussi")
        b.click('#account-set-password')
        b.wait_visible('#account-set-password-dialog')

        # weak password
        b.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 mismatch
        b.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 password
        long_password = "2a02-x!h4a" * 30
        b.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 valid
        b.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 #13384
        m.execute('echo "damaged:x:1234:1234:Damaged" >> /etc/passwd')

        # Logout and login with the new password
        b.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 Cockpit
            b.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 update
            b2 = 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 again
            b2.logout()
            b.wait_in_text("#account-last-login", year)
            b.wait_in_text("#account-last-login", month)
            b.wait_visible("#account-logout[disabled]")

            # Terminate session
            b2.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 login
        b.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 required
        b.logout()

        # login in second window to check if last login is updated in accounts list
        if not m.ws_container:  # User is not shown as logged in when logged in through Cockpit
            b2.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 password
        b.wait_in_text('#conversation-prompt', "New password:")
        b.set_val('#conversation-input', good_password_2)
        b.click('#login-button')

        # Confirm new password
        b.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.browser
        m = self.machine

        self.login_and_go("/users")

        # Test custom UID
        createUser(
            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 UID
        createUser(
            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 UID
        createUser(
            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 us
        createUser(
            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_MIN
        createUser(
            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_MAX
        createUser(
            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 integer
        createUser(
            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.browser
        m = self.machine

        self.login_and_go("/users")

        # Test custom home directory
        custom_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 fails
        m.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 user
        m.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 user
        self.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.machine
        b = self.browser
        new_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 password
        b.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')

        # Set minimum age to disallow changing it immediately again
        m.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.machine
        b = self.browser
        new_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 account
        for 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 password
            b.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 password
        for 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 None

    def testExpire(self) -> None:
        m = self.machine
        b = self.browser

        m.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 account
        b.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 date
        b.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 future
        when = 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 back
        b.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 password
        b.wait_text("#password-expiration-text", "Never expire password")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Maximum number of days between password change"), "99999")
        b.click("#password-expiration-button")
        b.wait_visible("#password-expiration")
        b.click("#password-expiration-expires")

        # Try an invalid number
        b.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 days
        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.wait_in_text("#password-expiration-text", "Require password change on")
        self.assertNotEqual(self.accountExpiryInfo("scruffy", "Password expires"), "never")

        # Now try and change it back
        b.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 again
        b.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/20262
        self.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 change
        b.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.browser
        m = self.machine

        # Clean out the relevant logfiles
        m.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 yet
        self.login_and_go("/users#/admin")
        # just the header, nothing else
        b.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 one
        self.login_and_go("/users#/admin")
        # Header + one line of logins
        b.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 tmt
        b.wait_text("#account-logs [data-label='From']", "::ffff:172.27.0.2")

    def testGroups(self) -> None:
        b = self.browser
        m = self.machine

        def 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 view
        b.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 button
        b.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 skip
        b.assert_pixels("#groups-list tr:contains('testgroup0')", "group-row", skip_layouts=["rtl"])

        # Delete it
        performGroupAction(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 back
        m.execute("groupadd testgroup0")

        # Groups used as primary need force deletion
        performGroupAction(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 labels
        b.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 button
        b.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 group
        b.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 group
        selectGroupFromMenu("testgroup0", enabled=True)

        # Check that changes ar persistent after reload
        b.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 it
        selectGroupFromMenu("testgroup0", enabled=False)

        # Clicking on the undo button will add the removed group back
        b.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 back
        b.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.browser

        self.login_and_go("/users")

        # Create new group
        b.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 id
        b.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 ID
        b.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 group
        b.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 ID
        b.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.browser
        m = self.machine

        m.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 groups
        b.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.browser
        m = self.machine

        self.login_and_go("/users")

        # Add a user externally
        m.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()
