#!/usr/bin/python3

# This file is part of Cockpit.
#
# Copyright (C) 2017 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 os
import time

import parent
from packagelib import PackageCase
from testlib import *


WAIT_SCRIPT = """
set -ex
for x in $(seq 1 200); do
    if curl --insecure -s https://%(addr)s:8443/candlepin; then
        break
    else
        sleep 1
    fi
done
"""


class NoSubManCase(PackageCase):

    def setUp(self):
        super().setUp()

        # Disable Subscription Manager on RHEL for these tests; subscriptions are tested in a separate class
        # On other OSes (Fedora/CentOS) we expect sub-man to be disabled in yum, so it should not get in the way there
        if self.machine.image.startswith("rhel") or self.machine.image.startswith("centos-8"):
            self.machine.execute("mv /usr/libexec/rhsm-service /usr/libexec/rhsm-service.disabled; pkill -e rhsm-service || true")
            self.addCleanup(self.machine.execute, "mv /usr/libexec/rhsm-service.disabled /usr/libexec/rhsm-service")

        # expected journal messages from enabling/disabling auto upgrade services
        self.allow_journal_messages("(Created symlink|Removed).*dnf-automatic-install.timer.*")


@skipImage("Image uses OSTree", "fedora-coreos")
@nondestructive
class TestUpdates(NoSubManCase):

    def setUp(self):
        super().setUp()

        # only the yum backend properly recognizes "enhancement" severity; apt
        # does not have that metadata and PackageKit-dnf does not parse it
        if self.backend == "yum":
            self.enhancement_severity = "enhancement"
        else:
            self.enhancement_severity = "bug"

        self.update_icon = "#page_status_notification_updates span:first-child"
        self.update_text = "#page_status_notification_updates"
        self.update_text_action = "#page_status_notification_updates a"

    def check_nth_update(self, index, pkgname, version, severity="bug",
                         num_issues=None, desc_matches=[], cves=[], bugs=[], arch=None):
        """Check the contents of the package update table row at index

        None properties will not be tested.
        """
        b = self.browser
        if arch is None:
            arch = self.primary_arch
        row = "#app .listing-ct tbody:nth-of-type(%i) " % index
        severity_to_icon = {"bug": "fa-bug", "enhancement": "pficon-enhancement", "security": "pficon-security"}

        if isinstance(pkgname, list):
            for idx, pkg in enumerate(pkgname, 1):
                self.assertEqual(b.text(row + "th > span:nth-of-type(%i)" % idx), pkg)
        else:
            self.assertEqual(b.text(row + "th span"), pkgname)
            b.mouse(row + "th span", "mouseover")
            b.wait_text("div.tooltip", "dummy " + pkgname + " (" + arch + ")")
            b.mouse(row + "th span", "mouseout")
            b.wait_not_present("div.tooltip")
        self.assertEqual(b.text(row + "td:nth-of-type(2)"), version)
        # verify type
        self.assertTrue(b.is_present(row + "td:nth-of-type(3) span." + severity_to_icon[severity]))
        self.assertEqual(b.text(row + "td:nth-of-type(3)").strip(),
                         num_issues is not None and str(num_issues) or "")

        # should not be expanded by default
        self.assertFalse(b.is_present(row + ".listing-ct-body"))
        # expand
        b.click(row + "td.listing-ct-toggle")
        b.wait_in_text(row + ".listing-ct-body", "Packages:")
        desc = b.text(row + ".listing-ct-body")

        # details should contain all description bits, CVEs and bug numbers
        for m in desc_matches + cves + bugs:
            self.assertIn(m, desc)

        return row

    def wait_checking_updates(self):
        '''Wait until spinner is gone from updates icon for 3 s'''

        good_count = 0
        for retry in range(60):
            classes = self.browser.attr(self.update_icon, "class")
            if classes is None or "spinner" in classes:
                good_count = 0
            else:
                good_count += 1
                if good_count >= 3:
                    return
            time.sleep(1)
        else:
            self.fail("Timed out waiting for updates spinner to go away")

    def testBasic(self):
        # no security updates, no changelogs
        b = self.browser
        m = self.machine

        self.enable_preload("packagekit", "index")

        m.start_cockpit()
        b.login_and_go("/system")
        # status on /system front page: no repos at all, thus no updates
        self.wait_checking_updates()
        b.wait_text(self.update_text, "System is up to date")
        self.assertEqual(b.attr(self.update_icon, "class"), "fa fa-check-circle-o")

        # no updates on the Software Updates page
        b.go("/updates")
        b.enter_page("/updates")
        b.wait_present(".content-header-extra button")
        b.wait_in_text("#state", "No updates pending")
        # PK starts from a blank state, thus should force refresh and set the "time since" to 0
        b.wait_text(".content-header-extra--updated", "Last checked: a few seconds ago")
        # empty state visible in main area
        b.wait_present(".container-fluid .pf-c-empty-state")

        install_lockfile = "/tmp/finish-pk"
        # create two updates; force installing chocolate before vanilla
        self.createPackage("vanilla", "1.0", "1", install=True)
        self.createPackage("vanilla", "1.0", "2", depends="chocolate",
                           postinst="while [ ! -e {0} ]; do sleep 1; done; rm -f {0}".format(install_lockfile))
        self.createPackage("chocolate", "2.0", "1", install=True, arch=self.secondary_arch)
        self.createPackage("chocolate", "2.0", "2", arch=self.secondary_arch)
        self.enableRepo()

        # check again
        b.wait_in_text(".content-header-extra button", "Check for Updates")
        b.click(".content-header-extra button")

        b.wait_text(".content-header-extra--updated", "Last checked: a few seconds ago")

        b.wait_in_text("#available h2", "Available Updates")
        self.assertEqual(b.text("#state"), "2 updates")

        b.wait_in_text("table.available", "vanilla")
        self.check_nth_update(1, "chocolate", "2.0-2", arch=self.secondary_arch)
        self.check_nth_update(2, "vanilla", "1.0-2")

        # updates are shown on system page
        b.go("/system")
        b.enter_page("/system")
        self.wait_checking_updates()
        b.wait_text(self.update_text, "Bug Fix Updates Available")
        self.assertIn("fa-bug", b.attr(self.update_icon, "class"))
        # should be a link, click on it to go to /updates
        b.click(self.update_text_action)
        b.enter_page("/updates")

        # old versions are still installed
        m.execute("test -f /stamp-vanilla-1.0-1 && test -f /stamp-chocolate-2.0-1")

        # no update history yet
        self.assertFalse(b.is_present("table.updates-history"))

        # should only have one button (no security updates)
        self.assertEqual(b.text("#app .container-fluid button"), "Install All Updates")
        b.click("#app .container-fluid button")

        b.wait_in_text("#state", "Applying updates")
        b.wait_present("#app div.progress-bar")

        # no refresh button or "last checked", but Cancel button
        b.wait_text(".content-header-extra--updated", "")
        b.wait_in_text(".content-header-extra button", "Cancel")

        # Cancel button should eventually get disabled
        b.wait_present(".content-header-extra button:disabled")

        # update log only exists in the expander, collapsed by default
        self.assertFalse(b.is_present("#update-log"))
        # expand it
        b.click(".expander-title button")
        # should eventually show chocolate when vanilla starts installing
        b.wait_in_text("#update-log", "chocolate")

        # finish the package installation
        m.execute("touch {0}".format(install_lockfile))

        # update should succeed and show restart page; cancel
        b.wait_in_text("#app .container-fluid h1", "Restart Recommended")
        self.assertEqual(b.text("#app .container-fluid button.pf-m-primary"), "Restart Now")
        b.click("#app .container-fluid button.pf-m-link:contains('Ignore')")

        # should go back to updates overview, nothing pending any more
        b.wait_in_text("#state", "No updates pending")
        b.wait_in_text(".content-header-extra--updated", "Last checked:")

        # empty state visible in main area
        b.wait_present(".container-fluid .pf-c-empty-state")

        # new versions are now installed
        m.execute("test -f /stamp-vanilla-1.0-2 && test -f /stamp-chocolate-2.0-2")

        # history shows the two packages, expanded by default
        b.wait_text("table.updates-history tbody.open td.history-pkgcount", "2 Packages")
        b.wait_in_text("table.updates-history tbody.open .listing-ct-panel", "chocolate")
        b.wait_in_text("table.updates-history tbody.open .listing-ct-panel", "vanilla")

        # system page has current state as well
        b.go("/system")
        b.enter_page("/system")
        self.wait_checking_updates()
        self.assertEqual(b.attr(self.update_icon, "class"), "fa fa-check-circle-o")
        b.wait_text(self.update_text, "System is up to date")

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

        # just changelog
        self.createPackage("norefs-bin", "1", "1", install=True)
        self.createPackage("norefs-bin", "2", "1", severity="enhancement",
                           changes="Now 10% *more* [unicorns](http://unicorn.example.com)")
        # binary from same source
        self.createPackage("norefs-doc", "1", "1", install=True)
        self.createPackage("norefs-doc", "2", "1", severity="enhancement",
                           changes="Now 10% *more* [unicorns](http://unicorn.example.com)")
        # bug fixes
        self.createPackage("buggy", "2", "1", install=True)
        self.createPackage("buggy", "2", "2", changes="* Fixit", bugs=[123, 456])
        # security fix with proper CVE list and severity
        self.createPackage("secdeclare", "3", "4.a1", install=True)
        self.createPackage("secdeclare", "3", "4.b1", severity="security",
                           changes="Will crash your data center", cves=["CVE-2014-123456"])
        # security fix with parsing from changes
        self.createPackage("secparse", "4", "1", install=True)
        self.createPackage("secparse", "4", "2", changes="Fix CVE-2014-54321 and CVE-2017-9999.")
        # security fix with RHEL severity and errata
        self.createPackage("sevcritical", "5", "1", install=True)
        self.createPackage("sevcritical", "5", "2", cves=["CVE-2014-54321"], securitySeverity="critical",
                           errata=["RHSA-2000:0001", "RHSA-2000:0002"], changes="More broken stuff")

        self.enableRepo()
        m.execute("pkcon refresh")

        m.start_cockpit()
        b.login_and_go("/updates")
        b.wait_in_text("#available h2", "Available Updates")
        self.assertEqual(b.text("#state"), "6 updates, including 3 security fixes")

        b.wait_in_text("table.available", "sevcritical")

        # security updates should get sorted on top and then alphabetically, so start with "secdeclare"
        sel = self.check_nth_update(1, "secdeclare", "3-4.b1", "security", 1,
                                    desc_matches=["Will crash your data center"], cves=["CVE-2014-123456"])
        # should not have erratum label in details
        self.assertNotIn("Errat", b.text(sel))

        # secparse should also be considered a security update as the changelog mentions CVEs
        self.check_nth_update(2, "secparse", "4-2", "security", 2,
                              desc_matches=["Fix CVE-2014-54321 and CVE-2017-9999."],
                              cves=["CVE-2014-54321", "CVE-2017-9999"])
        sel = self.check_nth_update(3, "sevcritical", "5-2", "security", 1,
                                    desc_matches=["More broken stuff"])

        if self.backend == 'yum':
            # sevcritical has a severity and errata
            details = b.text(sel + " .listing-ct-body")
            self.assertIn("Severity:", details)
            self.assertIn("critical", details)
            self.assertIn("Errata:", details)
            self.assertIn("RHSA-2000:0001", details)
            self.assertIn("RHSA-2000:0002", details)
            # icon has critical class
            self.assertIn("severity-critical", b.attr(sel + " td.type span", "class"))
            b.mouse(sel + " td.type span", "mouseover")
            b.wait_text("div.tooltip", "critical")
            b.mouse(sel + " td.type span", "mouseout")
            b.wait_not_present("div.tooltip")
            # details has link to severity definition
            self.assertIn("access.redhat.com", b.attr(sel + " .listing-ct-body dd.severity a:first-of-type", "href"))

        # buggy: bug refs, no security
        sel = self.check_nth_update(4, "buggy", "2-2", "bug", 2, bugs=["123", "456"], desc_matches=["Fixit"])
        # should filter out enumeration in overview
        ch = b.eval_js('document.querySelector("%s").innerHTML' % (sel + " td.changelog"))
        self.assertNotIn("<li>", ch)
        self.assertNotIn("*", ch)
        # should show bug fix icon and tooltip
        self.assertIn("fa-bug", b.attr(sel + " td.type span", "class"))
        b.mouse(sel + " td.type span", "mouseover")
        b.wait_text("div.tooltip", "bug fix")
        b.mouse(sel + " td.type span", "mouseout")
        b.wait_not_present("div.tooltip")

        # norefs: just changelog, show both binary packages
        sel = self.check_nth_update(5, ["norefs-bin", "norefs-doc"], "2-1", self.enhancement_severity,
                                    desc_matches=["Now 10% more unicorns"])
        # verify Markdown formatting in table cell
        self.assertEqual(b.text(sel + " td.changelog em"), "more")  # *more*
        self.assertEqual(b.attr(sel + " td.changelog a", "href"), "http://unicorn.example.com")
        # verify Markdown formatting in details
        self.assertEqual(b.text(sel + " .listing-ct-body em"), "more")  # *more*
        self.assertEqual(b.attr(sel + " .listing-ct-body a:first-of-type", "href"), "http://unicorn.example.com")

        # updates are shown on system page
        b.go("/system")
        b.enter_page("/system")
        self.wait_checking_updates()
        b.wait_text(self.update_text, "Security Updates Available")
        self.assertIn("security", b.attr(self.update_icon, "class"))

        # should be a link, click on it to go to back to /updates
        b.click(self.update_text_action)
        b.enter_page("/updates")

        # install only security updates
        self.assertEqual(b.text("#app .container-fluid button.pf-m-secondary"), "Install Security Updates")
        b.click("#app .container-fluid button.pf-m-secondary")

        b.wait_in_text("#state", "Applying updates")
        b.wait_present("#app div.progress-bar")

        # should have succeeded and show restart page
        b.wait_in_text("#app .container-fluid h1", "Restart Recommended")

        def assertHistory(path, updates):
            selector = path + " li:nth-child({0})"
            for index, pkg in enumerate(updates, start=1):
                b.wait_in_text(selector.format(index), pkg)
            # make sure we don't have any extra ones
            self.assertFalse(b.is_present(selector.format(len(updates) + 1)))

        # history on restart page should show the three security updates
        b.click(".expander-title button")
        assertHistory("ul", ["secdeclare", "secparse", "sevcritical"])

        # ignore restarting
        b.click("#app .container-fluid button.pf-m-link:contains('Ignore')")

        # should have succeeded; 3 non-security updates left
        b.wait_in_text("#state", "3 updates")
        b.wait_in_text(".container-fluid #available h2", "Available Updates")
        b.wait_in_text("table.available", "norefs-doc")
        self.assertIn("buggy", b.text("table.available"))
        self.assertNotIn("secdeclare", b.text("table.available"))
        self.assertNotIn("secparse", b.text("table.available"))

        # history should show the security updates
        assertHistory("table.updates-history ul", ["secdeclare", "secparse", "sevcritical"])

        # stop PackageKit (e. g. idle timeout) to make sure the page survives that
        m.execute("systemctl kill --signal=KILL packagekit.service")

        # new security versions are now installed
        m.execute("test -f /stamp-secdeclare-3-4.b1 && test -f /stamp-secparse-4-2 && test -f /stamp-sevcritical-5-2")
        # but the three others are untouched
        m.execute("test -f /stamp-buggy-2-1 && test -f /stamp-norefs-bin-1-1 && test -f /stamp-norefs-doc-1-1")

        # should now only have one button (no security updates left)
        self.assertEqual(b.text("#app .container-fluid button"), "Install All Updates")
        b.click("#app .container-fluid button")

        b.wait_in_text("#state", "Applying updates")
        b.wait_present("#app div.progress-bar")

        # should have succeeded and show restart
        b.wait_in_text("#app .container-fluid h1", "Restart Recommended")
        b.wait_present("#app .container-fluid button.pf-m-link:Contains('Ignore')")

        # new versions are now installed
        m.execute("test -f /stamp-norefs-bin-2-1 && test -f /stamp-norefs-doc-2-1")

        # history on restart page should show the three non-security updates
        b.click(".expander-title button")
        assertHistory("ul", ["buggy", "norefs-bin", "norefs-doc"])

        # do the reboot; this will disconnect the web UI
        b.click("#app .container-fluid button.pf-m-primary")
        b.switch_to_top()
        b.wait_in_text(".curtains-ct h1", "Disconnected")

        # ensure that rebooting actually worked
        m.wait_reboot()
        m.start_cockpit()
        b.reload()
        b.login_and_go("/updates")

        # no further updates
        b.wait_in_text("#state", "No updates pending")
        # empty state visible in main area
        b.wait_present(".container-fluid .pf-c-empty-state")

        # history on "up to date" page should show the recent update (expanded by default)
        assertHistory("table.updates-history tbody.open ul", ["buggy", "norefs-bin", "norefs-doc"])
        # and the previous one, not expaned
        b.wait_present("table.updates-history tbody:not(.open)")

        self.allow_restart_journal_messages()

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

        # security fix with proper CVE list and severity
        self.createPackage("secdeclare", "3", "4.a1", install=True)
        self.createPackage("secdeclare", "3", "4.b1", severity="security",
                           changes="Will crash your data center", cves=['CVE-2014-123456'])

        self.enableRepo()
        m.execute("pkcon refresh")

        m.start_cockpit()
        b.login_and_go("/updates")
        b.wait_in_text("#available h2", "Available Updates")
        self.assertEqual(b.text("#state"), "1 security fix")

        # should only have one button (only security updates)
        self.assertEqual(b.text("#app .container-fluid button"), "Install Security Updates")

        # security fix without CVE URLs
        # PackageKit's dnf backend does not recognize this (https://bugs.freedesktop.org/show_bug.cgi?id=101070)
        # and PackageKit's deb backend does not know about severity at all, so only test this on RPM
        if self.backend == "yum":
            self.createPackage("secnocve", "1", "1", install=True)
            self.createPackage("secnocve", "1", "2", severity="security", changes="Fix leak")
            self.enableRepo()
            # check for updates
            b.wait_in_text(".content-header-extra button", "Check for Updates")
            b.click(".content-header-extra button")
            b.wait_in_text("#available h2", "Available Updates")
            b.wait_in_text("#state", "2")

            b.wait_in_text("table.available", "secnocve")

            # secnocve should be displayed properly
            self.check_nth_update(2, "secnocve", "1-2", "security", desc_matches=["Fix leak"])

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

        # update with not too many binary packages
        for i in range(4):
            self.createPackage("coarse{:02}".format(i), "1", "1", install=True)
            self.createPackage("coarse{:02}".format(i), "1", "2", changes="make it greener")

        # update with lots of binary packages
        for i in range(10):
            self.createPackage("fine{:02}".format(i), "1", "1", install=True)
            self.createPackage("fine{:02}".format(i), "1", "2", changes="make it better")

        # update with long changelog
        long_changelog = ""
        for i in range(30):
            long_changelog += " - Things change #{:02}\n".format(i)
        self.createPackage("verbose", "1", "1", install=True)
        self.createPackage("verbose", "1", "2", changes=long_changelog)

        self.enableRepo()

        m.start_cockpit()
        b.login_and_go("/updates")

        b.wait_in_text("table.available", "Things change")

        # "coarse" package list should be complete
        t = b.text("#app .listing-ct tbody:nth-of-type(1) th")
        self.assertIn("coarse00", t)
        self.assertIn("coarse03", t)
        self.assertNotIn(u"…", t)

        # "fine" package list should be truncated
        t = b.text("#app .listing-ct tbody:nth-of-type(2) th")
        self.assertIn("fine00", t)
        self.assertIn("fine03", t)
        self.assertNotIn("fine09", t)
        self.assertIn(u"…", t)
        # but complete in the details
        self.check_nth_update(2, ["fine00", "fine01", "fine02", "fine03"], "1-2",
                              desc_matches=["fine07", "fine09"])

        # verbose changelog should be truncated
        desc = b.text("#app .listing-ct tbody:nth-of-type(3) td:nth-of-type(4)")
        self.assertIn("Things change #00", desc)
        self.assertNotIn("#01", desc)
        # but complete in the details
        self.check_nth_update(3, "verbose", "1-2",
                              desc_matches=["Things change #00", "Things change #29"])
        # seems we can't verify that the description has a scrollbar

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

        self.createPackage("vapor", "1", "1", install=True)
        self.createPackage("vapor", "1", "2")

        self.enableRepo()
        m.execute("pkcon refresh")

        # break the upgrade by removing the generated packages from the repo
        m.execute("rm -f {0}/vapor*.deb {0}/vapor*.rpm".format(self.repo_dir))

        m.start_cockpit()
        b.login_and_go("/updates")
        b.wait_in_text(".container-fluid h2", "Available Updates")
        self.assertEqual(b.text("#state"), "1 update")

        b.click("#app .container-fluid button")

        b.wait_in_text("#state", "Applying updates failed")

        self.assertRegex(b.text("#app .container-fluid pre:first-of-type"),
                         "missing|downloading|not.*available|No such file or directory")

        # not expecting any buttons
        self.assertFalse(b.is_present("#app button"))

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

        # this tends to corrupt the rpm database, so do a backup/restore
        if self.backend == 'dnf':
            self.restore_dir("/var/lib/rpm")

        # make sure we have enough time to crash PK
        self.createPackage("slow", "1", "1", install=True)
        # we don't want this installation to finish
        self.createPackage("slow", "1", "2", postinst="sleep infinity")
        self.enableRepo()
        m.execute("pkcon refresh")

        m.start_cockpit()
        b.login_and_go("/updates")

        b.click("#app .container-fluid button")

        # let updates start and zap PackageKit
        b.wait_present("#app div.progress-bar")
        m.execute("systemctl kill --signal=SEGV packagekit.service")

        b.wait_in_text("#state", "Applying updates failed")
        b.wait_in_text("#app .container-fluid", "PackageKit crashed")

        self.allow_journal_messages(".*org.freedesktop.PackageKit.*Error.NoReply.*")
        self.allow_journal_messages("Process .* dumped core.*")
        self.allow_journal_messages("Stack trace of thread.*")
        self.allow_journal_messages("#[0-9].*")

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

        system_service = m.execute("systemctl show -p FragmentPath packagekit.service | cut -f2 -d=").strip()
        m.execute('''mv {0} {0}.disabled
                     mv /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service.disabled
                     systemctl daemon-reload'''.format(system_service))
        self.addCleanup(m.execute,
                        '''mv {0}.disabled {0}
                           mv /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service.disabled /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service
                           systemctl daemon-reload'''.format(system_service))

        m.start_cockpit()
        b.login_and_go("/updates")

        b.wait_in_text("#state", "Loading available updates failed")
        b.wait_in_text("#app pre", "PackageKit is not installed")

        # update status on front page should be invisible
        b.go("/system")
        b.enter_page("/system")
        b.wait_text(self.update_text, "Loading available updates failed")
        self.assertIn("exclamation", b.attr(self.update_icon, "class"))

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

        self.createPackage("vanilla", "1.0", "1", install=True)
        self.createPackage("vanilla", "2.0", "2")
        self.enableRepo()
        m.execute("pkcon refresh")

        # getting update info is allowed to all users
        self.login_and_go("/updates", superuser=False)
        b.wait_text("#state", "1 update")
        b.wait_in_text("#available h2", "Available Updates")

        # but applying updates is not; FIXME: this is a crappy UX
        b.click("#app .container-fluid button")
        b.wait_text("#state", "Applying updates failed")
        b.wait_in_text(".container-fluid pre", "authentication")

        # become superuser
        b.switch_to_top()
        b.click("#super-user-indicator button")
        b.wait_in_text(".modal-dialog:contains('Administrative access')", "Please authenticate")
        b.set_input_text(".modal-dialog:contains('Administrative access') input", "foobar")
        b.click(".modal-dialog button:contains('Authenticate')")
        b.wait_not_present(".modal-dialog:contains('Administrative access')")
        b.wait_text("#super-user-indicator", "Administrative access")

        # page adjusts automatically to privilege change
        b.switch_to_frame("cockpit1:localhost/updates")
        b.wait_text("#state", "1 update")
        b.wait_in_text("#available h2", "Available Updates")

        # applying updates works now
        b.click("#app .container-fluid button")

        b.wait_in_text("#app .container-fluid h1", "Restart Recommended")
        self.assertEqual(b.text("#app .container-fluid button.pf-m-primary"), "Restart Now")
        b.click("#app .container-fluid button.pf-m-link:contains('Ignore')")

        # should go back to updates overview, nothing pending any more
        b.wait_in_text("#state", "No updates pending")

