#!/usr/bin/env python3
#
# Copyright (c) 2024 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

import glob
import os
import shutil

import pytest
import requests
from yunohost.app import (
    _is_installed,
    app_info,
    app_install,
    app_manifest,
    app_map,
    app_remove,
    app_ssowatconf,
    app_upgrade,
)
from yunohost.domain import _get_maindomain, domain_add, domain_list, domain_remove
from yunohost.permission import permission_delete, user_permission_list
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.file_utils import mkdir

from .conftest import get_test_apps_dir, message, raiseYunohostError
from .test_permission import check_LDAP_db_integrity, check_permission_for_apps


def setup_function(function):
    clean()


def teardown_function(function):
    clean()


def clean():
    # Make sure we have a ssowat
    os.system("mkdir -p /etc/ssowat/")
    app_ssowatconf()

    test_apps = [
        "break_yo_system",
        "legacy_app",
        "legacy_app__2",
        "manifestv2_app",
        "full_domain_app",
        "my_webapp",
    ]

    for test_app in test_apps:
        if _is_installed(test_app):
            app_remove(test_app)

        for filepath in glob.glob("/etc/nginx/conf.d/*.d/*%s*" % test_app):
            os.remove(filepath)
        for folderpath in glob.glob("/etc/yunohost/apps/*%s*" % test_app):
            shutil.rmtree(folderpath, ignore_errors=True)
        for folderpath in glob.glob("/var/www/*%s*" % test_app):
            shutil.rmtree(folderpath, ignore_errors=True)

        os.system("bash -c \"mysql -B 2>/dev/null <<< 'DROP DATABASE %s' \"" % test_app)
        os.system(
            "bash -c \"mysql -B 2>/dev/null <<< 'DROP USER %s@localhost'\"" % test_app
        )

    # Reset failed quota for service to avoid running into start-limit rate ?
    os.system("systemctl reset-failed nginx")
    os.system("systemctl start nginx")

    # Clean permissions
    for permission_name in user_permission_list()["permissions"]:
        if any(test_app in permission_name for test_app in test_apps):
            permission_delete(permission_name, force=True)


@pytest.fixture(autouse=True)
def check_LDAP_db_integrity_call():
    check_LDAP_db_integrity()
    yield
    check_LDAP_db_integrity()


@pytest.fixture(autouse=True)
def check_permission_for_apps_call():
    check_permission_for_apps()
    yield
    check_permission_for_apps()


@pytest.fixture(scope="module")
def secondary_domain(request):
    if "example.test" not in domain_list()["domains"]:
        domain_add("example.test")

    def remove_example_domain():
        domain_remove("example.test")

    request.addfinalizer(remove_example_domain)

    return "example.test"


#
# Helpers                                                                    #
#


def app_expected_files(domain, app):
    yield "/etc/nginx/conf.d/{}.d/{}.conf".format(domain, app)
    if app.startswith("legacy_app"):
        yield "/var/www/%s/index.html" % app
    yield "/etc/yunohost/apps/%s/settings.yml" % app
    if "manifestv2" in app or "my_webapp" in app:
        yield "/etc/yunohost/apps/%s/manifest.toml" % app
    else:
        yield "/etc/yunohost/apps/%s/manifest.json" % app
    yield "/etc/yunohost/apps/%s/scripts/install" % app
    yield "/etc/yunohost/apps/%s/scripts/remove" % app


def app_is_installed(domain, app):
    return _is_installed(app) and all(
        os.path.exists(f) for f in app_expected_files(domain, app)
    )


def app_is_not_installed(domain, app):
    return not _is_installed(app) and not all(
        os.path.exists(f) for f in app_expected_files(domain, app)
    )


def app_is_exposed_on_http(domain, path, message_in_page):
    try:
        r = requests.get(
            "https://127.0.0.1" + path + "/",
            headers={"Host": domain},
            timeout=10,
            verify=False,
        )
        return r.status_code == 200 and message_in_page in r.text
    except Exception:
        return False


def install_legacy_app(domain, path, public=True):
    app_install(
        os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
        args="domain={}&path={}&is_public={}".format(domain, path, 1 if public else 0),
        force=True,
    )


