#!/usr/bin/python3
# -*- coding: utf-8 -*-
# --------------------------------------------------------------------
# Copyright © 2014-2015 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
# --------------------------------------------------------------------

# --------------------------------------------------------------------
# Functional tests for the snappy upgrader.
# --------------------------------------------------------------------

import sys
import os
import logging
import unittest

from ubuntucoreupgrader.upgrader import (
    Upgrader,
    parse_args,
)

base_dir = os.path.abspath(os.path.dirname(__file__))
module_dir = os.path.normpath(os.path.realpath(base_dir + os.sep + '..'))
sys.path.append(base_dir)

from ubuntucoreupgrader.tests.utils import (
    append_file,
    TEST_DIR_MODE,
    create_file,
    UbuntuCoreUpgraderTestCase,
    )

CMD_FILE = 'ubuntu_command'


def call_upgrader(command_file, root_dir, update):
    '''
    Invoke the upgrader.

    :param command_file: commands file to drive the upgrader.
    :param root_dir: Test directory to apply the upgrade to.
    :param update: UpdateTree object.
    '''

    args = []
    args += ['--root-dir', root_dir]
    args += ['--debug', '1']

    # don't delete the archive and command files.
    # The tests clean up after themselves so they will get removed then,
    # but useful to have them around to diagnose test failures.
    args.append('--leave-files')

    args.append(command_file)
    commands = file_to_list(command_file)

    upgrader = Upgrader(parse_args(args), commands, [])
    upgrader.cache_dir = root_dir
    upgrader.MOUNTPOINT_CMD = "true"
    upgrader.run()


def create_device_file(path, type='c', major=-1, minor=-1):
    '''
    Create a device file.

    :param path: full path to device file.
    :param type: 'c' or 'b' (character or block).
    :param major: major number.
    :param minor: minor number.

    XXX: This doesn't actually create a device node,
    it simply creates a regular empty file whilst ensuring the filename
    gives the impression that it is a device file.

    This hackery is done for the following reasons:

    - non-priv users cannot create device nodes (and the tests run as a
      non-priv user).
    - the removed file in the upgrade tar file does not actually specify the
      _type_ of the files to remove. Hence, we can pretend that the file
      to remove is a device file since the upgrader cannot know for sure
      (it can check the existing on-disk file, but that isn't conclusive
      since the admin may have manually modified a file, or the server
      may have generated an invalid remove file - the upgrader cannot
      know for sure.
    '''
    assert (os.path.dirname(path).endswith('/dev'))

    append_file(path, 'fake-device file')


def create_directory(path):
    '''
    Create a directory.
    '''
    os.makedirs(path, mode=TEST_DIR_MODE, exist_ok=False)


def create_sym_link(source, dest):
    '''
    Create a symbolic link.

    :param source: existing file to link to.
    :param dest: name for the sym link.
    '''
    dirname = os.path.dirname(dest)
    os.makedirs(dirname, mode=TEST_DIR_MODE, exist_ok=True)

    os.symlink(source, dest)


def create_hard_link(source, dest):
    '''
    Create a hard link.

    :param source: existing file to link to.
    :param dest: name for the hard link.
    '''
    os.link(source, dest)


def is_sym_link_broken(path):
    '''
    :param path: symbolic link to check.
    :return: True if the specified path is a broken symbolic link,
     else False.
    '''
    try:
        os.lstat(path)
        os.stat(path)
    except:
        return True
    return False


def make_command_file(path, update_list):
    '''
    Create a command file that the upgrader processes.

    :param path: full path to file to create,
    :param update_list: list of update archives to include.
    '''
    l = []

    for file in update_list:
        l.append('update {} {}.asc'.format(file, file))

    # flatten
    contents = "\n".join(l) + '\n'

    append_file(path, contents)


def file_to_list(path):
    '''
    Convert the specified file into a list and return it.
    '''
    lines = []

    with open(path, 'r') as f:
        lines = f.readlines()

    lines = [line.rstrip() for line in lines]

    return lines


