Example #1
0
    def test_migrate_profile(self, tcex):
        """Test migrating profile."""
        os.environ['PYTEST_PWD'] = f'PYTEST_PWD_{randint(1000, 9999)}'
        profile_name = f'pytest-{randint(1000, 9999)}'

        # write temp test profile
        profile_filename = os.path.join(self.profile_dir,
                                        f'{profile_name}.json')
        with open(profile_filename, 'w') as fh:
            json.dump(self.profile_data, fh, indent=2)

        context = str(uuid4())
        profile = Profile(
            default_args=self.default_args.copy(),
            name=profile_name,
            redis_client=tcex.redis_client,
            tcex_testing_context=context,
        )

        # migrate profile to latest schema
        profile.migrate()

        # load profile
        profile_data = self.load_profile(f'{profile_name}.json')

        print('profile_data', profile_data)
    def init_profile(self,
                     profile_name,
                     pytestconfig=None,
                     monkeypatch=None,
                     options=None):
        """Stages and sets up the profile given a profile name"""
        self._profile = Profile(
            default_args=self.default_args.copy(),
            name=profile_name,
            pytestconfig=pytestconfig,
            monkeypatch=monkeypatch,
            redis_client=self.redis_client,
            tcex_testing_context=self.tcex_testing_context,
            logger=self.log,
            options=options,
        )

        # Override test environments if specified
        os_environments = None
        if pytestconfig:
            os_environments = pytestconfig.option.environment

        # check profile environment
        self.check_environment(self._profile.environments, os_environments)

        # migrate profile to latest schema
        self._profile.migrate()

        # merge profile inputs (add new / remove non-defined)
        self._profile.merge_inputs()

        # populate profile (env vars, etc)
        self._profile.data = self._profile.populate.replace_env_variables(
            self._profile.data)

        # validate input fields, this method requires env vars to be populated
        valid, message = self._profile.validate_inputs()

        # stage ThreatConnect data based on current profile, also used in teardown method
        self._staged_tc_data = self.stager.threatconnect.entities(
            self._profile.stage_threatconnect, self._profile.owner)

        # insert staged data for replacement
        self._profile.tc_staged_data = self._staged_tc_data

        # Replace staged_data
        self._profile.data = self._profile.populate.replace_tc_variables(
            self._profile.data)

        # replace all references and all staged variable
        self._profile.init()

        # stage kvstore data based on current profile
        self.stager.redis.from_dict(self._profile.stage_kvstore)

        return valid, message
Example #3
0
    def test_profile_context(self, tcex):
        """Test profile contexts

        Args:
            tcex (TcEx, fixture): An instantiated instance of TcEx object.
        """
        default_args = {}
        feature = 'PyTest'
        name = f'pytest-{randint(1000, 9999)}'

        # initialize profile
        profile = Profile(default_args=default_args,
                          feature=feature,
                          name=name,
                          redis_client=tcex.redis_client)

        # add profile
        profile.add()

        # test context
        context = str(uuid4())
        profile.add_context(context)

        # assert context
        assert profile.context_tracker == [context]

        # clear context
        profile.clear_context(context)
        profile._context_tracker = []

        # assert context
        assert profile.context_tracker == []

        # cleanup
        os.remove(profile.filename)
Example #4
0
 def _update_path_args(self, args):
     """Update path in args for each test profile."""
     # service Apps do not have a profile when this is needed.
     profile = self.profile or Profile(
         default_args=self.default_args.copy())
     args['tc_in_path'] = profile.tc_in_path
     args['tc_log_path'] = profile.tc_log_path
     args['tc_out_path'] = profile.tc_out_path
     args['tc_temp_path'] = profile.tc_temp_path