def install_manifestv2_app(domain, path, public=True):
    app_install(
        os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"),
        args="domain={}&path={}&init_main_permission={}".format(
            domain, path, "visitors" if public else "all_users"
        ),
        force=True,
    )


def install_full_domain_app(domain):
    app_install(
        os.path.join(get_test_apps_dir(), "full_domain_app_ynh"),
        args="domain=%s" % domain,
        force=True,
    )


def install_break_yo_system(domain, breakwhat):
    app_install(
        os.path.join(get_test_apps_dir(), "break_yo_system_ynh"),
        args="domain={}&path=/break_yo_system&breakwhat={}".format(domain, breakwhat),
        force=True,
    )


def test_legacy_app_install_main_domain():
    main_domain = _get_maindomain()

    install_legacy_app(main_domain, "/legacy")

    app_map_ = app_map(raw=True)
    assert main_domain in app_map_
    assert "/legacy" in app_map_[main_domain]
    assert "id" in app_map_[main_domain]["/legacy"]
    assert app_map_[main_domain]["/legacy"]["id"] == "legacy_app"

    assert app_is_installed(main_domain, "legacy_app")
    assert app_is_exposed_on_http(main_domain, "/legacy", "This is a dummy app")

    app_remove("legacy_app")

    assert app_is_not_installed(main_domain, "legacy_app")


def test_legacy_app_manifest_preinstall():
    m = app_manifest(os.path.join(get_test_apps_dir(), "legacy_app_ynh"))
    # v1 manifesto are expected to have been autoconverted to v2

    assert "id" in m
    assert "description" in m
    assert "integration" in m
    assert "install" in m
    assert m["doc"] == {}
    assert m["notifications"] == {
        "PRE_INSTALL": {},
        "PRE_UPGRADE": {},
        "POST_INSTALL": {},
        "POST_UPGRADE": {},
    }


def test_manifestv2_app_manifest_preinstall():
    m = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))

    assert "id" in m
    assert "install" in m
    assert "description" in m
    assert "doc" in m
    assert (
        "This is a dummy description of this app features"
        in m["doc"]["DESCRIPTION"]["en"]
    )
    assert (
        "Ceci est une fausse description des fonctionalités de l'app"
        in m["doc"]["DESCRIPTION"]["fr"]
    )
    assert "notifications" in m
    assert (
        "This is a dummy disclaimer to display prior to the install"
        in m["notifications"]["PRE_INSTALL"]["main"]["en"]
    )
    assert (
        "Ceci est un faux disclaimer à présenter avant l'installation"
        in m["notifications"]["PRE_INSTALL"]["main"]["fr"]
    )


def test_manifestv2_app_install_main_domain():
    main_domain = _get_maindomain()

    install_manifestv2_app(main_domain, "/manifestv2")

    app_map_ = app_map(raw=True)
    assert main_domain in app_map_
    assert "/manifestv2" in app_map_[main_domain]
    assert "id" in app_map_[main_domain]["/manifestv2"]
    assert app_map_[main_domain]["/manifestv2"]["id"] == "manifestv2_app"

    assert app_is_installed(main_domain, "manifestv2_app")

    assert app_is_exposed_on_http(main_domain, "/manifestv2", "Hextris")

    app_remove("manifestv2_app")

    assert app_is_not_installed(main_domain, "manifestv2_app")


def test_manifestv2_app_info_postinstall():
    main_domain = _get_maindomain()
    install_manifestv2_app(main_domain, "/manifestv2")
    m = app_info("manifestv2_app", full=True)["manifest"]

    assert "id" in m
    assert "install" in m
    assert "description" in m
    assert "doc" in m
    assert "The app install dir is /var/www/manifestv2_app" in m["doc"]["ADMIN"]["en"]
    assert (
        "Le dossier d'install de l'app est /var/www/manifestv2_app"
        in m["doc"]["ADMIN"]["fr"]
    )
    assert "notifications" in m
    assert (
        "The app install dir is /var/www/manifestv2_app"
        in m["notifications"]["POST_INSTALL"]["main"]["en"]
    )
    assert (
        "The app id is manifestv2_app"
        in m["notifications"]["POST_INSTALL"]["main"]["en"]
    )
    assert (
        f"The app url is {main_domain}/manifestv2"
        in m["notifications"]["POST_INSTALL"]["main"]["en"]
    )