class UpgraderFileRemovalTestCase(UbuntuCoreUpgraderTestCase):
    '''
    Test how the upgrader handles the removals file.
    '''

    def test_remove_file(self):
        '''
        Ensure the upgrader can remove a regular file.
        '''

        file = 'a-regular-file'

        self.update.add_to_removed_file([file])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        file_path = os.path.join(self.victim_dir, file)
        create_file(file_path, 'foo bar')

        self.assertTrue(os.path.exists(file_path))
        self.assertTrue(os.path.isfile(file_path))

        # remove 'system' suffix that upgrader will add back on
        vdir = os.path.split(self.victim_dir)[0]

        call_upgrader(cmd_file, vdir, self.update)

        self.assertFalse(os.path.exists(file_path))

    def test_remove_directory(self):
        '''
        Ensure the upgrader can remove a directory.
        '''
        dir = 'a-directory'

        self.update.add_to_removed_file([dir])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        dir_path = os.path.join(self.victim_dir, dir)
        create_directory(dir_path)

        self.assertTrue(os.path.exists(dir_path))
        self.assertTrue(os.path.isdir(dir_path))

        vdir = os.path.split(self.victim_dir)[0]
        call_upgrader(cmd_file, vdir, self.update)

        self.assertFalse(os.path.exists(dir_path))

    def test_remove_sym_link_file(self):
        '''
        Ensure the upgrader can remove a symbolic link to a file.
        '''
        src = 'the-source-file'
        link = 'the-symlink-file'

        self.update.add_to_removed_file([link])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        src_file_path = os.path.join(self.victim_dir, src)
        link_file_path = os.path.join(self.victim_dir, link)

        create_file(src_file_path, 'foo bar')

        self.assertTrue(os.path.exists(src_file_path))
        self.assertTrue(os.path.isfile(src_file_path))
        self.assertFalse(os.path.islink(src_file_path))

        create_sym_link(src_file_path, link_file_path)
        self.assertTrue(os.path.exists(link_file_path))
        self.assertTrue(os.path.islink(link_file_path))

        vdir = os.path.split(self.victim_dir)[0]
        call_upgrader(cmd_file, vdir, self.update)

        # original file should still be there
        self.assertTrue(os.path.exists(src_file_path))
        self.assertTrue(os.path.isfile(src_file_path))
        self.assertFalse(os.path.islink(src_file_path))

        # link should have gone
        self.assertFalse(os.path.exists(link_file_path))

    def test_remove_sym_link_directory(self):
        '''
        Ensure the upgrader can remove a symbolic link to a directory.
        '''
        dir = 'the-source-directory'
        link = 'the-symlink-file'

        self.update.add_to_removed_file([link])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        src_dir_path = os.path.join(self.victim_dir, dir)
        link_file_path = os.path.join(self.victim_dir, link)

        create_directory(src_dir_path)

        self.assertTrue(os.path.exists(src_dir_path))
        self.assertTrue(os.path.isdir(src_dir_path))
        self.assertFalse(os.path.islink(src_dir_path))

        create_sym_link(src_dir_path, link_file_path)
        self.assertTrue(os.path.exists(link_file_path))
        self.assertTrue(os.path.islink(link_file_path))

        vdir = os.path.split(self.victim_dir)[0]
        call_upgrader(cmd_file, vdir, self.update)

        # original directory should still be there
        self.assertTrue(os.path.exists(src_dir_path))
        self.assertTrue(os.path.isdir(src_dir_path))
        self.assertFalse(os.path.islink(src_dir_path))

        # link should have gone
        self.assertFalse(os.path.exists(link_file_path))

    def test_remove_hardlink(self):
        '''
        Ensure the upgrader can remove a hard link to a file.
        '''
        src = 'the-source-file'
        link = 'the-hardlink-file'

        self.update.add_to_removed_file([link])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        src_file_path = os.path.join(self.victim_dir, src)
        link_file_path = os.path.join(self.victim_dir, link)

        create_file(src_file_path, 'foo bar')

        src_inode = os.stat(src_file_path).st_ino

        self.assertTrue(os.path.exists(src_file_path))
        self.assertTrue(os.path.isfile(src_file_path))

        create_hard_link(src_file_path, link_file_path)
        self.assertTrue(os.path.exists(link_file_path))

        link_inode = os.stat(link_file_path).st_ino

        self.assertTrue(src_inode == link_inode)

        vdir = os.path.split(self.victim_dir)[0]
        call_upgrader(cmd_file, vdir, self.update)

        # original file should still be there
        self.assertTrue(os.path.exists(src_file_path))
        self.assertTrue(os.path.isfile(src_file_path))

        # Inode should not have changed.
        self.assertTrue(os.stat(src_file_path).st_ino == src_inode)

        # link should have gone
        self.assertFalse(os.path.exists(link_file_path))

    def test_remove_device_file(self):
        '''
        Ensure the upgrader can deal with a device file.

        XXX: Note that The upgrader currently "deals" with them by
        XXX: ignoring them :-)

        '''
        file = '/dev/a-fake-device'

        self.update.add_to_removed_file([file])

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        file_path = '{}{}'.format(self.victim_dir, file)

        create_device_file(file_path)

        self.assertTrue(os.path.exists(file_path))

        # sigh - we can't assert the that filetype is a char/block
        # device because it won't be :)
        self.assertTrue(os.path.isfile(file_path))

        vdir = os.path.split(self.victim_dir)[0]
        call_upgrader(cmd_file, vdir, self.update)

        # upgrader doesn't currently remove device files
        self.assertTrue(os.path.exists(file_path))


