#!/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 re
import time

import packagelib
import submanlib
import testlib

os_release = """
NAME="Foobar Adventure Linux Server"
VERSION="2.0 (Day of Doom)"
ID="foobar"
VERSION_ID="2.0"
PRETTY_NAME="Foobar Adventure Linux Server 2.0 (Day of Doom)"
"""

lscpu = """#!/bin/sh
echo 'CPU(s):              8'
echo 'On-line CPU(s) list: 0-7'
echo 'Thread(s) per core:  {0}'
echo 'Core(s) per socket:  4'
echo 'Socket(s):           1'
"""

# Helper for MB size when handling raw bytes.
MB = 1024**2


def ssh_reconnect(machine, timeout_sec=120):
    start_time = time.time()
    error = None
    while (time.time() - start_time) < timeout_sec:
        try:
            machine.execute("true", quiet=True)
            return
        except Exception as e:
            error = e
        time.sleep(0.5)

    raise error


class TestSystemInfo(testlib.MachineCase):
    def setUp(self):
        super().setUp()

        # Most OSes don't set nosmt by default, but there are some exceptions
        self.expect_smt_default = self.machine.image in ["fedora-coreos"]

        # Switching into and out of FIPS mode is not supported anymore in newer OSes.
        self.supportsFIPS = self.machine.image.startswith(("rhel-8", "rhel-9", "centos-9"))

    @testlib.skipImage("TODO: fix for OpenSUSE", "opensuse-tumbleweed")
    def testBasic(self):
        m = self.machine
        b = self.browser

        # /etc/os-release might be a symlink and file watching doesn't
        # follow symlinks, so we remove it and then create a regular
        # file.
        #
        # In addition hostnamed does not expect os-release to change so
        # we force a restart. Usually any such changes to os-release are
        # expected to happen during reboot, or picked up after a reboot.
        #
        # subscription-manager also screws with os-release so set it
        # to immutable
        #
        m.execute("rm /etc/os-release")
        m.write("/etc/os-release", os_release)
        m.execute("chattr +i /etc/os-release; (systemctl restart systemd-hostnamed || systemctl restart hostnamed)")

        # set static machine ID, as they have different lengths which disturbs the pixel test
        m.execute("mv /etc/machine-id /etc/machine-id.orig")
        m.write("/etc/machine-id", "123456789abcdef123456789abcdef00")
        # avoid "Last logged in" health card message, it breaks pixel tests and is too unpredictable
        m.execute("rm -f /var/log/lastlog /var/lib/lastlog/lastlog2.db /var/lib/wtmpdb/wtmp.db")

        self.login_and_go("/system")

        b.wait_visible('#system_information_os_text')

        # Health card can contain only one item - it normally is "Loading available updates fail"
        # But sometimes it also contains information about failed services which breaks mobile pixel tests
        m.execute("systemctl reset-failed")
        b.wait_not_present("#page_status_notification_system_services")

        # ensure general page/card layout without the changing specifics
        # need to wait until CPU usage settles down, to avoid a layout-shifting error/warning icon
        b.wait_not_present("#system-usage-cpu-progress + td .pf-v6-c-progress__status-icon")
        testlib.wait(lambda: b.get_pf_progress_value("#system-usage-cpu-progress + td") < 30)
        if b.pixels_label:
            # Wait for tuned text to appear, even thought we ignore it we need to button to have some text.
            recommended_profile = m.execute("tuned-adm recommend").strip()
            b.wait_text("#tuned-status-button", recommended_profile)
            # Wait for there to be no warning icons in the usage.
            b.wait_not_present('#system-usage .pf-v6-c-progress__status-icon')
        b.assert_pixels("#overview", "overview", ignore=[
            ".system-health .pf-v6-c-card__body",
            "#system_uptime",
            # #system_information_systime_button is not enough, need to grab the icon as well
            "td[data-label='system-time']",
            # CPU/memory metrics
            "#system-usage-cpu-progress + td",
            "#system-usage-memory-progress + td",
            "#tuned-status-button",
        ])

        m.execute("mv /etc/machine-id.orig /etc/machine-id")

        # Generate a new rsa key and change the config
        m.execute("ssh-keygen -f /etc/ssh/weirdname -t rsa -N ''")
        m.execute("chmod 600 /etc/ssh/weirdname")
        m.execute("restorecon /etc/ssh/weirdname || true")

        new_default = m.execute("ssh-keygen -l -f /etc/ssh/weirdname -E md5 | cut -d' ' -f2 | tr -d '\n'")
        new_alt = m.execute("ssh-keygen -l -f /etc/ssh/weirdname -E sha256 | cut -d' ' -f2 | tr -d '\n'")
        old_default = m.execute("ssh-keygen -l -f /etc/ssh/ssh_host_rsa_key -E md5 | cut -d' ' -f2 | tr -d '\n'")
        old_alt = m.execute("ssh-keygen -l -f /etc/ssh/ssh_host_rsa_key -E sha256 | cut -d' ' -f2 | tr -d '\n'")

        b.click("#system-ssh-keys-link")
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", "ED25519")
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", "RSA")
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", "ECDSA")
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", new_default)
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", old_default)
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", new_alt)
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", old_alt)

        b.click("#system_information_ssh_keys button:contains('Close')")
        b.wait_not_present("#system_information_ssh_keys")

        # Change ssh config and restart
        self.sed_file(r"s,.*HostKey *,#,; $ a HostKey /etc/ssh/weirdname", "/etc/ssh/sshd_config",
                      self.restart_sshd)
        ssh_reconnect(m)

        b.click("#system-ssh-keys-link")
        b.wait_visible("#system_information_ssh_keys")
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", "ED25519")
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", "RSA")
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", "ECDSA")
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", new_default)
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", old_default)
        b.wait_not_in_text("#system_information_ssh_keys .pf-v6-c-list", old_alt)
        b.wait_in_text("#system_information_ssh_keys .pf-v6-c-list", new_alt)

        b.wait_in_text('#system_information_os_text',
                       "Foobar Adventure Linux Server 2.0 (Day of Doom)")

        b.click("#system_information_ssh_keys button:contains('Close')")
        b.wait_not_present("#system_information_ssh_keys")

        m.execute("hostnamectl set-hostname --static --pretty 'Adventure Box'")
        b.wait_in_text('#system_information_hostname_text', "Adventure Box")

        b.click('#system_information_hostname_button')
        b.wait_visible("#system_information_change_hostname")
        b.wait_val("#sich-pretty-hostname", "Adventure Box")
        # Test setting the pretty hostname, changes the normal hostname
        b.set_input_text("#sich-pretty-hostname", "Adventure Time")
        b.wait_val("#sich-hostname", "adventure-time")
        # Changing the hostname should validate
        b.set_input_text("#sich-hostname", 65 * "x")
        b.wait_in_text("#system_information_change_hostname .pf-v6-c-helper-text__item-text", "64 characters or less")
        b.set_input_text("#sich-hostname", "host1.cockpit.lan$")
        b.wait_in_text("#system_information_change_hostname .pf-v6-c-helper-text__item-text", "Real host name can only contain")
        b.set_input_text("#sich-hostname", "host1.cockpit.lan")
        b.click("#system_information_change_hostname button:contains('Change')")
        b.wait_not_present("#system_information_change_hostname")

        b.wait_in_text('#system_information_hostname_text', "Adventure Time (host1.cockpit.lan)")
        self.assertEqual(m.execute("hostname").strip(), "host1.cockpit.lan")

        m.execute("hostnamectl set-hostname ''")
        m.execute("hostnamectl set-hostname --transient 'mydhcpname'")
        b.wait_in_text('#system_information_hostname_text', 'mydhcpname')

        b.logout()
        m.execute("chattr -i /etc/os-release; rm /etc/os-release")
        m.execute("rm /usr/lib/os-release || true")

        self.login_and_go("/system")
        mid = m.execute("cat /etc/machine-id")
        b.wait_text('#system_machine_id', mid)

        # uptime (introduced in PR #13885)
        b.wait_text_not("#system_uptime", "")
        # replace it with a known value, it should automatically update every minute
        m.write("/tmp/fake_uptime", "2000.12 12345.30\n")
        m.execute("mount -o bind /tmp/fake_uptime /proc/uptime")
        self.addCleanup(m.execute, "umount /proc/uptime")
        with b.wait_timeout(70):
            b.wait_text("#system_uptime", "33 minutes ago")
        # 4 months and a bit, timeformat rounds quite aggressively; also, test a slightly different format
        m.write("/tmp/fake_uptime", "10370000 12345.30\n")
        with b.wait_timeout(70):
            b.wait_text("#system_uptime", "4 months ago")

        self.allow_journal_messages("error loading contents of os-release: .*",  # C bridge
                                    ".* Neither /etc/os-release nor /usr/lib/os-release exists",  # py bridge
                                    "sudo: unable to resolve host host1.cockpit.lan: .*")

    @testlib.nondestructive
    @testlib.skipImage("TODO: fix for OpenSUSE", "opensuse-tumbleweed")
    def testMotd(self):
        m = self.machine
        b = self.browser

        self.restore_file("/etc/motd")
        # run this test with a tight umask to check preserving file permissions
        self.sed_file(r"/^UMASK/ s/0../077/", "/etc/login.defs")
        m.execute("rm -f /etc/motd")

        self.login_and_go("/system")
        b.wait_not_present('#motd-box')

        m.execute(r"printf '\n  \n  Hello\n  World\n\n' >/etc/motd; chmod 644 /etc/motd")
        b.wait_visible('#motd-box')
        # strips empty lines, but not leading spaces
        b.wait_text('#motd', "  Hello\n  World")

        # For some reason, switching to RTL takes a long time
        # here. Let's increase the delay.
        b.assert_pixels("#motd-box", "motd", wait_delay=2.0)

        b.click('#motd-box button:not(#motd-box-edit)')
        b.wait_not_present('#motd-box')

        # motd should stay dismissed after a reload
        b.reload()
        b.enter_page("/system")
        b.wait_not_present('#motd-box')

        m.execute("echo Hello again >/etc/motd")
        b.wait_visible('#motd-box')
        b.wait_text('#motd', "Hello again")

        # Cancel button
        b.click("#motd-box-edit")
        b.click("#motd-box-edit-modal button.pf-m-link")
        b.wait_not_present("motd-box-edit-modal")

        b.click("#motd-box-edit")
        b.set_input_text("#motd-box-edit-modal textarea", "Hello cockpit team")
        b.click("#motd-box-edit-modal button.pf-m-primary")
        b.wait_not_present("motd-box-edit-modal")
        b.wait_text('#motd', "Hello cockpit team")
        self.assertEqual("Hello cockpit team", self.machine.execute("cat /etc/motd").rstrip())

    @testlib.nondestructive
    def testHardwareInfo(self):
        b = self.browser
        m = self.machine

        self.login_and_go("/system")
        b.wait_in_text('#system_information_hardware_text', "QEMU")

        hardware_page_link = '.system-information a'
        b.click(hardware_page_link)
        b.enter_page("/system/hwinfo")

        # system info
        b.wait_in_text('#hwinfo-system-info-list', "CPU")
        # QEMU VM type
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(1) dd', "Other")
        # Name
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(2) dd', "Standard PC")
        # BIOS
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(2) .pf-v6-c-description-list__group:nth-of-type(1) dd', "SeaBIOS")
        # BIOS date gets parsed
        parsed_bios_date = m.execute("date --date $(cat /sys/class/dmi/id/bios_date) '+%B %-d, %Y'").strip()
        b.wait_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(2) .pf-v6-c-description-list__group:nth-of-type(3) dd', parsed_bios_date)

        pci_selector = '#hwinfo #pci-listing'
        heading_selector = ' .pf-v6-c-card__title'
        # PCI
        b.wait_in_text(pci_selector + heading_selector, "PCI")

        b.wait_in_text(pci_selector + ' tbody:first-of-type td[data-label=Slot]', "0000:00:00.0")

        # sorted by device class by default; this makes some assumptions about QEMU devices
        b.wait_in_text(pci_selector + ' tbody:first-of-type td[data-label=Class]', "Bridge")
        b.wait_in_text(pci_selector + ' tbody:last-of-type td[data-label=Class]', "Unclassified")

        # sort by model
        b.click(pci_selector + ' thead th:nth-child(2) button')
        b.wait_in_text(pci_selector + ' tbody:first-of-type td[data-label=Model]', "440")
        b.wait_in_text(pci_selector + ' tbody:last-of-type td[data-label=Model]', "Virtio SCSI")
        b.wait_not_in_text(pci_selector + ' tbody:last-of-type td[data-label=Model]', "Unclassified")

        # go back to system page
        b.click('.pf-v6-c-breadcrumb li:first-of-type')

        b.enter_page("/system")

        # now pretend this is a system without DMI
        b.logout()
        m.execute("mount -t tmpfs none /sys/class/dmi/id")
        # check if it's mounted as the memory tests umount it.
        self.addCleanup(m.execute, "! mountpoint -q /sys/class/dmi/id || umount /sys/class/dmi/id")
        self.login_and_go("/system")
        # asset tag should be hidden
        b.wait_not_present('#system_information_asset_tag_text')

        # Hardware should be hidden
        b.wait_not_present('#system_information_hardware_text')
        b.click(hardware_page_link)
        b.enter_page("/system/hwinfo")

        # CPU should still be shown, but not the DMI fields
        b.wait_in_text('#hwinfo-system-info-list', "CPU")
        self.assertNotIn('Type', b.text('#hwinfo-system-info-list'))
        self.assertNotIn('BIOS', b.text('#hwinfo-system-info-list'))

        # PCI should be shown
        b.wait_in_text(pci_selector + heading_selector, "PCI")
        b.wait_in_text(pci_selector + ' tbody:first-of-type td[data-label=Slot]', "0000:00:00.0")

        # Check also variants when only some fields are present
        m.write("/sys/class/dmi/id/chassis_type", "10")
        b.go("/system")
        b.enter_page('/system')
        b.wait_not_present('#system_information_hardware_text')

        m.write("/sys/class/dmi/id/board_vendor", "VENDOR")
        m.write("/sys/class/dmi/id/board_name", "NAME")
        b.reload()
        b.enter_page('/system')
        b.wait_in_text('#system_information_hardware_text', "VENDOR NAME")
        b.click(hardware_page_link)
        b.enter_page("/system/hwinfo")
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(2) dd', "NAME")
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(3) dd', "VENDOR")

        # Clean up after lazy OEMs, falls back to board vendor/name
        m.write("/sys/class/dmi/id/sys_vendor", "To Be Filled By O.E.M.")
        m.write("/sys/class/dmi/id/product_name", "To Be Filled By O.E.M.")
        m.write("/sys/class/dmi/id/product_serial", "PL1234")
        m.write("/sys/class/dmi/id/board_vendor", "brdven")
        m.write("/sys/class/dmi/id/board_name", "brdnam")
        b.reload()
        b.enter_page("/system/hwinfo")
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(2) dd', "brdnam")
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(1) .pf-v6-c-description-list__group:nth-of-type(3) dd', "brdven")

        b.go("/system")
        b.enter_page('/system')
        b.wait_in_text('#system_information_hardware_text', "brdven brdnam")
        if m.execute("uname -m").strip() == "x86_64":
            b.wait_in_text('#system_information_asset_tag_text', "PL1234")
        else:
            b.wait_not_present('#system_information_asset_tag_text')
        b.click(hardware_page_link)

        # /proc/cpuinfo on x86; very incomplete, just what pkg/lib/machine-info.js looks at
        m.write("/tmp/cpuinfo", """processor\t: 0
vendor_id\t: GenuineIntel
model\t\t: 42
model name\t: Professor NumberCrunch

processor\t: 1
vendor_id\t: GenuineIntel
model\t\t: 42
model name\t: Professor NumberCrunch
""")
        m.execute("mount -o bind /tmp/cpuinfo /proc/cpuinfo")
        self.addCleanup(m.execute, "umount /proc/cpuinfo")

        b.reload()
        b.enter_page('/system/hwinfo')
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(2) .pf-v6-c-description-list__group:nth-of-type(1) dd', "2x Professor NumberCrunch")

        # /proc/cpuinfo on PowerPC; complete info
        m.write("/tmp/cpuinfo", """processor\t: 0
cpu\t\t: POWER9 (architected), altivec supported
clock\t\t: 3000.000000MHz
revision\t: 2.3 (pvr 004e 1203)

processor\t: 1
cpu\t\t: POWER9 (architected), altivec supported
clock\t\t: 3000.000000MHz
revision\t: 2.3 (pvr 004e 1203)
""")

        b.reload()
        b.enter_page('/system/hwinfo')
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(2) .pf-v6-c-description-list__group:nth-of-type(1) dd', "2x POWER9 (architected), altivec supported")

        # correct CPU count on overview
        b.go("/system")
        b.enter_page("/system")
        b.wait_in_text("#system-usage-cpu-progress + td", "of 2 CPUs")

        # /proc/cpuinfo on s390x (reduced)
        m.write("/tmp/cpuinfo", """vendor_id       : IBM/S390
# processors    : 2
bogomips per cpu: 3241.00
max thread id   : 0
features	: esan3 zarch stfle msa ldisp eimm dfp edat etf3eh highgprs te vx vxd vxe gs vxe2 vxp sort dflt sie
processor 0: version = FF,  identification = 2EB428,  machine = 8561
processor 1: version = FF,  identification = 2EB428,  machine = 8561

cpu number      : 0
cpu cores       : 1
version         : FF
identification  : 2EB428
machine         : 8561

cpu number      : 1
cpu cores       : 1
version         : FF
identification  : 2EB428
machine         : 8561
""")

        b.reload()
        b.enter_page("/system")
        b.wait_in_text("#system-usage-cpu-progress + td", "of 2 CPUs")

        b.go('/system/hwinfo')
        b.enter_page('/system/hwinfo')
        b.wait_in_text('#hwinfo-system-info-list .hwinfo-system-info-list-item:nth-of-type(2) .pf-v6-c-description-list__group:nth-of-type(1) dd', "2x IBM/S390")

        # umount mocked /sys/class/dmi/id
        m.execute("umount /sys/class/dmi/id")
        m.execute("udevadm trigger --verbose /sys/devices/virtual/dmi/id")
        b.reload()
        b.enter_page('/system/hwinfo')

        # Memory details should be shown from our mocked DMI information from systemd's test files.
        b.wait_in_text('#hwinfo #memory-listing' + heading_selector, "Memory")
        b.wait_in_text('#hwinfo #memory-listing table', "DIMM")
        b.wait_in_text('#hwinfo #memory-listing table', "RAM")

        tmp_dmi_tables = "/tmp/dmi_tables"
        m.execute(f"mkdir {tmp_dmi_tables}")
        self.addCleanup(m.execute, f"rm -rf {tmp_dmi_tables}")
        m.upload(["verify/files/dmi/smbios_entry_point", "verify/files/dmi/DMI"], tmp_dmi_tables)
        m.execute(f"mount -o bind {tmp_dmi_tables} /sys/firmware/dmi/tables")
        self.addCleanup(m.execute, "umount /sys/firmware/dmi/tables")
        m.execute("udevadm trigger --verbose /sys/devices/virtual/dmi/id")

        b.reload()
        b.enter_page('/system/hwinfo')
        distro_without_systemd_memory_dmi = m.image.startswith('rhel-8-')

        # Test more specific memory data with a fake dmidecode
        b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=ID]', "BANK 0: ChannelA-DIMM0")
        b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Type]', "DDR4")
        if distro_without_systemd_memory_dmi:
            b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Size]', "4 GB")
        else:
            b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Size]', "4 GiB")
        b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=State]', "Present")
        b.wait_text('#memory-listing tbody:nth-of-type(1) td[data-label="Memory technology"]', "Unknown")
        b.wait_text('#memory-listing tbody:nth-of-type(1) td[data-label=Rank]', "Single rank")
        b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Speed]', "2400 MT/s")

        b.wait_in_text('#memory-listing tbody:nth-of-type(2) td[data-label=ID]', "BANK 2: ChannelB-DIMM0")
        b.wait_in_text('#memory-listing tbody:nth-of-type(2) td[data-label=Type]', "DDR4")
        if distro_without_systemd_memory_dmi:
            b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Size]', "4 GB")
        else:
            b.wait_in_text('#memory-listing tbody:nth-of-type(1) td[data-label=Size]', "4 GiB")
        b.wait_in_text('#memory-listing tbody:nth-of-type(2) td[data-label=State]', "Present")
        b.wait_text('#memory-listing tbody:nth-of-type(2) td[data-label="Memory technology"]', "Unknown")
        b.wait_text('#memory-listing tbody:nth-of-type(2) td[data-label=Rank]', "Single rank")
        b.wait_in_text('#memory-listing tbody:nth-of-type(2) td[data-label=Speed]', "2400 MT/s")

    @testlib.nondestructive
    def testCPUSecurityMitigationsDetect(self):
        b = self.browser
        m = self.machine

        self.restore_dir("/usr/local/bin")
        m.start_cockpit()

        def spoof_threads(threads_per_core, expect_link_present, expect_smt_state=None, cmdline=None):
            m.write('/usr/local/bin/lscpu', lscpu.format(threads_per_core))
            m.execute('chmod +x /usr/local/bin/lscpu')
            if cmdline:
                m.write('/run/cmdline', cmdline)
                m.execute('if selinuxenabled 2>/dev/null; then chcon --reference /proc/cmdline /run/cmdline; fi')
                m.execute('mount --bind /run/cmdline /proc/cmdline; rm /run/cmdline')

            try:
                b.login_and_go('/system/hwinfo')

                if not expect_link_present:
                    b.wait_in_text('#hwinfo-system-info-list', "CPU")
                    b.wait_not_in_text('#hwinfo-system-info-list', "CPU security")
                else:
                    b.click('#hwinfo button:contains("Mitigations")')

                if expect_smt_state is not None:
                    b.wait_in_text('#cpu-mitigations-dialog .nosmt-heading', 'nosmt')
                    b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input' +
                                   (":checked" if expect_smt_state else ":not(:checked)"))

                b.logout()
            finally:
                if cmdline:
                    m.execute('while ! umount /proc/cmdline; do sleep 1; done')

        spoof_threads(1, expect_link_present=False)
        spoof_threads(2, expect_link_present=True, expect_smt_state=True, cmdline='param1 param2 nosmt param3=value3')
        spoof_threads(2, expect_link_present=True, expect_smt_state=True, cmdline='param1 param2 nosmt=force param3=value3')
        spoof_threads(2, expect_link_present=True, expect_smt_state=True, cmdline='param1 mitigations=auto,nosmt param3=value3')
        spoof_threads(2, expect_link_present=True, expect_smt_state=True, cmdline='param1 mitigations=nosmt,something param3=value3')
        spoof_threads(2, expect_link_present=True, expect_smt_state=False, cmdline='param1 mitigations=something param3=value3')
        spoof_threads(2, expect_link_present=False, cmdline='param1 nosmt=someunknown param3=value3')
        spoof_threads(2, expect_link_present=True, expect_smt_state=self.expect_smt_default, cmdline=None)

    @testlib.skipImage("TODO: add Arch Linux grub entry support", "arch")
    @testlib.timeout(1200)
    def testCPUSecurityMitigationsEnable(self):
        b = self.browser
        m = self.machine

        # spoof SMT
        m.write('/usr/local/bin/lscpu', lscpu.format(2))
        m.execute('chmod +x /usr/local/bin/lscpu')

        # Switch nosmt option
        self.login_and_go('/system/hwinfo')
        b.click('#hwinfo button:contains("Mitigations")')
        b.click('#cpu-mitigations-dialog #nosmt-switch input')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input' +
                       (':not(:checked)' if self.expect_smt_default else ':checked'))
        b.click('#cpu-mitigations-dialog Button:contains("Save and reboot")')

        self.wait_reboot()
        if self.expect_smt_default:
            self.assertNotIn('nosmt', m.execute('cat /proc/cmdline'))
        else:
            self.assertIn('nosmt', m.execute('cat /proc/cmdline'))

        # Ensure that future kernel upgrades also retain the option
        # - Debian: no BLS, options go into /etc/default/grub and grub.cfg
        # - BLS, options go directly into entries, or entries use $kernelopt (defined in grubenv)
        if not m.ostree_image:
            m.execute(r"""
echo dummy > /boot/vmlinuz-42.0.0; mkdir -p /lib/modules/42.0.0/
if type update-grub >/dev/null 2>&1; then
    update-grub  # Debian/Ubuntu
    grep -q 'linux.*/vmlinuz-42.0.0.*nosmt' /boot/grub*/grub.cfg
elif type update-bootloader >/dev/null 2>&1; then
    update-bootloader  # suse
    grep -q 'linux.*/vmlinuz-42.0.0.*nosmt' /boot/grub*/grub.cfg
else
    cp -a /boot/grub2/grubenv /boot/grub2/grubenv.prev
    kernel-install add 42.0.0 /boot/vmlinuz-42.0.0 2>/dev/null
    grep -q '^options.*\bnosmt\b' /boot/loader/entries/*42.0.0*.conf ||
    ( grub2-editenv list | grep -q kernelopts.*nosmt &&
      grep -q '^options.*$kernelopts' /boot/loader/entries/*42.0.0*.conf )
fi
""")
            # clean up so that next reboot works
            m.execute(r"""
rm /boot/vmlinuz-42.0.0
if type update-grub >/dev/null 2>&1; then
    update-grub  # Debian/Ubuntu
elif type update-bootloader >/dev/null 2>&1; then
    update-bootloader # suse
else
    kernel-install remove 42.0.0 /boot/vmlinuz-42.0.0
    # HACK: https://bugzilla.redhat.com/show_bug.cgi?id=2078359 and https://bugzilla.redhat.com/show_bug.cgi?id=2078379
    mv /boot/grub2/grubenv.prev /boot/grub2/grubenv
fi
""")

        # Switch back nosmt option
        self.login_and_go('/system/hwinfo')
        b.click('#hwinfo button:contains("Mitigations")')
        b.wait_in_text('#cpu-mitigations-dialog .nosmt-heading', 'nosmt')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input' +
                       (':not(:checked)' if self.expect_smt_default else ':checked'))
        b.click('#cpu-mitigations-dialog #nosmt-switch input')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input' +
                       (':checked' if self.expect_smt_default else ':not(:checked)'))
        b.click('#cpu-mitigations-dialog Button:contains("Save and reboot")')
        self.wait_reboot()
        if self.expect_smt_default:
            self.assertIn('nosmt', m.execute('cat /proc/cmdline'))
        else:
            self.assertNotIn('nosmt', m.execute('cat /proc/cmdline'))

        # updates mitigations=nosmt when that is present
        m.upload(["../pkg/lib/kernelopt.sh"], "/tmp/")
        if self.expect_smt_default:
            m.execute("/tmp/kernelopt.sh remove nosmt")
        m.execute("/tmp/kernelopt.sh set mitigations=auto,nosmt")
        self.reboot()
        self.login_and_go('/system/hwinfo')
        b.click('#hwinfo button:contains("Mitigations")')
        b.wait_in_text('#cpu-mitigations-dialog .nosmt-heading', 'nosmt')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input:checked')
        b.click('#cpu-mitigations-dialog #nosmt-switch input')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input:not(:checked)')
        b.click('#cpu-mitigations-dialog Button:contains("Save and reboot")')
        self.wait_reboot()
        self.assertNotIn('nosmt', m.execute('cat /proc/cmdline'))
        self.assertIn('mitigations=auto', m.execute('cat /proc/cmdline'))

        # Behaviour for non-admins
        self.login_and_go('/system/hwinfo', superuser=False)
        b.wait_visible('#cpu_mitigations[disabled]')
        b.mouse('#tip-cpu-security', 'mouseenter')
        b.wait_text('.pf-v6-c-tooltip', 'The user admin is not permitted to change cpu security mitigations')
        b.mouse('#tip-cpu-security', 'mouseleave')
        b.wait_not_present("div.pf-v6-c-tooltip")

        # Behaviour if grub update tools are missing
        b.logout()
        m.execute('mv /etc/default/grub /etc/default/grub.bak || true')
        m.write('/tmp/grubby', '#!/bin/sh\necho 0')
        m.execute('[ ! -f /usr/sbin/grubby ] || mount --bind /tmp/grubby /usr/sbin/grubby')
        m.write('/tmp/update-bootloader', '#!/bin/sh\necho 0')
        m.execute('[ ! -f /usr/sbin/update-bootloader ] || mount --bind /tmp/update-bootloader /usr/sbin/update-bootloader')
        m.execute('systemctl stop rpm-ostreed.service || true; systemctl mask rpm-ostreed.service')
        self.login_and_go('/system/hwinfo')
        b.click('#hwinfo button:contains("Mitigations")')
        b.click('#cpu-mitigations-dialog #nosmt-switch input')
        b.wait_visible('#cpu-mitigations-dialog #nosmt-switch input:checked')
        b.click("#cpu-mitigations-dialog Button:contains('Save and reboot')")
        b.wait_in_text('#cpu-mitigations-dialog .pf-v6-c-alert__title', 'No supported grub update mechanism found')

        self.allow_journal_messages('Sourcing file `/etc/default/grub.*',
                                    'Generating grub configuration file.*',
                                    'Found linux image.*',
                                    'Found initrd image.*',
                                    '.*warning: setlocale: LC_ALL: cannot change locale.*',
                                    'done')

    @testlib.nondestructive
    def testOverview(self):
        m = self.machine
        b = self.browser

        # packagekit often eats a lot of CPU; silence it to not screw up the "system is idle" test
        m.execute("systemctl mask packagekit")
        self.addCleanup(m.execute, "systemctl unmask packagekit")

        def progressUsage(number):
            return b.get_pf_progress_value(f".system-usage tr:nth-child({number})")

        def progressUsageRaw(number):
            return b.get_raw_pf_progress_value(f".system-usage tr:nth-child({number})")

        def cpuUsage(raw: bool = False):
            if raw:
                return progressUsageRaw(1)
            else:
                return progressUsage(1)

        def memoryUsage(raw: bool = False):
            if raw:
                return progressUsageRaw(2)
            else:
                return progressUsage(2)

        self.login_and_go("/system")

        # CPU
        # first wait until system settles down
        testlib.wait(lambda: cpuUsage() < 20)
        m.spawn("for i in $(seq $(nproc)); do cat /dev/urandom > /dev/null & done; touch /tmp/cpu_hog", "cpu_hog.log")
        m.execute("while [ ! -e /tmp/cpu_hog ]; do sleep 1; done")
        testlib.wait(lambda: cpuUsage() > 75)
        m.execute("pkill -e -f [c]at.*urandom; rm -f /tmp/cpu_hog")
        # should go back to idle usage
        # HACK: work around pmie CPU usage https://bugzilla.redhat.com/show_bug.cgi?id=2140572
        testlib.wait(lambda: cpuUsage() < 20, tries=200)

        # memory: our test machines should use a reasonable chunk of available memory; MiB or GiB
        b.wait_in_text(".system-usage tr:nth-child(2)", "iB")
        initial_usage = memoryUsage(raw=True)
        max_memory = int(b.attr(".system-usage tr:nth-child(2) .pf-v6-c-progress__bar", "aria-valuemax"))
        self.assertGreater(initial_usage, 10 * MB)
        self.assertLess(initial_usage, max_memory * 0.9)
        # allocate an extra 200 MB; this may cause other stuff to get unmapped,
        # thus not exact addition, but usage should go up
        #
        # The "true" after "sleep" is there to prevent bash from
        # replacing it's own process with the sleep (as a "tail call
        # optimization") and thereby dropping the memory blob too early.
        #
        mem_hog = m.spawn("""
            awk 'BEGIN { x = sprintf("%200000000s",""); system("touch /tmp/hogged; sleep infinity") }'
            """, "mem_hog.log")
        try:
            m.execute("while [ ! -e /tmp/hogged ]; do sleep 1; done")
            # bars update every 5s
            time.sleep(8)
            testlib.wait(lambda: memoryUsage(raw=True) >= initial_usage + 10 * MB)
            hog_usage = memoryUsage()
        finally:
            m.execute("kill %d" % mem_hog)
        # Should go back to initial_usage, but it doesn't always, for example on fedora.
        # So let's be happy if the usage drops significantly
        try:
            testlib.wait(lambda: memoryUsage() <= hog_usage - 10)
        except testlib.Error:
            print(f"Memory before hogging: {initial_usage}, after hogging: {hog_usage}, now: {memoryUsage()}")
            raise

        self.assertGreater(memoryUsage(), 10)

    @testlib.nondestructive
    def testShutdownStatus(self):
        m = self.machine
        b = self.browser

        self.login_and_go("/system")
        b.wait_not_present("#system-health-shutdown-status")

        # Schedule a reboot
        m.execute("shutdown --reboot +10")
        self.addCleanup(m.execute, "shutdown -c")

        b.wait_in_text('#system-health-shutdown-status-text', "Scheduled reboot")

        # Check that reloading still shows the reboot text
        b.reload()
        b.enter_page("/system")
        b.wait_in_text('#system-health-shutdown-status-text', "Scheduled reboot")

        # Cancel
        b.click("#system-health-shutdown-status-cancel-btn")
        b.wait_not_present('#system-health-shutdown-status')

        # Schedule a poweroff
        m.execute("shutdown --poweroff +10")
        b.wait_in_text('#system-health-shutdown-status-text', "Scheduled poweroff")

        # Cancel
        b.click("#system-health-shutdown-status-cancel-btn")
        b.wait_not_present('#system-health-shutdown-status')
        dbus_call = 'busctl get-property org.freedesktop.login1 /org/freedesktop/login1  org.freedesktop.login1.Manager ScheduledShutdown'
        self.assertIn('(st) "" ', m.execute(dbus_call).strip())

    @testlib.skipImage("crypto-policies not available", "debian-*", "ubuntu-*", "arch", "opensuse-tumbleweed")
    @testlib.skipOstree("crypto-policies not available")
    def testCryptoPolicies(self):
        m = self.machine
        b = self.browser

        def shown_profile_text(profile):
            return "Default" if profile == "DEFAULT" else profile

        def change_profile(profile, new_profile):
            b.click("#crypto-policy-button")
            b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected", shown_profile_text(profile))
            profile_button_name = shown_profile_text(new_profile)
            b.click(f"#crypto-policy-dialog .pf-v6-c-menu__list-item[data-value='{profile_button_name}'] button")
            b.click("#crypto-policy-save-reboot")
            # Initramfs re-generation takes a while
            self.wait_reboot(timeout_sec=600)
            m.start_cockpit()
            self.login_and_go("/system")
            b.wait_text("#crypto-policy-button", shown_profile_text(new_profile))

        cmd = "update-crypto-policies"

        self.login_and_go("/system")

        profile = m.execute(cmd + " --show").strip()
        b.wait_text("#crypto-policy-button", shown_profile_text(profile))

        # RHEL 8/10 have no SHA1 policy, so do not show it.
        b.click("#crypto-policy-button")
        func = b.wait_not_present if re.match(r'(centos|rhel)-(8|10).*', m.image) else b.wait_visible
        func(".pf-v6-c-menu__item-main .pf-v6-c-menu__item-text:contains('DEFAULT:SHA1')")
        b.click("#crypto-policy-dialog button:contains('Cancel')")
        b.wait_not_present("#crypto-policy-dialog")

        # Test if a new subpolicy can be set
        new_profile = "LEGACY:AD-SUPPORT"
        change_profile(profile, new_profile)

        profile = m.execute(cmd + " --show").strip()
        self.assertEqual(profile, new_profile)
        b.wait_text("#crypto-policy-button", shown_profile_text(profile))

        # Select a custom policy (non-selectable option)
        profile = "EMPTY"
        m.execute(cmd + f" --set {profile}")
        b.enter_page("/system")
        b.wait_text("#crypto-policy-button", shown_profile_text(profile))
        b.click("#crypto-policy-button")
        b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected", shown_profile_text(profile))
        b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected", "Custom cryptographic policy")
        b.click("#crypto-policy-dialog button.pf-v6-c-button.pf-m-link")

        # FIPS mode
        if self.supportsFIPS:
            change_profile(profile, "FIPS")
        else:
            # FIPS is not an available choice
            b.click("#crypto-policy-button")
            b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected", shown_profile_text(profile))
            self.assertNotIn("FIPS", b.text("#crypto-policy-dialog"))
            b.click("#crypto-policy-dialog button:contains('Cancel')")
            b.wait_not_present("#crypto-policy-dialog")

            # pretend the machine already has FIPS enabled, then it can't be disabled
            m.execute(cmd + " --set FIPS")  # this is known incomplete/invalid, but suffices to set the UI state
            b.wait_text("#crypto-policy-current", "FIPS")
            self.assertFalse(b.is_present("#crypto-policy-button"))
            m.execute(cmd + f" --set {profile}")  # this is known incomplete/invalid, but suffices to set the UI state
            b.wait_text("#crypto-policy-button", shown_profile_text(profile))

    @testlib.skipImage("crypto-policies not available", "debian-*", "ubuntu-*", "arch")
    @testlib.skipOstree("crypto-policies not available")
    def testInconsistentCryptoPolicy(self):
        m = self.machine
        b = self.browser
        cmd = "update-crypto-policies"

        # Admin sets FIPS crypto policy in terminal, but FIPS mode is disabled
        m.execute(cmd + " --set FIPS")
        self.login_and_go("/system")
        b.wait_text("#inconsistent_crypto_policy", "FIPS is not properly enabled")
        b.click(".system-health-crypto-policies button.pf-v6-c-button.pf-m-link")
        if self.supportsFIPS:
            # fix the FIPS policy
            b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected .pf-v6-c-label.pf-m-orange", "inconsistent")
        else:
            # pick any valid non-FIPS policy
            b.click("#crypto-policy-dialog .pf-v6-c-menu__list-item[data-value='DEFAULT'] button")
        b.click("#crypto-policy-save-reboot")
        # Initramfs re-generation takes a while
        self.wait_reboot(timeout_sec=600)
        m.start_cockpit()
        self.login_and_go("/system")
        if self.supportsFIPS:
            b.wait_text("#crypto-policy-button", "FIPS")
            self.assertEqual(m.execute("cat /proc/sys/crypto/fips_enabled").strip(), "1")
        else:
            b.wait_text("#crypto-policy-button", "Default")
            # rest of the test does not apply any more
            return

        m.execute(cmd + " --set DEFAULT")
        b.wait_text("#inconsistent_crypto_policy", "Cryptographic policy is inconsistent")
        m.execute(cmd + " --set FIPS:OSPP")
        b.wait_text("#crypto-policy-button", "FIPS:OSPP")
        b.wait_not_present("#inconsistent_crypto_policy")

        # Setting via dialog
        m.execute(cmd + " --set DEFAULT")
        b.wait_text("#inconsistent_crypto_policy", "Cryptographic policy is inconsistent")
        b.click(".system-health-crypto-policies button.pf-v6-c-button.pf-m-link")
        b.wait_in_text(".pf-v6-c-menu__item.pf-m-selected .pf-v6-c-label.pf-m-orange", "inconsistent")
        b.click("#crypto-policy-save-reboot")
        # Initramfs re-generation takes a while
        self.wait_reboot(timeout_sec=600)
        m.start_cockpit()
        self.login_and_go("/system")
        b.wait_text("#crypto-policy-button", "Default")
        self.assertEqual(m.execute("cat /proc/sys/crypto/fips_enabled").strip(), "0")