@skipImage("Image uses OSTree", "fedora-coreos")
class TestWsUpdate(NoSubManCase):
    def testBasic(self):
        # The main case for this is that cockpit-ws itself gets upgraded, which
        # restarts the service and terminates the connection. As we can't
        # (efficiently) build a newer working cockpit-ws package, test the two
        # parts (reconnect and warning about disconnect) separately.
        # This test is destructive and thus must run last.

        # no security updates, no changelogs
        b = self.browser
        m = self.machine

        install_lockfile = "/tmp/finish-pk"
        # updating this package takes longer than a cockpit start and building the page
        self.createPackage("slow", "1", "1", install=True)
        self.createPackage(
            "slow", "1", "2", postinst="while [ ! -e {0} ]; do sleep 1; done; rm -f {0}".format(install_lockfile))
        self.enableRepo()
        m.execute("pkcon refresh")

        m.start_cockpit()
        b.login_and_go("/updates")

        b.click("#app .container-fluid button")
        b.wait_in_text("#state", "Applying updates")
        b.wait_in_text("#app div.progress-description", "slow")

        # restarting should pick up that install progress
        m.restart_cockpit()
        b.login_and_go("/updates")
        b.wait_in_text("#state", "Applying updates")
        b.wait_present("#app div.progress-bar")

        # finish the package installation
        m.execute("touch {0}".format(install_lockfile))

        # should have succeeded and show restart page; cancel
        b.wait_in_text("#app .container-fluid h1", "Restart Recommended")
        b.click("#app .container-fluid button.pf-m-link:contains('Ignore')")
        b.wait_in_text("#state", "No updates pending")

        # now pretend that there is a newer cockpit-ws available, warn about disconnect
        self.createPackage("cockpit-ws", "999", "1")
        # these have strict version dependencies to cockpit-ws, don't get in the way
        self.createPackage("cockpit", "999", "1")
        self.createPackage("cockpit-dashboard", "999", "1")
        m.execute("if type apt; then dpkg -P cockpit-ws-dbgsym; fi")
        self.enableRepo()
        b.wait_in_text(".content-header-extra button", "Check for Updates")
        b.click(".content-header-extra button")

        b.wait_in_text(".container-fluid #available h2", "Available Updates")
        self.assertEqual(b.text("#state"), "3 updates")
        b.wait_in_text("table.available", "cockpit-ws")
        b.wait_in_text("table.available", "cockpit-dashboard")
        b.wait_in_text("#app div.alert-warning", "This web console will be updated.")

        self.allow_restart_journal_messages()