class UpgraderFileAddTestCase(UbuntuCoreUpgraderTestCase):
    '''
    Test how the upgrader handles adding new files.
    '''

    def test_create_file(self):
        '''
        Ensure the upgrader can create a regular file.
        '''
        file = 'created-regular-file'

        file_path = os.path.join(self.update.system_dir, file)

        create_file(file_path, 'foo bar')

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        file_path = os.path.join(self.victim_dir, file)
        self.assertFalse(os.path.exists(file_path))

        call_upgrader(cmd_file, self.victim_dir, self.update)

        self.assertTrue(os.path.exists(file_path))
        self.assertTrue(os.path.isfile(file_path))

    def test_create_directory(self):
        '''
        Ensure the upgrader can create a directory.
        '''
        dir = 'created-directory'

        dir_path = os.path.join(self.update.system_dir, dir)

        create_directory(dir_path)

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        dir_path = os.path.join(self.victim_dir, dir)
        self.assertFalse(os.path.exists(dir_path))

        call_upgrader(cmd_file, self.victim_dir, self.update)

        self.assertTrue(os.path.exists(dir_path))
        self.assertTrue(os.path.isdir(dir_path))

    def test_create_absolute_sym_link_to_file(self):
        '''
        Ensure the upgrader can create a symbolic link to a file (which
        already exists and is not included in the update archive).
        '''
        src = 'the-source-file'
        link = 'the-symlink-file'

        # the file the link points to should *NOT* be below the 'system/'
        # directory (since there isn't one post-unpack).

        # an absolute sym-link target path
        src_file_path = '/{}'.format(src)
        link_file_path = os.path.join(self.update.system_dir, link)

        victim_src_file_path = os.path.normpath('{}/{}'
                                                .format(self.victim_dir,
                                                        src_file_path))
        victim_link_file_path = os.path.join(self.victim_dir, link)

        # Create a broken sym link ('/system/<link> -> /<src>')
        create_sym_link(src_file_path, link_file_path)

        self.assertTrue(os.path.lexists(link_file_path))
        self.assertTrue(os.path.islink(link_file_path))
        self.assertTrue(is_sym_link_broken(link_file_path))

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        create_file(victim_src_file_path, 'foo')

        self.assertTrue(os.path.exists(victim_src_file_path))
        self.assertTrue(os.path.isfile(victim_src_file_path))

        self.assertFalse(os.path.exists(victim_link_file_path))

        call_upgrader(cmd_file, self.victim_dir, self.update)

        self.assertTrue(os.path.exists(victim_src_file_path))
        self.assertTrue(os.path.isfile(victim_src_file_path))

        # upgrader should have created the link in the victim directory
        self.assertTrue(os.path.lexists(victim_link_file_path))
        self.assertTrue(os.path.islink(victim_link_file_path))
        self.assertFalse(is_sym_link_broken(victim_link_file_path))

    def test_create_relative_sym_link_to_file(self):
        '''
        Ensure the upgrader can create a symbolic link to a file (which
        already exists and is not included in the update archive).
        '''
        src = 'a/b/c/the-source-file'
        link = 'a/d/e/the-symlink-file'

        # a relative sym-link target path
        # ##src_file_path = '../../b/c/{}'.format(src)
        src_file_path = '../../b/c/the-source-file'.format(src)

        # the file the link points to should *NOT* be below the 'system/'
        # directory (since there isn't one post-unpack).

        link_file_path = os.path.join(self.update.system_dir, link)

        victim_src_file_path = os.path.normpath('{}/{}'
                                                .format(self.victim_dir,
                                                        src))
        victim_link_file_path = os.path.join(self.victim_dir, link)

        create_sym_link(src_file_path, link_file_path)

        self.assertTrue(os.path.lexists(link_file_path))
        self.assertTrue(os.path.islink(link_file_path))
        self.assertTrue(is_sym_link_broken(link_file_path))

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        create_file(victim_src_file_path, 'foo')

        self.assertTrue(os.path.exists(victim_src_file_path))
        self.assertTrue(os.path.isfile(victim_src_file_path))

        self.assertFalse(os.path.exists(victim_link_file_path))

        call_upgrader(cmd_file, self.victim_dir, self.update)

        self.assertTrue(os.path.exists(victim_src_file_path))
        self.assertTrue(os.path.isfile(victim_src_file_path))

        # upgrader should have created the link in the victim directory
        self.assertTrue(os.path.lexists(victim_link_file_path))
        self.assertTrue(os.path.islink(victim_link_file_path))
        self.assertFalse(is_sym_link_broken(victim_link_file_path))

    def test_create_broken_sym_link_file(self):
        '''
        Ensure the upgrader can create a broken symbolic link
        (one that points to a non-existent file).
        '''
        src = 'the-source-file'
        link = 'the-symlink-file'

        # the file the link points to should *NOT* be below the 'system/'
        # directory (since there isn't one post-unpack).
        src_file_path = src

        link_file_path = os.path.join(self.update.system_dir, link)

        # Create a broken sym link ('/system/<link> -> /<src>')
        create_sym_link(src_file_path, link_file_path)

        self.assertTrue(os.path.lexists(link_file_path))
        self.assertTrue(os.path.islink(link_file_path))
        self.assertTrue(is_sym_link_broken(link_file_path))

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        victim_src_file_path = os.path.join(self.victim_dir, src)
        victim_link_file_path = os.path.join(self.victim_dir, link)

        self.assertFalse(os.path.exists(victim_src_file_path))
        self.assertFalse(os.path.exists(victim_link_file_path))

        call_upgrader(cmd_file, self.victim_dir, self.update)

        # source still shouldn't exist
        self.assertFalse(os.path.exists(victim_src_file_path))

        # upgrader should have created the link in the victim directory
        self.assertTrue(os.path.lexists(victim_link_file_path))
        self.assertTrue(os.path.islink(victim_link_file_path))
        self.assertTrue(is_sym_link_broken(victim_link_file_path))