class TestSystemInfoTime(packagelib.PackageCase):
    def set_change_time_dialog_mode(self, mode: str) -> None:
        b = self.browser
        b.click("#change_systime_btn")
        b.click(f"#change_systime button:contains('{mode}')")
        b.wait_in_text("#system_information_change_systime #change_systime_btn", mode)

    def testTime(self):
        m = self.machine
        b = self.browser

        def ntp_enabled():
            return 'true' in m.execute(
                'busctl get-property org.freedesktop.timedate1 /org/freedesktop/timedate1 org.freedesktop.timedate1 NTP')

        # make sure system is on expected timezone EEST
        m.execute("timedatectl set-timezone Europe/Helsinki")

        # Something gets confused when systemd-timesyncd isn't
        # available.  This is harmless.
        #
        self.allow_journal_messages(
            "org.freedesktop.systemd1: couldn't get property org.freedesktop.systemd1.Service ExecMain "
            "at /org/freedesktop/systemd1/unit/systemd_2dtimedated_2eservice: "
            "GDBus.Error:org.freedesktop.DBus.Error.UnknownProperty.*")
        # journal gets confused with time jumps
        self.allow_journal_messages(r"Journal file .*\.journal corrupted, ignoring file.*")

        self.login_and_go("/system", superuser=False)
        b.wait_text_not("#system_information_systime_button", "")
        b.wait_visible('#system_information_systime_button[disabled]')
        # Gain admin access
        b.become_superuser()

        # Change the date
        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        self.set_change_time_dialog_mode("Manually")
        b.set_input_text("#systime-date-input input", "2037-01-24")
        # invalid time
        b.set_input_text("#systime-time-input-input", "25:61")
        b.click("#system_information_change_systime .apply")
        b.wait_text("#systime-manual-row .dialog-error", "Invalid time format")
        # valid time
        b.set_input_text("#systime-time-input-input", "08:03")
        # wait until icon settles down
        b.wait_visible("#systime-time-input-input[aria-invalid='false']")
        b.wait_not_present("#systime-manual-row .dialog-error")
        b.assert_pixels("#system_information_change_systime", "systime-manual-time")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(60):
            b.wait_not_present("#system_information_change_systime")

        b.wait_text("#system_information_systime_button", "Jan 24, 2037, 8:03 AM")

        self.assertFalse(ntp_enabled())
        self.assertIn("Sat Jan 24 08:03:", m.execute("date"))
        self.assertIn("EET 2037\n", m.execute("date"))

        # Set to NTP
        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        self.set_change_time_dialog_mode("Automatically using NTP")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(60):
            b.wait_not_present("#system_information_change_systime")

        testlib.wait(ntp_enabled)

        # Change the date
        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        self.set_change_time_dialog_mode("Manually")
        b.set_input_text("#systime-date-input input", "2050-06-04")
        b.set_input_text("#systime-time-input-input", "06:34")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(120):  # Changing time on Arch can be slow
            b.wait_not_present("#system_information_change_systime")

        self.assertFalse(ntp_enabled())
        self.assertIn("Sat Jun  4 06:34:", m.execute("date"))
        self.assertIn("EEST 2050\n", m.execute("date"))

    @testlib.skipImage("timesyncd not available", "rhel*", "centos-*")
    @testlib.skipImage("TODO: fix for OpenSUSE", "opensuse-tumbleweed")
    def testTimeServersTimesyncd(self):
        m = self.machine
        b = self.browser

        # run this test with a tight umask to check proper file permissions (timesyncd runs as non-root)
        self.sed_file(r"/^UMASK/ s/0../077/", "/etc/login.defs")

        if m.image.startswith("debian") or m.image.startswith("ubuntu") or m.image == "arch":
            if m.execute("type chronyc || true").strip() != "":
                # chronyd is default, install timesyncd
                self.addPackageSet("timesyncd")
                self.enableRepo()
                m.execute("apt-get update; apt-get install -y systemd-timesyncd")
                m.execute("systemctl restart systemd-timedated; timedatectl set-ntp off; timedatectl set-ntp on")
            else:
                # timesyncd is default
                pass
        else:
            # chronyd is default, give priority to timesyncd
            self.write_file("/etc/systemd/ntp-units.d/10-test.list", "systemd-timesyncd.service")

        conf = "/etc/systemd/timesyncd.conf.d/50-cockpit.conf"

        self.login_and_go("/system")

        # Wait until everything is ready to go...
        b.wait_attr("#system_information_systime_button", "data-timedated-initialized", "true")

        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")

        def get_timesyncd_start():
            return int(m.execute("systemctl show -p ExecMainStartTimestampMonotonic --value systemd-timesyncd").strip())

        prev_timesyncd_start = get_timesyncd_start()

        # Add two NTP servers.
        self.set_change_time_dialog_mode("Automatically using specific NTP servers")
        b.set_input_text("#systime-ntp-servers div:nth-child(1) input", "0.ntp.example.com")
        b.click('#systime-ntp-servers div:nth-child(1) button')
        b.set_input_text("#systime-ntp-servers div:nth-child(2) input", "1.ntp.example.com")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(120):  # Changing time on Arch can be slow
            b.wait_not_present("#system_information_change_systime")

        self.assertIn("0.ntp.example.com", m.execute(f"grep '^NTP=' {conf}"))
        self.assertIn("1.ntp.example.com", m.execute(f"grep '^NTP=' {conf}"))
        # Cockpit created the file with the correct permissions
        self.assertEqual(m.execute(f"stat --format '%a' {conf}").strip(), "644")

        # restarts timesyncd to pick up the new config
        testlib.wait(lambda: get_timesyncd_start() > prev_timesyncd_start, delay=0.2)
        prev_timesyncd_start = get_timesyncd_start()

        # not predictable which of 0 or 1 timesyncd picks, but it should be one of those
        self.assertIn(".ntp.example.com", m.execute("timedatectl timesync-status"))

        # Set conf from the outside, check that we pick that up, and
        # switch to default servers.
        m.write(conf, "[Time]\nNTP=2.ntp.example.com\n")
        b.wait_attr("#system_information_systime_button", "data-timedated-initialized", "true")
        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        b.wait_val("#systime-ntp-servers div:nth-child(1) input", "2.ntp.example.com")
        self.set_change_time_dialog_mode("Automatically using NTP")
        b.wait_not_present("#systime-ntp-servers")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(120):  # Changing time on Arch can be slow
            b.wait_not_present("#system_information_change_systime")

        self.assertIn("2.ntp.example.com", m.execute(f"grep '^#NTP=' {conf}"))

        # restarts timesyncd to pick up the new config
        testlib.wait(lambda: get_timesyncd_start() > prev_timesyncd_start, delay=0.2)

        self.assertNotIn("example.com", m.execute("timedatectl timesync-status"))

    @testlib.skipImage("chronyd not available", "arch")
    def testTimeServersChronyd(self):
        m = self.machine
        b = self.browser

        enabled_conf = "/etc/chrony/sources.d/cockpit.sources"
        disabled_conf = "/etc/chrony/sources.d/cockpit.disabled"

        if m.image.startswith("debian") or m.image.startswith("ubuntu"):
            # timesyncd is default, install chronyd
            self.addPackageSet("chronyd")
            self.enableRepo()
            m.execute("apt-get update; apt-get install -y chrony")
            m.execute("systemctl restart systemd-timedated; timedatectl set-ntp off; timedatectl set-ntp on")
        else:
            # chronyd is default
            pass

        self.login_and_go("/system")

        # Wait until everything is ready to go...
        b.wait_attr("#system_information_systime_button", "data-timedated-initialized", "true")

        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")

        def get_chronyd_start():
            return int(m.execute("systemctl show -p ExecMainStartTimestampMonotonic --value chronyd").strip())

        prev_chronyd_start = get_chronyd_start()

        # Add two NTP servers.
        self.set_change_time_dialog_mode("Automatically using additional NTP servers")
        b.set_input_text("#systime-ntp-servers div:nth-child(1) input", "0.ntp.example.com")
        b.click('#systime-ntp-servers div:nth-child(1) button')
        b.set_input_text("#systime-ntp-servers div:nth-child(2) input", "1.ntp.example.com")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(60):
            b.wait_not_present("#system_information_change_systime")

        m.execute(f"grep 0.ntp.example.com {enabled_conf}")
        m.execute(f"grep 1.ntp.example.com {enabled_conf}")
        m.execute(f"! test -f {disabled_conf}")

        # restarts chronyd to pick up the new config
        testlib.wait(lambda: get_chronyd_start() > prev_chronyd_start, delay=0.2)
        prev_chronyd_start = get_chronyd_start()

        # Set conf from the outside, check that we pick that up, and
        # switch to default servers.
        m.write(enabled_conf, "server 2.ntp.example.com\n")
        b.wait_attr("#system_information_systime_button", "data-timedated-initialized", "true")
        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        b.wait_val("#systime-ntp-servers div:nth-child(1) input", "2.ntp.example.com")
        self.set_change_time_dialog_mode("Automatically using NTP")
        b.wait_not_present("#systime-ntp-servers")
        b.click("#system_information_change_systime .apply")
        with b.wait_timeout(60):
            b.wait_not_present("#system_information_change_systime")

        m.execute(f"! test -f {enabled_conf}")
        m.execute(f"grep 2.ntp.example.com {disabled_conf}")

        # restarts timesyncd to pick up the new config
        testlib.wait(lambda: get_chronyd_start() > prev_chronyd_start, delay=0.2)

    def testTimeServersUnsupported(self):
        m = self.machine
        b = self.browser

        m.execute("! systemctl is-active chronyd || systemctl stop chronyd")
        m.execute("! systemctl is-active systemd-timesyncd || systemctl stop systemd-timesyncd")
        m.execute("systemctl mask chronyd.service || systemctl mask chrony.service")
        m.execute("systemctl mask systemd-timesyncd.service")

        self.login_and_go("/system")

        # Wait until everything is ready to go...
        b.wait_attr("#system_information_systime_button", "data-timedated-initialized", "true")

        b.click("#system_information_systime_button")
        b.wait_visible("#system_information_change_systime")
        b.click("#system_information_change_systime #change_systime_btn")
        b.wait_in_text("#change_systime", "Automatically using NTP")
        b.wait_not_present("#change_systime button:contains('Automatically using specific NTP servers')")
        b.wait_not_present("#change_systime button:contains('Automatically using additional NTP servers')")