@skipImage("Image uses OSTree", "fedora-coreos")
@skipImage("No subscriptions", "debian-stable", "debian-testing", "fedora-31", "centos-8-stream",
           "fedora-32", "fedora-testing", "ubuntu-2004", "ubuntu-stable")
class TestUpdatesSubscriptions(PackageCase):
    provision = {
        "0": {"address": "10.111.112.1/20", "dns": "10.111.112.1"},
        "services": {"image": "services"}
    }

    def register(self):
        # this fails with "Unable to find available subscriptions for all your installed products", but works anyway
        self.machine.execute(
            "LC_ALL=C.UTF-8 subscription-manager register --insecure --serverurl https://10.111.112.100:8443/candlepin --org=admin --activationkey=awesome_os_pool || true")
        self.machine.execute("LC_ALL=C.UTF-8 subscription-manager attach --auto")

    def setUp(self):
        super().setUp()
        self.candlepin = self.machines['services']
        m = self.machine

        # wait for candlepin to be active and verify
        self.candlepin.execute("systemctl start tomcat")

        # remove all existing products (RHEL server), as we can't control them
        m.execute("rm -f /etc/pki/product-default/*.pem /etc/pki/product/*.pem")

        # download product info from the candlepin machine and install it
        product_file = os.path.join(self.tmpdir, "88888.pem")
        self.candlepin.download("/home/admin/candlepin/generated_certs/88888.pem", product_file)

        # # upload product info to the test machine
        m.execute("mkdir -p /etc/pki/product")
        m.upload([product_file], "/etc/pki/product")

        # make sure that rhsm skips certificate checks for the server
        self.sed_file("s/insecure = 0/insecure = 1/g", "/etc/rhsm/rhsm.conf")

        # Wait for the web service to be accessible
        m.execute(script=WAIT_SCRIPT % {"addr": "10.111.112.100"})
        self.update_icon = "#page_status_notification_updates span:first-child"
        self.update_text = "#page_status_notification_updates"
        self.update_text_action = "#page_status_notification_updates a"

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

        # fresh machine, no updates available; by default our rhel-* images are not registered
        m.start_cockpit()
        b.login_and_go("/system")
        # show unregistered status on system front page
        b.wait_in_text(self.update_text, "Not Registered")
        self.assertIn("triangle", b.attr(self.update_icon, "class"))

        # software updates page also shows unregistered
        b.go("/updates")
        b.enter_page("/updates")
        # empty state visible in main area
        b.wait_present(".container-fluid .pf-c-empty-state button")
        b.wait_in_text(".container-fluid .pf-c-empty-state", "This system is not registered")
        # no header bar
        b.wait_not_present(".content-header-extra")

        # test the button to switch to Subscriptions
        b.click(".container-fluid .pf-c-empty-state button")
        b.switch_to_top()
        b.wait_js_cond('window.location.pathname === "/subscriptions"')

        # after registration it should show the usual "system is up to date", through the "status changed" signal
        self.register()
        b.go("/updates")
        b.enter_page("/updates")
        b.wait_present(".content-header-extra button")
        b.wait_in_text("#state", "No updates pending")
        b.wait_in_text(".container-fluid .pf-c-empty-state", "up to date")

        # same on system page
        b.go("/system")
        b.enter_page("/system")
        self.assertEqual(b.attr(self.update_icon, "class"), "fa fa-check-circle-o")
        b.wait_text(self.update_text, "System is up to date")

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

        # one available update
        self.createPackage("vanilla", "1.0", "1", install=True)
        self.createPackage("vanilla", "1.0", "2")
        self.enableRepo()

        m.start_cockpit()

        b.login_and_go("/system")
        # by default our rhel-* images are not registered; show warning on system page
        b.wait_in_text(self.update_text, "Not Registered")
        self.assertIn("triangle", b.attr(self.update_icon, "class"))
        # should be a link leading to subscriptions page
        b.click(self.update_text_action)
        b.enter_page("/subscriptions")

        # software updates page also shows unregistered
        b.go("/updates")
        b.enter_page("/updates")

        # empty state visible in main area
        b.wait_present(".container-fluid .pf-c-empty-state button")
        b.wait_in_text(".container-fluid .pf-c-empty-state", "This system is not registered")

        # should show header bar
        b.wait_text("#state", "1 update")
        b.wait_text(".content-header-extra--updated", "Last checked: a few seconds ago")

        # after registration it should show available updates
        self.register()
        b.go("/updates")
        b.enter_page("/updates")
        b.wait_not_present(".container-fluid .pf-c-empty-state")
        b.wait_in_text("#available h2", "Available Updates")
        # no update history yet
        self.assertFalse(b.is_present("table.updates-history"))

        # has action buttons
        b.wait_present(".content-header-extra button")
        self.assertEqual(b.text("#app .container-fluid button"), "Install All Updates")

        # show available updates on system page too
        b.go("/system")
        b.enter_page("/system")
        b.wait_text(self.update_text, "Bug Fix Updates Available")
        self.assertIn("fa-bug", b.attr(self.update_icon, "class"))