Example #5
0
    def test_migrate_profile(self, tcex):
        """Test migrating profile."""
        os.environ['PYTEST_PWD'] = f'PYTEST_PWD_{randint(1000, 9999)}'
        profile_name = f'pytest-{randint(1000, 9999)}'

        # write temp test profile
        profile_filename = os.path.join(self.profile_dir, f'{profile_name}.json')
        with open(profile_filename, 'w') as fh:
            json.dump(self.profile_data, fh, indent=2)

        context = str(uuid4())
        profile = Profile(
            default_args=self.default_args.copy(),
            feature='PyTest',
            name=profile_name,
            redis_client=tcex.redis_client,
            tcex_testing_context=context,
        )

        # migrate profile to latest schema
        profile.migrate()

        # load profile
        profile_data = self.load_profile(f'{profile_name}.json')

        # validate redis was renamed to kvstore
        assert profile_data.get('stage', {}).get('redis') is None
        assert profile_data.get('stage', {}).get('kvstore') is not None
        # validate default input moved to default
        assert (
            profile_data.get('inputs', {}).get('defaults', {}).get('tc_proxy_host') == 'localhost'
        )
        # validate variable schema has been migrated
        assert (
            profile_data.get('inputs', {}).get('required', {}).get('vault_token')
            == '${env:PYTEST_PWD}'
        )