def test_manifestv2_app_info_preupgrade(monkeypatch):
    manifest = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))

    from yunohost.app_catalog import _load_apps_catalog as original_load_apps_catalog

    def custom_load_apps_catalog(*args, **kwargs):
        res = original_load_apps_catalog(*args, **kwargs)
        res["apps"]["manifestv2_app"] = {
            "id": "manifestv2_app",
            "level": 10,
            "lastUpdate": 999999999,
            "maintained": True,
            "manifest": manifest,
            "state": "working",
            "git": {"url": "whatever", "revision": "12345acbdef"},
        }
        res["apps"]["manifestv2_app"]["manifest"]["version"] = "99999~ynh1"

        return res

    monkeypatch.setattr("yunohost.app._load_apps_catalog", custom_load_apps_catalog)

    main_domain = _get_maindomain()
    install_manifestv2_app(main_domain, "/manifestv2")
    i = app_info("manifestv2_app", with_upgrade_infos=True)

    assert i["upgrade"]["status"] == "upgradable"
    assert i["upgrade"]["new_version"] == "99999~ynh1"

    # FIXME : meh, the code evolved and now implies a git_clone
    # to fetch the PRE_UPGRADE notifications ... but it's hard to test/mock T_T
    # assert (
    #     "This is a dummy disclaimer to display prior to any upgrade"
    #     in i["from_catalog"]["manifest"]["notifications"]["PRE_UPGRADE"]["main"]["en"]
    # )


def test_app_from_catalog():
    main_domain = _get_maindomain()

    app_install(
        "my_webapp",
        args=f"domain={main_domain}&path=/site&with_sftp=0&password=superpassword&init_main_permission=visitors&with_mysql=0&phpversion=none",
    )
    app_map_ = app_map(raw=True)
    assert main_domain in app_map_
    assert "/site" in app_map_[main_domain]
    assert "id" in app_map_[main_domain]["/site"]
    assert app_map_[main_domain]["/site"]["id"] == "my_webapp"

    assert app_is_installed(main_domain, "my_webapp")
    assert app_is_exposed_on_http(
        main_domain, "/site", "you have just installed My Webapp"
    )

    # Try upgrade, should do nothing
    with pytest.raises(YunohostError):
        with message("apps_no_target_can_be_upgraded"):
            app_upgrade("my_webapp")

    # Force upgrade, should upgrade to the same version
    with message("app_upgraded", app="my_webapp"):
        app_upgrade("my_webapp", force=True)

    app_remove("my_webapp")

    assert app_is_not_installed(main_domain, "my_webapp")


def test_legacy_app_install_secondary_domain(secondary_domain):
    install_legacy_app(secondary_domain, "/legacy")

    assert app_is_installed(secondary_domain, "legacy_app")
    assert app_is_exposed_on_http(secondary_domain, "/legacy", "This is a dummy app")

    app_remove("legacy_app")

    assert app_is_not_installed(secondary_domain, "legacy_app")


def test_legacy_app_install_secondary_domain_on_root(secondary_domain):
    install_legacy_app(secondary_domain, "/")

    app_map_ = app_map(raw=True)
    assert secondary_domain in app_map_
    assert "/" in app_map_[secondary_domain]
    assert "id" in app_map_[secondary_domain]["/"]
    assert app_map_[secondary_domain]["/"]["id"] == "legacy_app"

    assert app_is_installed(secondary_domain, "legacy_app")
    assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app")

    app_remove("legacy_app")

    assert app_is_not_installed(secondary_domain, "legacy_app")


def test_legacy_app_install_private(secondary_domain):
    install_legacy_app(secondary_domain, "/legacy", public=False)

    assert app_is_installed(secondary_domain, "legacy_app")
    assert not app_is_exposed_on_http(
        secondary_domain, "/legacy", "This is a dummy app"
    )

    app_remove("legacy_app")

    assert app_is_not_installed(secondary_domain, "legacy_app")