@skipImage("Image uses OSTree", "fedora-coreos")
@nondestructive
class TestAutoUpdates(NoSubManCase):

    def setUp(self):
        super().setUp()
        # not implemented for yum and apt yet, only dnf
        self.supported_backend = self.backend in ["dnf"]
        self.addCleanup(self.machine.execute, "systemctl disable --now dnf-automatic-install.timer 2>/dev/null; rm -rf /etc/systemd/system/dnf-automatic-*")

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

        m.start_cockpit()
        b.login_and_go("/updates")

        if not self.supported_backend:
            self.assertFalse(b.is_present("#automatic"))
            self.assertFalse(b.is_present("#auto-update-type"))
            return

        def wait_pending():
            # the controls get disabled while applying the config changes is in progress; wait for both transitions
            b.wait_present("#automatic input:disabled")
            b.wait_not_present("#automatic input:disabled")
            b.wait_not_present("#automatic .disabled")

        def assertTimerDnf(hour, dow):
            out = m.execute("systemctl --no-legend list-timers dnf-automatic-install.timer")
            if hour:
                # don't test the minutes, due to RandomizedDelaySec=60m
                self.assertRegex(out, " %s:" % hour)
            else:
                self.assertEqual(out, "")
            if dow:
                self.assertRegex(out, "^%s\s" % dow)
            else:
                # "every day" should not have a "LEFT" time > 1 day
                self.assertNotIn(" day", out)

            # service should not run right away
            self.assertEqual(m.execute("systemctl is-active dnf-automatic-install.service || true").strip(), "inactive")

            # automatic reboots should be enabled whenever timer is enabled
            out = m.execute("systemctl cat dnf-automatic-install.service")
            if hour:
                self.assertRegex(out, "ExecStartPost=/.*shutdown")
            else:
                self.assertNotIn("ExecStartPost", out)

        def assertTimer(hour, dow=None):
            if self.backend == "dnf":
                assertTimerDnf(hour, dow)
            else:
                raise NotImplementedError(self.backend)

        def assertTypeDnf(_type):
            if _type == "all":
                match = '= default'
            elif _type == "security":
                match = '= security'
            else:
                raise ValueError(_type)

            self.assertIn(match, m.execute("grep upgrade_type /etc/dnf/automatic.conf"))

        def assertType(_type):
            if self.backend == "dnf":
                assertTypeDnf(_type)
            else:
                raise NotImplementedError(self.backend)

        # automatic updates are supported, but off
        b.wait_present("#automatic h2")
        b.wait_present("#automatic .onoff-ct input:not(:checked)")
        self.assertFalse(b.is_present("#auto-update-type"))
        assertTimer(None)

        # enable
        b.click("#automatic .onoff-ct input")
        wait_pending()
        b.wait_present("#automatic .onoff-ct input:checked")
        assertTimer("06")
        assertType("all")
        b.wait_in_text("#auto-update-type", "Apply all updates")
        b.wait_in_text("#auto-update-time", "06:00")
        b.wait_in_text("#auto-update-day", "every day")

        # change type to security
        b.set_val("#auto-update-type", "security")
        wait_pending()
        b.wait_in_text("#auto-update-type", "Apply security updates")
        assertType("security")
        assertTimer("06")

        # change it back
        b.set_val("#auto-update-type", "all")
        wait_pending()
        b.wait_in_text("#auto-update-type", "Apply all updates")
        assertType("all")
        assertTimer("06")

        # change day
        b.set_val("#auto-update-day", "thu")
        wait_pending()
        b.wait_in_text("#auto-update-day", "Thursday")
        assertType("all")
        assertTimer("06", "Thu")

        # change time
        b.set_val("#auto-update-time", "21:00")
        wait_pending()
        b.wait_in_text("#auto-update-time", "21:00")
        assertType("all")
        assertTimer("21", "Thu")

        # page should parse it correctly from the timer
        b.logout()
        b.login_and_go("/updates")
        b.wait_present("#automatic .onoff-ct input:checked")
        b.wait_in_text("#auto-update-day", "Thursday")
        b.wait_in_text("#auto-update-time", "21:00")

        # change back to daily
        b.set_val("#auto-update-day", "")
        wait_pending()
        b.wait_in_text("#auto-update-day", "every day")
        assertType("all")
        assertTimer("21")

        # disable
        b.click("#automatic .onoff-ct input")
        wait_pending()
        b.wait_present("#automatic .onoff-ct input:not(:checked)")
        self.assertFalse(b.is_present("#auto-update-type"))
        assertTimer(None)

        if self.backend == "dnf":
            # OnCalendar= parsing: only time
            m.execute("mkdir -p /etc/systemd/system/dnf-automatic.timer.d")
            m.execute(r'printf "[Timer]\nOnUnitInactiveSec=\nOnCalendar=08:00\n" > '
                      r'/etc/systemd/system/dnf-automatic.timer.d/cal.conf; systemctl daemon-reload')
            b.reload()
            b.enter_page("/updates")
            b.click("#automatic .onoff-ct input")
            b.wait_present("#automatic .onoff-ct input:checked")
            b.wait_in_text("#auto-update-time", "08:00")
            b.wait_in_text("#auto-update-day", "every day")

            # OnCalendar= parsing: weekday and time
            m.execute(r'printf "[Timer]\nOnUnitInactiveSec=\nOnCalendar=Tue 20:00\n" > '
                      r'/etc/systemd/system/dnf-automatic.timer.d/cal.conf; systemctl daemon-reload')
            b.reload()
            b.enter_page("/updates")
            b.wait_present("#automatic .onoff-ct input:checked")
            b.wait_in_text("#auto-update-time", "20:00")
            b.wait_in_text("#auto-update-day", "Tuesday")

            # OnCalendar= parsing: "every day" calendar and time
            m.execute(r'printf "[Timer]\nOnUnitInactiveSec=\nOnCalendar=*-*-* 07:00\n" > '
                      r'/etc/systemd/system/dnf-automatic.timer.d/cal.conf; systemctl daemon-reload')
            b.reload()
            b.enter_page("/updates")
            b.wait_present("#automatic .onoff-ct input:checked")
            b.wait_in_text("#auto-update-time", "07:00")
            b.wait_in_text("#auto-update-day", "every day")

            # OnCalendar= parsing: unsupported
            m.execute(r'printf "[Timer]\nOnUnitInactiveSec=\nOnCalendar=*-02-* 11:00\n" > '
                      r'/etc/systemd/system/dnf-automatic.timer.d/cal.conf; systemctl daemon-reload')
            b.reload()
            b.enter_page("/updates")
            b.wait_in_text(".container-fluid .pf-c-empty-state", "up to date")
            b.wait_not_present("#automatic")

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

        self.createPackage("vanilla", "1.0", "1", install=True)
        self.createPackage("vanilla", "1.0", "2")
        self.enableRepo()

        m.start_cockpit()
        b.login_and_go("/updates")
        b.wait_present("#available")

        if not self.supported_backend:
            self.assertFalse(b.is_present("#automatic"))
            self.assertFalse(b.is_present("#auto-update-type"))
            return

        b.wait_present("#automatic .onoff-ct input:not(:checked)")
        self.assertFalse(b.is_present("#auto-update-type"))

        # enable
        b.click("#automatic .onoff-ct input")
        b.wait_present("#automatic .onoff-ct input:checked")
        b.wait_present("#auto-update-type")

        if self.backend == 'dnf':
            self.checkUpgradeRebootDnf()
        else:
            raise NotImplementedError(self.backend)

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

        m.execute("pkcon refresh")

        self.login_and_go("/updates", superuser=False)

        if not self.supported_backend:
            self.assertFalse(b.is_present("#automatic"))
            self.assertFalse(b.is_present("#auto-update-type"))
            return

        # detecting auto updates configuration works unprivileged, but changing does not
        b.wait_present("#automatic .onoff-ct input:disabled:not(:checked)")

        # become superuser
        b.switch_to_top()
        b.click("#super-user-indicator button")
        b.wait_in_text(".modal-dialog:contains('Administrative access')", "Please authenticate")
        b.set_input_text(".modal-dialog:contains('Administrative access') input", "foobar")
        b.click(".modal-dialog button:contains('Authenticate')")
        b.wait_not_present(".modal-dialog:contains('Administrative access')")
        b.wait_text("#super-user-indicator", "Administrative access")
        b.switch_to_frame("cockpit1:localhost/updates")

        # enable auto-updates
        b.wait_present("#automatic .onoff-ct input:not(:checked)")
        b.wait_not_present("#auto-update-type")
        b.click("#automatic .onoff-ct input")
        b.wait_present("#automatic .onoff-ct input:checked")
        b.wait_present("#auto-update-type:not(:disabled)")

        # Drop privileges
        b.switch_to_top()
        b.click("#super-user-indicator button")
        b.click(".modal-dialog:contains('Switch to limited access') button:contains('Limit access')")
        b.wait_not_present(".modal-dialog:contains('Switch to limited access')")
        b.wait_text("#super-user-indicator", "Limited access")
        b.switch_to_frame("cockpit1:localhost/updates")

        # auto-update status still visible, but disabled
        b.wait_present("#automatic .onoff-ct input:checked")
        b.wait_present("#automatic .onoff-ct input:disabled")
        b.wait_present("#auto-update-type:disabled")

    def checkUpgradeRebootDnf(self):
        """part of testWithAvailableUpdates() for dnf backend"""

        m = self.machine

        # dial down the random sleep to avoid the test having to wait 5 mins
        self.sed_file("/random_sleep/ s/=.*$/= 3/", "/etc/dnf/automatic.conf")
        # then manually start the upgrade job like the timer would
        m.execute("systemctl start dnf-automatic-install.service")
        # new vanilla package got installed, and triggered reboot; cancel that
        m.execute("test -f /stamp-vanilla-1.0-2")
        m.execute("until test -f /run/nologin; do sleep 1; done")
        m.execute("set -e; shutdown -c; test ! -f /run/nologin")
        # service should show vanilla upgrade and scheduling shutdown
        out = m.execute(
            "if systemctl status dnf-automatic-install.service; then echo 'expected service to be stopped'; exit 1; fi")
        self.assertIn("vanilla", out)
        self.assertIn("Shutdown scheduled", out)

        # run it again, now there are no available updates → no reboot
        m.execute("systemctl start dnf-automatic-install.service")
        m.execute("set -e; test -f /stamp-vanilla-1.0-2; test ! -f /run/nologin")
        # service should not do much
        out = m.execute(
            "if systemctl status dnf-automatic-install.service; then echo 'expected service to be stopped'; exit 1; fi")
        self.assertNotIn("vanilla", out)
        self.assertNotIn("Shutdown", out)


