#!/usr/bin/python3

# 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 parent
from testlib import *


@nondestructive
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")
        b.wait_in_text('#accounts-list', "anton")

        # Set a real name
        b.go("#/anton")
        b.wait_text("#account-user-name", "anton")
        b.wait_text("#account-title", "anton")
        b.wait_not_attr("#account-delete", "disabled", "disabled")
        b.set_val('#account-real-name', "Anton Arbitrary")
        b.wait_not_attr('#account-real-name', 'data-dirty', 'true')
        # don't wait for /etc/passwd to be current - this should hold true once the 'data-dirty' was removed
        self.assertIn(":Anton Arbitrary:", m.execute("grep anton /etc/passwd"))
        # Table title changes along
        b.wait_text("#account-title", "Anton Arbitrary")

        # Add some other GECOS fields
        b.set_val('#account-real-name', "Anton Arbitrary,1,123")
        b.wait_not_attr('#account-real-name', 'data-dirty', 'true')
        # don't wait for /etc/passwd to be current - this should hold true once the 'data-dirty' was removed
        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('.cockpit-account-real-name:contains("Anton")', "Anton Arbitrary")
        b.go("/users/#anton")

        good_password = "tqymuVh.ZfZnP§9Wr=LM3JyG5yx"
        # Delete it
        b.click('#account-delete')
        b.wait_popup('account-confirm-delete-dialog')
        b.click('#account-confirm-delete-apply')
        b.wait_popdown('account-confirm-delete-dialog')
        b.wait_visible("#accounts")
        b.wait_not_in_text('#accounts-list', "Anton Arbitrary")

        # Check root user
        b.go("#/root")
        b.wait_text("#account-user-name", "root")
        # some operations are not allowed for root user
        b.wait_present("#account-delete[disabled]")
        b.wait_present("#account-real-name-wrapper[disabled]")
        b.wait_attr("#account-logout", "disabled", "disabled")
        b.wait_present("#account-locked:not(:checked)")
        # 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 breadcrumb
        b.click("#account .breadcrumb a")
        b.wait_present("#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_popup('accounts-create-dialog')
        b.set_val('#accounts-create-user-name', "berta")
        b.set_val('#accounts-create-real-name', "Berta Bestimmt")
        b.set_val('#accounts-create-pw1', good_password)

        # wrong password confirmation
        b.set_val('#accounts-create-pw2', good_password + 'b')
        b.click('#accounts-create-create')
        b.wait_in_text("#accounts-create-dialog .dialog-error.help-block", "The passwords do not match")

        # correct password confirmation
        b.set_val('#accounts-create-pw2', good_password)
        b.click('#accounts-create-create')
        b.wait_not_visible("#accounts-create-dialog .dialog-error")
        b.wait_popdown('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("stat -c '%U' {0}".format(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")

        # Create a locked user
        m.execute("sed -i 's/^SHELL=.*$/SHELL=/' /etc/default/useradd")
        b.click('#accounts-create')
        b.wait_popup('accounts-create-dialog')
        b.set_val('#accounts-create-user-name', "jussi")
        b.set_val('#accounts-create-real-name', "Jussi Junior")
        b.set_val('#accounts-create-pw1', good_password)
        b.set_val('#accounts-create-pw2', good_password)
        b.set_checked('#accounts-create-locked', True)
        b.click('#accounts-create-create')
        b.wait_popdown('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("getent group %s" % m.get_admin_group())

        admin_role_sel = 'input[data-name="%s"]' % m.get_admin_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.set_checked('#account-locked', False)
        b.wait(lambda: not is_locked())
        b.wait_present(admin_role_sel + ":not(:checked)")
        b.set_checked(admin_role_sel, True)
        b.wait(is_admin)
        if m.image != "rhel-8-2-distropkg": #Changed in #13492
            b.wait_present(admin_role_sel + ":checked")
            b.wait_not_present("#account-roles .pf-c-alert.pf-m-info")

        # Login as jussi and change role admin for itself
        b.logout()
        b.login_and_go("/users", user="jussi", password=good_password)
        b.go("#/jussi")
        b.wait_text("#account-user-name", "jussi")
        b.wait_present(admin_role_sel + ":checked")
        b.set_checked(admin_role_sel, False)
        b.wait(lambda: not is_admin())
        if m.image != "fedora-coreos": # User is not shown as logged in when logged in through Cockpit
            b.wait_present("#account-roles .pf-c-alert.pf-m-info")
        m.execute("/usr/sbin/usermod jussi -G %s -a" % m.get_admin_group())
        b.wait_present(admin_role_sel + ":checked:not([disabled])")
        b.go("#/admin")
        b.wait_text("#account-user-name", "admin")
        b.wait_present(admin_role_sel + ":checked")
        if m.image != "rhel-8-2-distropkg": #Changed in #13492
            b.wait_not_present("#account-roles .pf-c-alert.pf-m-info")
        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_popup('account-set-password-dialog')

        # weak password
        b.set_val("#account-set-password-pw1", 'a')
        b.set_val("#account-set-password-pw2", 'a')
        b.click('#account-set-password-apply')
        b.wait_in_text(".check-passwords.has-error .dialog-error.help-block", "Password quality check failed:")

        # password mismatch
        b.set_val("#account-set-password-pw1", good_password + 'a')
        b.set_val("#account-set-password-pw2", good_password + 'b')
        b.click('#account-set-password-apply')
        b.wait_in_text(".check-passwords.has-error .dialog-error.help-block", "The passwords do not match")

        good_password_2 = "cEwghLY§X9R&m8RLwk4Xfed9Bw="
        # Now set to something valid
        b.set_val("#account-set-password-pw1", good_password_2)
        b.set_val("#account-set-password-pw2", good_password_2)
        b.click('#account-set-password-apply')
        b.wait_popdown('account-set-password-dialog')

        # Logout and login with the new password
        b.logout()
        self.allow_restart_journal_messages()
        b.open("/users")
        b.wait_visible("#login")
        b.set_val("#login-user-input", "jussi")
        b.set_val("#login-password-input", good_password_2)
        b.click('#login-button')
        b.expect_load()
        b.wait_present('#content')

        # incomplete passwd entry; fixed in PR #13384
        if m.image not in ["rhel-8-2-distropkg"]:
            m.execute('echo "damaged:x:1234:1234:Damaged" >> /etc/passwd')
            b.go("/users")
            b.enter_page("/users")
            b.wait_in_text('#accounts-list', "damaged")
            b.click('.cockpit-account-user-name[href="#/damaged"]')
            b.wait_in_text("#account-title", "Damaged")

        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("lastlog: Unknown user or range: anton")

    def testUnprivileged(self):
        m = self.machine
        b = self.browser
        new_password = "tqymuVh.Zf5"
        new_password_2 = "cEwghLYX"

        m.execute("useradd anton; echo anton:foobar | chpasswd")
        self.login_and_go("/users", user="anton", authorized=False)
        b.go("#/anton")
        b.wait_text("#account-user-name", "anton")
        b.wait_present('#account-set-password:enabled')
        b.click('#account-set-password')
        b.wait_popup('account-set-password-dialog')
        b.set_val("#account-set-password-old", "foobar")
        b.set_val("#account-set-password-pw1", new_password)
        b.set_val("#account-set-password-pw2", new_password)
        b.click('#account-set-password-apply')
        b.wait_popdown('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", "anton")
        b.set_val("#login-password-input", new_password)
        b.click('#login-button')
        b.expect_load()
        b.wait_present('#content')

        # Set minimum age to disallow changing it immediately again
        m.execute("chage --mindays 7 anton")
        b.enter_page("/users")
        b.go("#/anton")
        b.wait_text("#account-user-name", "anton")
        b.wait_present('#account-set-password:enabled')
        b.click('#account-set-password')
        b.wait_popup('account-set-password-dialog')
        b.set_val("#account-set-password-old", new_password)
        b.set_val("#account-set-password-pw1", new_password_2)
        b.set_val("#account-set-password-pw2", new_password_2)
        b.click('#account-set-password-apply')
        b.wait_in_text(".dialog-error", "must wait longer")

        self.allow_restart_journal_messages()
        self.allow_authorize_journal_messages()

    @skipImage("ssh root login not allowed", "fedora-coreos")
    def testRootLogin(self):
        m = self.machine
        b = self.browser
        new_password = "tqymuVh.Zf5"

        m.execute("useradd anton; echo anton:foobar | chpasswd")
        self.login_and_go("/users", user="root", authorized=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_present('#account-set-password:enabled')
            b.click('#account-set-password')
            b.wait_popup('account-set-password-dialog')
            b.wait_visible("#account-set-password-pw1")
            # root does not need to know old password
            b.wait_not_visible("#account-set-password-old")
            b.set_val("#account-set-password-pw1", new_password)
            b.set_val("#account-set-password-pw2", new_password)
            b.click('#account-set-password-apply')
            b.wait_popdown('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.expect_load()
            b.wait_present('#content')

        self.allow_restart_journal_messages()
        self.allow_authorize_journal_messages()

    def accountExpiryInfo(self, account, field):
        for line in self.machine.execute("LC_ALL=C chage -l {0}".format(account)).split("\n"):
            if line.startswith(field):
                (name, delim, 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' || true")
        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-button", "Never lock account")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")
        b.click("#account-expiration-button")
        b.wait_popup("account-expiration")
        b.click("#account-expiration-expires")

        # Try an invalid date
        b.set_val("#account-expiration-input", "blah")
        b.click("#account-expiration button:nth-of-type(2)")
        b.wait_text("#account-expiration .dialog-error", "Invalid expiration date")

        # Now a valid date 30 days in the future
        when = datetime.datetime.now() + datetime.timedelta(days=30)
        b.set_val("#account-expiration-input", when.isoformat().split("T")[0])
        b.click("#account-expiration button:nth-of-type(2)")
        b.wait_popdown("account-expiration")
        b.wait_in_text("#account-expiration-button", "Lock account on")
        self.assertNotEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")

        # Now try and change it back
        b.click("#account-expiration-button")
        b.wait_popup("account-expiration")
        b.click("#account-expiration-never")
        b.click("#account-expiration button:nth-of-type(2)")
        b.wait_popdown("account-expiration")
        b.wait_text("#account-expiration-button", "Never lock account")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Account expires"), "never")

        # Try to expire a password
        b.wait_text("#password-expiration-button", "Never expire password")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Password expires"), "never")
        b.click("#password-expiration-button")
        b.wait_popup("password-expiration")
        b.click("#password-expiration-expires")

        # Try an invalid number
        b.set_val("#password-expiration-input", "-3")
        b.click("#password-expiration button:nth-of-type(2)")
        b.wait_text("#password-expiration .dialog-error", "Invalid number of days")

        # Expire password every 30 days
        b.set_val("#password-expiration-input", "30")
        b.click("#password-expiration button:nth-of-type(2)")
        b.wait_popdown("password-expiration")
        b.wait_in_text("#password-expiration-button", "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_popup("password-expiration")
        b.click("#password-expiration-never")
        b.click("#password-expiration button:nth-of-type(2)")
        b.wait_popdown("password-expiration")
        b.wait_text("#password-expiration-button", "Never expire password")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Password expires"), "never")

        # Now change it to expire again
        b.click("#password-expiration-button")
        b.wait_popup("password-expiration")
        b.click("#password-expiration-expires")
        b.set_val("#password-expiration-input", "30")
        b.click("#password-expiration button:nth-of-type(2)")
        b.wait_popdown("password-expiration")

        b.logout()
        self.allow_authorize_journal_messages()
        self.login_and_go("/users", user="scruffy")
        b.go("#/scruffy")
        b.wait_text("#account-user-name", "scruffy")
        b.wait_text("#account-expiration-button", "Never lock account")
        b.wait_present("#account-expiration-button[disabled]")
        b.wait_in_text("#password-expiration-button", "Require password change on")
        b.wait_present("#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_popup("password-reset")
        b.click("#password-reset button:nth-of-type(2)")
        b.wait_popdown("password-reset")
        b.wait_in_text("#password-expiration-button", "Password must be changed")
        self.assertEqual(self.accountExpiryInfo("scruffy", "Password expires"), "password must be changed")


if __name__ == '__main__':
    test_main()