def test_legacy_app_install_unknown_domain():
    with pytest.raises(YunohostError):
        with message("app_argument_invalid"):
            install_legacy_app("whatever.nope", "/legacy")

    assert app_is_not_installed("whatever.nope", "legacy_app")


def test_legacy_app_install_multiple_instances(secondary_domain):
    install_legacy_app(secondary_domain, "/foo")
    install_legacy_app(secondary_domain, "/bar")

    assert app_is_installed(secondary_domain, "legacy_app")
    assert app_is_exposed_on_http(secondary_domain, "/foo", "This is a dummy app")

    assert app_is_installed(secondary_domain, "legacy_app__2")
    assert app_is_exposed_on_http(secondary_domain, "/bar", "This is a dummy app")

    app_remove("legacy_app")

    assert app_is_not_installed(secondary_domain, "legacy_app")
    assert app_is_installed(secondary_domain, "legacy_app__2")

    app_remove("legacy_app__2")

    assert app_is_not_installed(secondary_domain, "legacy_app")
    assert app_is_not_installed(secondary_domain, "legacy_app__2")


def test_legacy_app_install_path_unavailable(secondary_domain):
    # These will be removed in teardown
    install_legacy_app(secondary_domain, "/legacy")

    with pytest.raises(YunohostError):
        with message("app_location_unavailable"):
            install_legacy_app(secondary_domain, "/")

    assert app_is_installed(secondary_domain, "legacy_app")
    assert app_is_not_installed(secondary_domain, "legacy_app__2")


def test_legacy_app_install_with_nginx_down(mocker, secondary_domain):
    os.system("systemctl stop nginx")

    with raiseYunohostError(
        mocker, "app_action_cannot_be_ran_because_required_services_down"
    ):
        install_legacy_app(secondary_domain, "/legacy")


def test_legacy_app_failed_install(secondary_domain):
    # This will conflict with the folder that the app
    # attempts to create, making the install fail
    mkdir("/var/www/legacy_app/", 0o750)

    with pytest.raises(YunohostError):
        with message("app_install_script_failed"):
            install_legacy_app(secondary_domain, "/legacy")

    assert app_is_not_installed(secondary_domain, "legacy_app")


def test_legacy_app_failed_remove(secondary_domain):
    install_legacy_app(secondary_domain, "/legacy")

    # The remove script runs with set -eu and attempt to remove this
    # file without -f, so will fail if it's not there ;)
    os.remove("/etc/nginx/conf.d/{}.d/{}.conf".format(secondary_domain, "legacy_app"))

    # TODO / FIXME : can't easily validate that 'app_not_properly_removed'
    # is triggered for weird reasons ...
    app_remove("legacy_app")

    #
    # Well here, we hit the classical issue where if an app removal script
    # fails, so far there's no obvious way to make sure that all files related
    # to this app got removed ...
    #
    assert app_is_not_installed(secondary_domain, "legacy")


def test_full_domain_app(secondary_domain):
    install_full_domain_app(secondary_domain)

    assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app")


def test_full_domain_app_with_conflicts(mocker, secondary_domain):
    install_legacy_app(secondary_domain, "/legacy")

    with raiseYunohostError(mocker, "app_full_domain_unavailable"):
        install_full_domain_app(secondary_domain)


def test_systemfuckedup_during_app_install(secondary_domain):
    with pytest.raises(YunohostError):
        with message("app_install_failed"):
            with message("app_action_broke_system"):
                install_break_yo_system(secondary_domain, breakwhat="install")

    assert app_is_not_installed(secondary_domain, "break_yo_system")


def test_systemfuckedup_during_app_remove(secondary_domain):
    install_break_yo_system(secondary_domain, breakwhat="remove")

    with pytest.raises(YunohostError):
        with message("app_action_broke_system"):
            with message("app_removed"):
                app_remove("break_yo_system")

    assert app_is_not_installed(secondary_domain, "break_yo_system")