Example #6
0
class TestCase:
    """Base TestCase Class"""

    _app_path = os.getcwd()
    _current_test = None
    _default_args = None
    _profile = None
    _session_admin = None
    _session_exchange = None
    _stager = None
    _staged_tc_data = []
    _timer_class_start = None
    _timer_method_start = None
    _validator = None
    app = None
    context = None
    enable_update_profile = False
    env = set(os.getenv('TCEX_TEST_ENVS', 'build').split(','))
    env_store = EnvStore(logger=logger)
    ij = InstallJson(logger=logger)
    initialized = False
    log = logger
    redis_client = None
    session = Session()
    tcex = None
    tcex_testing_context = None
    use_token = True
    _skip = False
    utils = Utils()

    def _reset_property_flags(self):
        """Reset all control flag."""
        # used to prevent pytest from executing @property methods
        self.initialized = False
        self.tcex = False

    def _log_args(self, args):
        """Log args masking any that are marked encrypted and log warning for unknown args.

        Args:
            args (dict): A dictionary of args.
        """
        for name, value in sorted(args.items()):
            input_data = self.ij.params_dict.get(name)
            if input_data is None and name not in self.default_args:
                # if name is not in install.json and not a default arg log a warning
                if name not in ['tcex_testing_context'
                                ]:  # values that should not cause a warning
                    self.log.data('run', 'input', f'{name}: unknown arg.',
                                  'warning')
            elif input_data is not None and input_data.get('encrypt') is True:
                self.log.data('run', 'input', f'{name}: ***')
            else:
                self.log.data('run', 'input', f'{name}: {value}')

    @staticmethod
    def _to_bool(value):
        """Return bool value from int or string."""
        return str(value).lower() in ['1', 'true']

    def _update_args(self, args: dict) -> dict:
        """Update args before running App.

        Args:
            args: The current argument dictionary.

        Returns:
            dict: The updated argument dictionary.
        """

        if self.ij.runtime_level.lower() in ['playbook']:
            # set requested output variables
            args[
                'tc_playbook_out_variables'] = self.profile.tc_playbook_out_variables

        # update path args
        self._update_path_args(args)

        # update default args with app args
        app_args = self.default_args.copy()
        app_args.update(args)

        # update default args with app args
        app_args['tc_logger_name'] = self.context

        # safely log all args to tests.log
        self._log_args(app_args)

        return app_args

    def _update_path_args(self, args):
        """Update path in args for each test profile."""
        # service Apps do not have a profile when this is needed.
        profile = self.profile or Profile(
            default_args=self.default_args.copy())
        args['tc_in_path'] = profile.tc_in_path
        args['tc_log_path'] = profile.tc_log_path
        args['tc_out_path'] = profile.tc_out_path
        args['tc_temp_path'] = profile.tc_temp_path

    @property
    def api_access_id(self):
        """Return TC API Access ID"""
        return self.env_store.getenv(
            '/ninja/tc/tci/exchange_admin/api_access_id')

    @property
    def api_secret_key(self):
        """Return TC API Secret Key"""
        return self.env_store.getenv(
            '/ninja/tc/tci/exchange_admin/api_secret_key')

    def check_environment(self, environments, os_environments=None):
        """Check if test case matches current environments, else skip test.

        Args:
            environments (list): The test case environments.
            os_environments (list): The user/os defined environment.
        """
        test_envs = environments or ['build']
        os_envs = set(os.environ.get('TCEX_TEST_ENVS', 'build').split(','))
        if os_environments:
            os_envs = set(os_environments)
        # APP-1212 - fix issue where outputs were being deleted when profile was skipped
        self._skip = False
        if not os_envs.intersection(set(test_envs)):
            self._skip = True
            pytest.skip('Profile skipped based on current environment.')

    def create_config(self, args):
        """Create files necessary to start a Service App."""
        config = self._update_args(args)

        # service Apps will get their args/params from encrypted file in the "in" directory
        data = json.dumps(config, sort_keys=True).encode('utf-8')
        key = ''.join(random.choice(string.ascii_lowercase) for i in range(16))
        encrypted_data = self.utils.encrypt_aes_cbc(key, data)

        # ensure that the in directory exists
        os.makedirs(config.get('tc_in_path'), exist_ok=True)

        # write the file in/.app_params.json
        app_params_json = os.path.join(config.get('tc_in_path'),
                                       '.test_app_params.json')
        with open(app_params_json, 'wb') as fh:
            fh.write(encrypted_data)

        # create environment variable for tcex inputs method to pick up to read encrypted file
        os.environ['TC_APP_PARAM_KEY'] = key
        os.environ['TC_APP_PARAM_FILE'] = app_params_json

    @property
    def default_args(self):
        """Return App default args."""
        if self._default_args is None and self.initialized:
            self._default_args = {
                # local override TCI_EXCHANGE_ADMIN_API_ACCESS_ID
                'api_access_id':
                self.api_access_id,
                'api_default_org':
                os.getenv('API_DEFAULT_ORG', 'TCI'),
                # local override TCI_EXCHANGE_ADMIN_API_SECRET_KEY
                'api_secret_key':
                self.api_secret_key,
                'tc_api_path':
                self.tc_api_path,
                'tc_in_path':
                os.getenv('TC_IN_PATH', 'log'),
                'tc_log_level':
                os.getenv('TC_LOG_LEVEL', 'trace'),
                'tc_log_path':
                os.getenv('TC_LOG_PATH', 'log'),
                'tc_log_to_api':
                self._to_bool(os.getenv('TC_LOG_TO_API', 'false')),
                'tc_logger_name':
                'tcex',
                'tc_out_path':
                os.getenv('TC_OUT_PATH', 'log'),
                'tc_proxy_external':
                self._to_bool(os.getenv('TC_PROXY_EXTERNAL', 'false')),
                # local override TC_PROXY_HOST
                'tc_proxy_host':
                self.env_store.getenv('/ninja/proxy/tc_proxy_host',
                                      default='localhost'),
                # local override TC_PROXY_PASSWORD
                'tc_proxy_password':
                self.env_store.getenv('/ninja/proxy/tc_proxy_password',
                                      default=''),
                # local override TC_PROXY_PORT
                'tc_proxy_port':
                self.env_store.getenv('/ninja/proxy/tc_proxy_port',
                                      default='4242'),
                'tc_proxy_tc':
                self._to_bool(os.getenv('TC_PROXY_TC', 'false')),
                # local override TC_PROXY_USERNAME
                'tc_proxy_username':
                self.env_store.getenv('/ninja/proxy/tc_proxy_username',
                                      default=''),
                'tc_temp_path':
                os.getenv('TC_TEMP_PATH', 'log'),
            }

            # try to use token when possible
            if self.use_token is True:
                token = os.getenv('TC_TOKEN', self.tc_token)
                if token is not None:
                    # if token was successfully retrieved from TC use token and remove hmac values
                    self._default_args['tc_token'] = token
                    self._default_args['tc_token_expires'] = int(
                        time.time()) + 3600
                    del self._default_args['api_access_id']
                    del self._default_args['api_secret_key']
        return self._default_args

    def init_profile(self,
                     profile_name,
                     pytestconfig=None,
                     monkeypatch=None,
                     options=None):
        """Stages and sets up the profile given a profile name"""
        self._profile = Profile(
            default_args=self.default_args.copy(),
            name=profile_name,
            pytestconfig=pytestconfig,
            monkeypatch=monkeypatch,
            redis_client=self.redis_client,
            tcex_testing_context=self.tcex_testing_context,
            logger=self.log,
            options=options,
        )

        # Override test environments if specified
        os_environments = None
        if pytestconfig:
            os_environments = pytestconfig.option.environment

        # check profile environment
        self.check_environment(self._profile.environments, os_environments)

        # migrate profile to latest schema
        self._profile.migrate()

        # merge profile inputs (add new / remove non-defined)
        self._profile.merge_inputs()

        # populate profile (env vars, etc)
        self._profile.data = self._profile.populate.replace_env_variables(
            self._profile.data)

        # validate input fields, this method requires env vars to be populated
        valid, message = self._profile.validate_inputs()

        # stage ThreatConnect data based on current profile, also used in teardown method
        self._staged_tc_data = self.stager.threatconnect.entities(
            self._profile.stage_threatconnect, self._profile.owner)
        # insert staged data for replacement
        self._profile.tc_staged_data = self._staged_tc_data

        # Replace staged_data
        self._profile.data = self._profile.populate.replace_tc_variables(
            self._profile.data)

        # replace all references and all staged variable
        self._profile.init()

        # stage kvstore data based on current profile
        self.stager.redis.from_dict(self._profile.stage_kvstore)

        return valid, message

    @property
    def profile(self):
        """Return profile instance."""
        return self._profile

    def run(self):
        """Implement in Child Class"""
        raise NotImplementedError('Child class must implement this method.')

    def run_app_method(self, app, method):
        """Run the provided App method."""
        try:
            getattr(app, method)()
        except SystemExit as e:
            self.log.data('run', 'Exit Code', e.code)
            if e.code != 0 and self.profile and e.code not in self.profile.exit_codes:
                self.log.data(
                    'run',
                    'App failed',
                    f'App exited with code of {e.code} in method {method}',
                    'error',
                )
            app.tcex.log.info(f'Exit Code: {e.code}')
            return e.code
        except Exception:
            self.log.data(
                'run',
                'App failed',
                f'App encountered except in {method}() method ({traceback.format_exc()})',
                'error',
            )
            return 1
        return 0

    @property
    def session_admin(self):
        """Return requests Session object for TC admin account.

        The credential this session uses require special activation in the ThreatConnect Platform
        and is not intended for normal use.
        """
        # APP-102 - adding a session for TC API Admin role request
        api_access_id = self.env_store.getenv(
            '/ninja/tc/system/admin_api/api_access_id')
        api_secret_key = self.env_store.getenv(
            '/ninja/tc/system/admin_api/api_secret_key')
        if (self._session_admin is None and api_access_id is not None
                and api_secret_key is not None
                and self.tc_api_path is not None):
            # support for a proxy is not a typical use case, but can be added later if needed
            self._session_admin = TestSession(
                api_access_id=api_access_id,
                api_secret_key=api_secret_key,
                api_path=self.tc_api_path,
                logger=self.log,
            )
        return self._session_admin

    @property
    def session_exchange(self):
        """Return requests Session object for TC admin account.

        The credential this session uses require special activation in the ThreatConnect Platform
        and is not intended for normal use.
        """
        # APP-102 - adding a session for TC Exchange Admin role request
        if (self._session_exchange is None and self.api_access_id is not None
                and self.api_secret_key is not None
                and self.tc_api_path is not None):
            # support for a proxy is not a typical use case, but can be added later if needed
            self._session_exchange = TestSession(
                api_access_id=self.api_access_id,
                api_secret_key=self.api_secret_key,
                api_path=self.tc_api_path,
                logger=self.log,
            )
        return self._session_exchange

    @classmethod
    def setup_class(cls):
        """Run once before all test cases."""
        cls.initialized = True
        cls._timer_class_start = time.time()
        cls.log.title('Setup Class', '#')
        TestCase.log.data('setup class', 'started', datetime.now().isoformat())
        TestCase.log.data('setup class', 'local envs', cls.env)

    def setup_method(self):
        """Run before each test method runs."""
        self._timer_method_start = time.time()
        self._current_test = os.getenv('PYTEST_CURRENT_TEST').split(' ')[0]
        self.log.title(self._current_test, '=')
        self.log.data('setup method', 'started', datetime.now().isoformat())

        # create and log current context
        self.context = os.getenv('TC_PLAYBOOK_DB_CONTEXT', str(uuid.uuid4()))
        self.log.data('setup method', 'context', self.context)

        # setup per method instance of tcex
        args = self.default_args.copy()
        args['tc_log_file'] = os.path.join(self.test_case_log_test_dir,
                                           'setup.log')
        args[
            'tc_logger_name'] = f'tcex-{self.test_case_feature}-{self.test_case_name}'
        self.tcex = TcEx(config=args)

        # initialize new stager instance
        self._stager = self.stager_init()

        # initialize new validator instance
        self._validator = self.validator_init()

        # Adding this for batch to created the -batch and errors files
        os.makedirs(os.path.join(self.test_case_log_test_dir, 'DEBUG'),
                    exist_ok=True)

    @property
    def stager(self):
        """Return instance of Stager class."""
        return self._stager

    def stager_init(self):
        """Return instance of Stager class."""
        tc_log_file = os.path.join(self.test_case_log_test_dir, 'stage.log')

        # args data
        args = self.default_args.copy()

        # override default log level if profiled
        args['tc_log_level'] = 'warning'

        # set log path to be the feature and test case name
        args['tc_log_file'] = tc_log_file

        # set a logger name to have a logger specific for stager
        args['tc_logger_name'] = 'tcex-stager'

        tcex = TcEx(config=args)
        return Stager(tcex, logger)

    @property
    def tc_api_path(self):
        """Return TC Api Path."""
        return os.getenv('TC_API_PATH')

    @property
    def tc_token(self):
        """Return a valid API token.

        note:: requires TC >= 6.0
        """
        if self.tc_api_path is None:  # no API path, no token
            return None
        data = None
        token = None
        token_url_path = self.env_store.getenv('/ninja/tc/token/url_path',
                                               env_type='remote')
        if token_url_path is None:
            # could not retrieve URL path
            return None

        # determine the token type
        token_type = 'api'
        # per conversation with Marut, we should be able to just use api tokens
        # if self.ij.runtime_level.lower() in [
        #     'apiservice',
        #     'triggerservice',
        #     'webhooktriggerservice',
        # ]:
        #     data = {'serviceId': os.getenv('TC_TOKEN_SVC_ID', '441')}
        #     token_type = 'svc'

        # retrieve token from API using HMAC auth
        r = self.session_exchange.post(f'{token_url_path}/{token_type}',
                                       json=data,
                                       verify=True)
        if r.status_code == 200:
            token = r.json().get('data')
            self.log.data('setup', 'Using Token', token)
            self.log.data('setup', 'Token Elapsed', r.elapsed, 'trace')
        else:
            self.log.error(f'Failed to retrieve token ({r.text})')
        return token

    @classmethod
    def teardown_class(cls):
        """Run once before all test cases."""
        cls.initialized = False
        cls.log.title('Teardown Class', '^')
        TestCase.log.data('teardown class', 'finished',
                          datetime.now().isoformat())
        TestCase.log.data('teardown class', 'elapsed',
                          time.time() - cls._timer_class_start)

    def teardown_method(self):
        """Run after each test method runs."""
        if (self.enable_update_profile
                and self.ij.runtime_level.lower() not in [
                    'apiservice', 'triggerservice', 'webhooktriggerservice'
                ] and self._skip is False):
            # exit message can not be validated for a Service App
            self.profile.update_exit_message()

        # delete threatconnect staged data
        self.stager.threatconnect.delete_staged(self._staged_tc_data)

        # log running times
        self.log.data('teardown method', 'finished',
                      datetime.now().isoformat())
        self.log.data('teardown method', 'elapsed',
                      time.time() - self._timer_class_start)

        # APP-262 - close out loggers
        self.tcex.logger.shutdown()
        self.stager.tcex.logger.shutdown()
        self.validator.tcex.logger.shutdown()

        # APP-262 - disconnect redis
        self.tcex.redis_client.connection_pool.disconnect()
        self.stager.tcex.redis_client.connection_pool.disconnect()
        self.validator.tcex.redis_client.connection_pool.disconnect()

        # update profile for session data
        if self.profile:  # doesn't exist for API services
            self.profile.session_manager.update_profile()

    @property
    def test_case_data(self):
        """Return partially parsed test case data."""
        return os.getenv('PYTEST_CURRENT_TEST').split(' ')[0].split('::')

    @property
    def test_case_feature(self):
        """Return partially parsed test case data."""
        return self.test_case_data[0].split('/')[1].replace('/', '-')

    @property
    def test_case_feature_dir(self):
        """Return profile fully qualified filename."""
        return os.path.join(self._app_path, 'tests', self.test_case_feature)

    @property
    def test_case_log_feature_dir(self):
        """Return profile fully qualified filename."""
        return os.path.join(self._app_path, os.getenv('TC_LOG_PATH', 'log'),
                            self.test_case_feature)

    @property
    def test_case_log_test_dir(self):
        """Return profile fully qualified filename."""
        return os.path.join(self.test_case_log_feature_dir,
                            self.test_case_name)

    @property
    def test_case_name(self):
        """Return partially parsed test case data."""
        return self.test_case_data[-1].replace('/', '-').replace('[',
                                                                 '-').replace(
                                                                     ']', '')

    def validate_exit_message(self, test_exit_message, op='eq', **kwargs):
        """Validate App exit message."""
        if test_exit_message is not None:
            message_tc_file = os.path.join(
                os.getenv('TC_OUT_PATH', 'log'),
                self.test_case_feature,
                self.test_case_name,
                'message.tc',
            )
            app_exit_message = None
            if os.path.isfile(message_tc_file):
                with open(message_tc_file) as mh:
                    app_exit_message = mh.read()

                if app_exit_message:
                    kwargs['title'] = 'Exit Message Validation'
                    kwargs['log_app_data'] = json.dumps(app_exit_message)
                    if op == 'eq':
                        kwargs['log_test_data'] = json.dumps(test_exit_message)

                    # compare
                    passed, assert_error = self.validator.compare(
                        app_exit_message, test_exit_message, op=op, **kwargs)
                    assert passed, assert_error
                else:
                    assert False, 'The message.tc file was empty.'
            else:
                assert False, f'No message.tc file found at ({message_tc_file}).'

    @property
    def validator(self):
        """Return instance of Stager class."""
        return self._validator

    def validator_init(self):
        """Return instance of Stager class."""
        tc_log_file = os.path.join(self.test_case_log_test_dir, 'validate.log')

        # args data
        args = self.default_args.copy()

        # override default log level if profiled
        args['tc_log_level'] = 'warning'

        # set log path to be the feature and test case name
        args['tc_log_file'] = tc_log_file

        # set a logger name to have a logger specific for stager
        args['tc_logger_name'] = 'tcex-validator'

        tcex = TcEx(config=args)
        return Validator(tcex, logger)