class UpgraderRemoveFileTests(UbuntuCoreUpgraderTestCase):

    def common_removed_file_test(self, contents):
        '''
        Common code to test for an invalid removed file.

        The contents parameter specifies the contents of the removed
        file.
        '''
        file = 'created-regular-file'

        create_file(self.update.removed_file, contents)

        file_path = os.path.join(self.update.system_dir, file)
        create_file(file_path, 'foo bar')

        archive = self.update.create_archive(self.TARFILE)
        self.assertTrue(os.path.exists(archive))

        cmd_file = os.path.join(self.update.tmp_dir, CMD_FILE)
        make_command_file(cmd_file, [self.TARFILE])

        vdir = os.path.split(self.victim_dir)[0]

        file_path = os.path.join(vdir, file)
        self.assertFalse(os.path.exists(file_path))

        # XXX: There is an implicit test here since if the upgrader
        # fails (as documented on LP: #1437225), this test will also
        # fail.
        call_upgrader(cmd_file, vdir, self.update)

        self.assertTrue(os.path.exists(vdir))
        self.assertTrue(os.path.exists(self.victim_dir))
        self.assertTrue(os.path.exists(file_path))

        # ensure the empty removed file hasn't removed the directory the
        # unpack applies to.
        self.assertTrue(self.victim_dir)

    def test_removed_file_empty(self):
        '''
        Ensure the upgrader ignores an empty 'removed' file.
        '''
        self.common_removed_file_test('')

    def test_removed_file_space(self):
        '''
        Ensure the upgrader handles a 'removed' file containing just a
        space.
        '''
        self.common_removed_file_test(' ')

    def test_removed_file_nl(self):
        '''
        Ensure the upgrader handles a 'removed' file containing just a
        newline
        '''
        self.common_removed_file_test('\n')


def main():
    kwargs = {}
    format =             \
        '%(asctime)s:'   \
        '%(filename)s:'  \
        '%(name)s:'      \
        '%(funcName)s:'  \
        '%(levelname)s:' \
        '%(message)s'

    kwargs['format'] = format

    # We want to see what's happening
    kwargs['level'] = logging.DEBUG

    logging.basicConfig(**kwargs)

    unittest.main(
        testRunner=unittest.TextTestRunner(
            stream=sys.stdout,
            verbosity=2,

            # don't keep running tests if one fails
            # (... who _wouldn't_ want this???)
            failfast=True
        ),

    )

if __name__ == '__main__':
    main()