def test_systemfuckedup_during_app_install_and_remove(secondary_domain):
    with pytest.raises(YunohostError):
        with message("app_install_failed"):
            with message("app_action_broke_system"):
                install_break_yo_system(secondary_domain, breakwhat="everything")

    assert app_is_not_installed(secondary_domain, "break_yo_system")


def test_systemfuckedup_during_app_upgrade(secondary_domain):
    install_break_yo_system(secondary_domain, breakwhat="upgrade")

    with pytest.raises(YunohostError):
        with message("app_upgrade_broke_the_system", app="break_yo_system"):
            app_upgrade(
                "break_yo_system",
                file=os.path.join(get_test_apps_dir(), "break_yo_system_ynh"),
            )


def test_failed_multiple_app_upgrade(secondary_domain):
    install_legacy_app(secondary_domain, "/legacy")
    install_break_yo_system(secondary_domain, breakwhat="upgrade")

    with message("apps_upgrade_cancelled", apps="legacy_app"):
        res = app_upgrade(
            ["break_yo_system", "legacy_app"],
            file={
                "break_yo_system": os.path.join(
                    get_test_apps_dir(), "break_yo_system_ynh"
                ),
                "legacy_app": os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
            },
        )
    assert "break_yo_system" in res["failed"]
    assert "legacy_app" in res["cancelled"]


