cockpit/test/verify/check-users

1260 lines
55 KiB
Python
Executable File

#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv)
# This file is part of Cockpit.
#
# Copyright (C) 2013 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
import datetime
import os
from testlib import MachineCase, nondestructive, skipDistroPackage, skipOstree, test_main, wait
good_password = "tqymuVh.ZfZnP§9Wr=LM3JyG5yx"
useradd_defaults = {}
def getUserAddDetails(machine):
global useradd_defaults
if not useradd_defaults: # dictionary is empty
useradd_defaults_str = machine.execute("useradd -D")
lines = useradd_defaults_str.split("\n")
for line in lines:
if line:
key, value = line.split("=")
useradd_defaults[key] = value
return useradd_defaults
def performUserAction(browser, user, action):
browser.wait_visible(f"#accounts-list tbody tr:contains({user}) .pf-v5-c-dropdown button")
browser.click(f"#accounts-list tbody tr:contains({user}) .pf-v5-c-dropdown button")
browser.wait_visible(f"#accounts-list tbody tr:contains({user}) .pf-v5-c-dropdown button[aria-expanded=true]")
browser.click(f".pf-v5-c-dropdown__menu-item:contains({action})")
def createUser(
browser,
machine,
user_name,
real_name,
locked,
force_password_change,
password=None,
custom_home_dir=None,
default_shell=None,
custom_shell=None,
uid=None,
expected_uid=None,
verify_created=True,
run_assert_pixels=False,
):
if default_shell is None:
default_shell = getUserAddDetails(machine)["SHELL"] or "/bin/bash"
browser.wait_visible('#accounts-create')
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)
@nondestructive
@skipDistroPackage()
class TestAccounts(MachineCase):
def testBasic(self):
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")
# 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.assert_pixels("#users-page", "user-detail-page")
b.wait_text("#account-user-name", "anton")
b.wait_text("#account-title", "anton")
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', "Anton Arbitrary")
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")
# 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-v5-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_text("#account-shell", "/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", True)
b.wait(lambda: m.execute("passwd -S root").split()[1] in ["L", "LK"])
# go back to accounts overview, check pf-v5-c-breadcrumb
b.click("#account .pf-v5-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")
# 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-v5-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-v5-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-v5-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-v5-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")
# lock option is now disabled
b.click("#accounts-list tbody tr:contains(robert) .pf-v5-c-dropdown button")
b.wait_in_text(".pf-v5-c-dropdown__menu li:contains('Lock account') .pf-m-disabled", 'Lock account')
b.click("#accounts-list tbody tr:contains(robert) .pf-v5-c-dropdown button")
# 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")
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):
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:contains(robert) td[data-label='Group'] .pf-v5-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:contains(robert) td[data-label='Group'] .pf-v5-c-label.pf-m-cyan:contains(users)", "users")
m.execute(f"/usr/bin/gpasswd -a robert {m.get_admin_group()}")
b.wait_in_text("#accounts-list tbody tr:contains(robert) td[data-label='Group'] .pf-v5-c-label.pf-m-gold", m.get_admin_group())
m.execute(f"/usr/bin/gpasswd -d robert {m.get_admin_group()}")
b.wait_not_present("#accounts-list tbody tr:contains(robert) td[data-label='Group'] .pf-v5-c-label.pf-m-gold")
# test filters
b.set_input_text("#accounts-filter input", "root")
b.wait_in_text("#accounts-list tbody tr:first-child td[data-label='Username']", "root")
b.set_input_text("#accounts-filter input", "rOBeRt")
b.wait_in_text("#accounts-list tbody tr:first-child 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 tr:first-child td[data-label='ID']", uid)
b.set_input_text("#accounts-filter input", "spooky")
b.wait_visible("#accounts div.pf-v5-c-empty-state")
b.inject_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, invert=False):
# current account is always in the first row
b.wait_in_text("#accounts-list tbody tr:first-child 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)
# clear text filters
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):
b = self.browser
m = self.machine
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_visible("#accounts-create-password-pw2-helper")
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_visible("#accounts-create-password-pw2-helper")
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_visible("#accounts-create-password-pw2-helper")
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_visible("#accounts-create-password-pw2-helper")
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_visible("#accounts-create-password-pw2-helper")
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-v5-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-v5-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-v5-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():
return m.execute("passwd -S jussi | cut -d' ' -f2").strip() in ["L", "LK"]
def is_admin():
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', False)
b.wait(lambda: not is_locked())
b.wait_not_present(admin_role_sel + f" .pf-v5-c-label:contains(:{m.get_admin_group()})")
b.click("#account-groups")
b.click(admin_role_sel + f" 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-child #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_visible(admin_role_sel + f" .pf-v5-c-label:contains({m.get_admin_group()})")
b.wait_not_present("#account-groups-helper")
b.click(f".pf-v5-c-label-group__list .pf-v5-c-label__content:contains({m.get_admin_group()}) + span > button[aria-label='Close {m.get_admin_group()}']")
b.wait(lambda: not is_admin())
b.wait_not_present(admin_role_sel + f" .pf-v5-c-label:contains({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_visible(admin_role_sel + f" .pf-v5-c-label:contains({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_visible(admin_role_sel + f" .pf-v5-c-label:contains({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-v5-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-v5-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-v5-c-form__helper-text .pf-m-warning")
b.click('#account-set-password-dialog button.apply')
b.wait_in_text("#account-set-password-dialog .pf-v5-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-v5-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.ostree_image: # 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.ostree_image: # 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:contains(robert) td[data-label='Last active']", "Never logged in")
# On OSTree this happens over ssh
if m.ostree_image:
self.restore_dir("/etc/ssh", '( ! systemctl is-active sshd.socket || systemctl stop sshd.socket) && systemctl restart sshd.service')
m.execute("sed -i 's/.*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication yes/' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*")
m.execute("( ! systemctl is-active sshd.socket || systemctl stop sshd.socket) && systemctl restart sshd.service")
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):
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_visible("#accounts-list td[data-label='Username']:contains('bob') + td + td:contains('1500')")
# Test dialog predicts corrent 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_visible("#accounts-list td[data-label='Username']:contains(john) + td + td:contains(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_visible("#accounts-list td[data-label='Username']:contains(jack) + td + td:contains(1501)")
b.wait_visible("#accounts-list td[data-label='Username']:contains(john) + td + td:contains(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):
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):
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-v5-c-modal-box__body", "must wait longer")
@skipOstree("ssh root login not allowed")
def testRootLogin(self):
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')
# Logout and login with the new password
for user in ["anton", "root"]:
b.logout()
b.open("/users")
b.wait_visible("#login")
b.set_val("#login-user-input", user)
b.set_val("#login-password-input", new_password)
b.click('#login-button')
b.wait_visible('#content')
def accountExpiryInfo(self, account, field):
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):
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")
b.go("#/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-v5-c-modal-box__footer button:contains(Change)")
b.wait_text("#account-expiration .pf-v5-c-form__helper-text .pf-m-error", "Invalid expiration date")
# Now a valid date 30 days in the future
when = datetime.datetime.now() + datetime.timedelta(days=30)
b.set_input_text("#account-expiration-input input", when.isoformat().split("T")[0])
b.click("#account-expiration .pf-v5-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-v5-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", "Password expires"), "never")
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-v5-c-modal-box__footer button:contains(Change)")
b.wait_text("#password-expiration .pf-v5-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-v5-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-v5-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")
# 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-v5-c-modal-box__footer button:contains(Change)")
b.wait_not_present("#password-expiration")
b.logout()
self.login_and_go("/users", user="scruffy")
b.go("#/scruffy")
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")
b.go("#/scruffy")
b.wait_text("#account-user-name", "scruffy")
b.click("#password-reset-button")
b.wait_visible("#password-reset")
b.click("#password-reset .pf-v5-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")
@skipOstree("User is not shown as logged in when logged in through Cockpit")
def testAccountLogs(self):
b = self.browser
m = self.machine
# Clean out the relevant logfiles
m.execute("truncate -s0 /var/log/{[bw]tmp,lastlog} /var/run/utmp")
# Login once to create an entry
self.login_and_go("/users")
b.logout()
self.login_and_go("/users")
b.go("#/admin")
b.wait_visible("#account-logs")
# Header + one line of logins
b.wait_js_func("ph_count_check", "#account-logs tr", 2)
def testGroups(self):
b = self.browser
m = self.machine
def performGroupAction(browser, group, action):
browser.click(f"#groups-list tbody tr:contains({group}) .pf-v5-c-dropdown button")
browser.click(f"#groups-list tbody tr:contains({group}) .pf-v5-c-dropdown__menu li:contains({action})")
def selectGroupFromMenu(group, enabled):
if enabled:
wait(lambda: "testgroup0" not in m.execute("groups admin"))
else:
wait(lambda: "testgroup0" in m.execute("groups admin"))
b.wait_not_present(".pf-v5-c-select__menu")
b.click("#account-groups")
b.click(f".pf-v5-c-select__menu li:contains({group}) button")
if enabled:
b.wait_in_text(".pf-v5-c-label-group__list", group)
b.wait_not_present(f".pf-v5-c-select__menu li:contains({group}) button")
wait(lambda: "testgroup0" in m.execute("groups admin"))
else:
b.wait_not_in_text(".pf-v5-c-label-group__list", group)
b.wait_not_present(f".pf-v5-c-select__menu li:contains({group}) button")
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-v5-c-button.apply", "Delete")
b.click("#group-confirm-delete-dialog footer .pf-v5-c-button.apply")
b.wait_not_present('#group-confirm-delete-dialog')
b.wait_visible("#groups-list")
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-v5-c-button.apply", "Force delete")
b.assert_pixels("#group-confirm-delete-dialog", "group-delete-dialog")
b.click("#group-confirm-delete-dialog footer .pf-v5-c-button.apply")
b.wait_not_present('#account-confirm-delete-dialog')
b.wait(lambda: "#/" == b.eval_js('window.location.hash'))
b.wait_visible("#groups-list")
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-v5-c-label-group__list", "admin")
b.wait_in_text(".pf-v5-c-label-group__list", m.get_admin_group())
# Primary group cannot be remove but others have a remove button
b.wait_visible(".pf-v5-c-label-group__list .pf-v5-c-label__content:contains(admin)")
b.wait_not_present(".pf-v5-c-label-group__list .pf-v5-c-label__content:contains(admin) + span > button")
b.wait_visible(f".pf-v5-c-label-group__list .pf-v5-c-label__content:contains({m.get_admin_group()}) + span > button[aria-label='Close {m.get_admin_group()}']")
# Clicking on the close button removes the group
b.click(f".pf-v5-c-label-group__list .pf-v5-c-label__content:contains({m.get_admin_group()}) + span > button[aria-label='Close {m.get_admin_group()}']")
b.wait_not_present(f".pf-v5-c-label-group__list .pf-v5-c-label__content:contains({m.get_admin_group()})")
b.wait_not_present(".pf-v5-c-select__menu")
# Add admin to the testgroup0 group
selectGroupFromMenu("testgroup0", True)
# Check that changes ar persistent after reload
b.reload()
b.enter_page("/users")
b.wait_in_text(".pf-v5-c-label-group__list", "testgroup0")
# Clicking on a used groups in the menu will remove it
selectGroupFromMenu("testgroup0", False)
# Clicking on the undo button will add the removed group back
b.click("#group-undo-btn")
b.wait_in_text(".pf-v5-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", True)
b.click("#group-undo-btn")
b.wait_not_in_text(".pf-v5-c-label-group__list", "testgroup0")
wait(lambda: "testgroup0" not in m.execute("groups admin"))
def testGroupCreate(self):
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 td[data-label="Group name"]:contains("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-v5-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", "The group name can only consist of letters")
b.wait_in_text("#groups-create-id-helper", "The 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", "The 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):
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 td[data-label="Group name"]:contains("titan")')
b.click("#groups-list tbody tr:contains(titan) .pf-v5-c-dropdown button")
b.click("#groups-list tbody tr:contains(titan) .pf-v5-c-dropdown__menu li:contains('Rename group')")
b.set_input_text("#group-confirm-rename-dialog #group-name", "phoebe")
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:contains({m.get_admin_group()}) .pf-v5-c-dropdown button")
if __name__ == '__main__':
test_main()