class TestSystemInfoSubscribed(submanlib.SubscriptionCase):
    provision = {
        "0": {"address": "10.111.112.1/20", "dns": "10.111.112.1"},
        "services": {"image": "services"}
    }

    @testlib.onlyImage("insights-client is only on RHEL", "rhel*")
    @testlib.skipBeiboot("no local overrides/config in beiboot mode")
    def testInsightsStatus(self):
        m = self.machine
        b = self.browser

        # insights-client requires a valid subscription
        self.setup_candlepin_service(self.machines['services'])
        self.register_with_candlepin()

        # insights-client might get started during boot and might then
        # run concurrently with our explicit "insights-client
        # --register" below.  insights-client is not designed to be
        # run concurrently and there is no protection against it,
        # apparently.  So let's prevent that.
        m.execute("systemctl disable --now insights-client")

        # Pretend that the Subscriptions page can do Insights stuff
        self.write_file("/etc/cockpit/subscription-manager.override.json", '{ "features": { "insights": true } }')

        # Run a mock version of the Insights API locally and configure
        # insights-client to access it. That requires a good enough
        # TLS mock insights server certificate
        m.upload(["verify/files/mock-insights", "../src/tls/ca/alice.key", "../src/tls/ca/alice.pem"], self.vm_tmpdir)
        pid = m.spawn(f"{self.vm_tmpdir}/mock-insights", "mock-insights")
        self.addCleanup(m.execute, f"kill {pid}")
        m.execute("while ! ss -tulpn | grep 8443; do sleep 1; done")

        hostname = m.execute("hostname").rstrip()
        self.write_file("/etc/insights-client/insights-client.conf", f"""
[insights-client]
auto_config=False
auto_update=False
base_url={hostname}:8443/r/insights
cert_verify=/var/lib/insights/mock-certs/ca.crt
username=admin
password=foobar
""")

        # Initially we are not registered
        self.login_and_go('/system')
        b.wait_text(".system-health-insights a", "Not connected to Insights")

        # Enable insights, results should appear automatically
        m.execute("insights-client --register", timeout=180)
        self.addCleanup(m.execute, "insights-client --unregister")
        with b.wait_timeout(60):
            b.wait_in_text(".system-health-insights a", "Connected to Insights")

        b.logout()


if __name__ == '__main__':
    testlib.test_main()