class TestMockedAppUpgrade:
    """
    This class is here to test the logical workflow of app_upgrade and thus
    mock nearly all side effects
    """

    def setup_method(self, method):
        self.apps_list = []
        self.upgradable_apps_list = []

    def _mock_app_upgrade(self, mocker):
        # app list
        mocker.patch("yunohost.app._installed_apps", side_effect=lambda: self.apps_list)
        mocker.patch(
            "yunohost.utils.app_utils._installed_apps",
            side_effect=lambda: self.apps_list,
        )

        # just check if an app is really installed
        mocker.patch(
            "yunohost.app._is_installed", side_effect=lambda app: app in self.apps_list
        )
        mocker.patch(
            "yunohost.utils.app_utils._is_installed",
            side_effect=lambda app: app in self.apps_list,
        )

        mocker.patch(
            "yunohost.app.app_info",
            side_effect=lambda app, full=False, with_upgrade_infos=False: {
                "upgrade": {
                    "status": "upgradable"
                    if app in self.upgradable_apps_list
                    else "up_to_date",
                    "current_version": "1.2.3",
                },
                "manifest": {"id": app},
            },
        )
        mocker.patch(
            "yunohost.app._app_upgrade_infos",
            side_effect=lambda app, current_version=None: {
                "status": "upgradable"
                if app in self.upgradable_apps_list
                else "up_to_date",
                "current_version": current_version or "1.2.3",
            },
        )

        def custom_extract_app(app):
            return (
                {
                    "version": "?",
                    "packaging_format": 1,
                    "id": app,
                    "notifications": {"PRE_UPGRADE": None, "POST_UPGRADE": None},
                },
                "MOCKED_BY_TEST",
            )

        # return (manifest, extracted_app_folder)
        mocker.patch("yunohost.app._extract_app", side_effect=custom_extract_app)

        mocker.patch(
            "yunohost.app._check_manifest_requirements",
            return_value=[{"id": "dummytest", "passed": True, "error": None}],
        )

        # raise on failure
        mocker.patch("yunohost.app._assert_system_is_sane_for_app", return_value=True)

        from os.path import exists  # import the unmocked function

        def custom_get_manifest_of_app(app):
            return {
                "id": app,
                "packaging_format": 1,
                "version": "1.2.3~ynh1",
                "arguments": {"install": []},
            }

        mocker.patch(
            "yunohost.utils.app_utils._get_manifest_of_app",
            side_effect=custom_get_manifest_of_app,
        )
        mocker.patch(
            "yunohost.app._get_manifest_of_app", side_effect=custom_get_manifest_of_app
        )

        # install_failed, failure_message_with_debug_instructions =
        self.hook_exec_with_script_debug_if_failure = mocker.patch(
            "yunohost.hook.hook_exec_with_script_debug_if_failure",
            return_value=(False, ""),
        )
        # settings =
        mocker.patch("yunohost.app._get_app_settings", return_value={})
        mocker.patch("yunohost.utils.app_utils._get_app_settings", return_value={})
        # return nothing
        mocker.patch("yunohost.app._set_app_settings")
        mocker.patch("yunohost.utils.app_utils._set_app_settings")

        from os import listdir  # import the unmocked function

        def custom_os_listdir(path):
            if "MOCKED_BY_TEST" in str(path):
                return []
            return listdir(path)

        mocker.patch("os.listdir", side_effect=custom_os_listdir)
        mocker.patch("yunohost.app.rm")
        mocker.patch("yunohost.app.cp")
        mocker.patch("yunohost.app.rmtree")
        mocker.patch("yunohost.app.chmod")
        mocker.patch("yunohost.app.chown")
        mocker.patch("yunohost.app.app_ssowatconf")

    def test_app_upgrade_no_apps(self, mocker):
        self._mock_app_upgrade(mocker)

        with message("apps_already_up_to_date"):
            app_upgrade()

    def test_app_upgrade_app_not_install(self, mocker):
        self._mock_app_upgrade(mocker)

        with pytest.raises(YunohostValidationError):
            app_upgrade("some_app")

    def test_app_upgrade_one_app(self, mocker):
        self._mock_app_upgrade(mocker)
        self.apps_list = ["some_app"]

        # yunohost is happy, not apps to upgrade
        with message("apps_already_up_to_date"):
            app_upgrade()

        self.hook_exec_with_script_debug_if_failure.assert_not_called()

        self.upgradable_apps_list.append("some_app")

        app_upgrade()

        self.hook_exec_with_script_debug_if_failure.assert_called_once()
        assert (
            self.hook_exec_with_script_debug_if_failure.call_args.kwargs["env"][
                "YNH_APP_ID"
            ]
            == "some_app"
        )

    def test_app_upgrade_continue_on_failure(self, mocker):
        self._mock_app_upgrade(mocker)
        self.apps_list = ["a", "b", "c"]
        self.upgradable_apps_list = self.apps_list

        def fails_on_b(self, *args, env, **kwargs):
            if env["YNH_APP_ID"] == "b":
                return True, "Dummy failure"
            return False, "ok"

        self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b

        with message("apps_upgrade_cancelled", apps="c"):
            res = app_upgrade()
        assert "a" in res["success"]
        assert "b" in res["failed"]
        assert "c" in res["cancelled"]

        with message("app_upgrade_continuing_with_other_apps", app="b"):
            res = app_upgrade(continue_on_failure=True)
        assert "a" in res["success"]
        assert "b" in res["failed"]
        assert "c" in res["success"]

    def test_app_upgrade_continue_on_failure_broken_system(self, mocker):
        """--continue-on-failure should stop on a broken system"""

        self._mock_app_upgrade(mocker)
        self.apps_list = ["a", "broke_the_system", "c"]
        self.upgradable_apps_list = self.apps_list

        def fails_on_b(self, *args, env, **kwargs):
            if env["YNH_APP_ID"] == "broke_the_system":
                return True, "failed"
            return False, "ok"

        self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b

        def _assert_system_is_sane_for_app(manifest, state):
            if state == "post" and manifest["id"] == "broke_the_system":
                raise Exception()
            return True

        mocker.patch(
            "yunohost.app._assert_system_is_sane_for_app",
            side_effect=_assert_system_is_sane_for_app,
        )

        with message("apps_upgrade_cancelled", apps="c"):
            res = app_upgrade()
        assert "a" in res["success"]
        assert "broke_the_system" in res["failed"]
        assert "c" in res["cancelled"]

        with message("apps_upgrade_cancelled", apps="c"):
            res = app_upgrade(continue_on_failure=True)
        assert "a" in res["success"]
        assert "broke_the_system" in res["failed"]
        # Difference with the previous test (without breaking the system) : breaking the system bypasses continue_on_failure
        assert "c" in res["cancelled"]