Example #7
0
    def test_profile_add(self):
        """Test adding a profile.

        {
          "exit_codes": [
            0
          ],
          "exit_message": null,
          "inputs": {
            "optional": {
              "my_multi": [
                "one",
                "two"
              ]
            },
            "required": {
              "my_bool": false
            }
          },
          "options": {
            "autostage": {
              "enabled": false,
              "only_inputs": null
            },
            "session": {
              "blur": [],
              "enabled": false
            }
          },
          "outputs": null,
          "stage": {
            "kvstore": {}
          }
        }

        Args:
            tcex (TcEx, fixture): An instantiated instance of TcEx object.
        """
        default_args = {}
        feature = 'PyTest'
        name = f'pytest-{randint(1000, 9999)}'

        # initialize profile
        profile = Profile(default_args=default_args,
                          feature=feature,
                          name=name)

        # add profile
        profile.add()

        # load profile from disk
        profile_data = self.load_profile(f'{name}.json')

        # assert JSON file matches expected values
        assert profile_data.get('exit_codes') == [0]
        assert profile_data.get('exit_message') is None
        # inputs must match those defined in mock_app.py
        for k, _ in profile_data.get('inputs', {}).get('optional', {}).items():
            if k == 'my_multi':
                break
        else:
            assert False, 'Key my_multi not found in inputs, did someone change inputs in mock App?'
        for k, _ in profile_data.get('inputs', {}).get('required', {}).items():
            if k == 'my_bool':
                break
        else:
            assert False, 'Key my_bool not found in inputs, did someone change inputs in mock App?'
        assert profile_data.get('outputs') is None

        # cleanup
        os.remove(profile.filename)