@skipImage("Image uses OSTree", "fedora-coreos")
class TestAutoUpdatesInstall(NoSubManCase):
    def testUnsupported(self):
        b = self.browser
        m = self.machine

        m.execute("if type dnf; then dnf remove -y dnf-automatic; elif type apt; then dpkg -P unattended-upgrades; fi")

        # first test with available upgrades
        self.createPackage("vanilla", "1.0", "1", install=True)
        self.createPackage("vanilla", "1.0", "2")
        self.enableRepo()

        m.start_cockpit()
        b.login_and_go("/updates")
        b.wait_present("#available")
        if self.backend == 'dnf':
            b.wait_present("#automatic .onoff-ct input:not(:checked)")
        else:
            # on-demand installation isn't supported, switch to enable it isn't visible
            self.assertFalse(b.is_present("#automatic"))

        # apply updates
        b.click("#app .container-fluid button.pk-update--all")

        # empty state visible in main area
        b.click('.container-fluid .pf-c-empty-state button.pf-m-link')
        self.assertFalse(b.is_present("#available"))
        if self.backend == 'dnf':
            b.is_present('#automatic')
        else:
            # on-demand installation isn't supported
            self.assertFalse(b.is_present('#automatic'))

    @skipImage("No supported auto update backend", "debian-stable", "debian-testing", "ubuntu-2004", "ubuntu-stable")
    def testInstall(self):
        b = self.browser
        m = self.machine

        m.execute('dnf remove -y dnf-automatic')

        # provide minimal content in order for the backend to be seen as supported
        timerContent = '''
[Unit]
Description=dnf-automatic timer
# See comment in dnf-makecache.service
ConditionPathExists=!/run/ostree-booted

[Timer]
OnBootSec=1h
OnUnitInactiveSec=1d
Unit=-.mount

[Install]
WantedBy=basic.target
        '''
        self.createPackage('dnf-automatic', '1', '1', content={
            '/etc/dnf/automatic.conf': '',
            '/usr/lib/systemd/system/dnf-automatic.timer': timerContent,
            '/usr/lib/systemd/system/dnf-automatic-install.timer': timerContent
        })
        self.enableRepo()

        m.start_cockpit()
        b.login_and_go('/updates')

        # click through install dialog
        b.click("#automatic .onoff-ct input")
        b.wait_popup('dialog')
        b.wait_visible('#dialog button.apply')
        b.wait_not_attr('#dialog button.apply', 'disabled', '')
        b.click('#dialog button.apply')

        # as dnf-automatic isn't actually installed DnfImpl.setConfig will fail,
        # but we can check that the backend is now enabled
        b.wait_present("#automatic .onoff-ct input:checked")


if __name__ == '__main__':
    test_main()
