From 9bfefe782ef0b3cb7a0f8383f9387fb5ca6c92d8 Mon Sep 17 00:00:00 2001
From: Jana Cupova <root@localhost>
Date: May 02 2022 04:55:44 +0000
Subject: [PATCH 1/3] Hub, plugins and tools inputs validation


Fixes: https://pagure.io/koji/issue/3319

---

diff --git a/hub/kojihub.py b/hub/kojihub.py
index b2e50cc..1da5f84 100644
--- a/hub/kojihub.py
+++ b/hub/kojihub.py
@@ -91,6 +91,30 @@ def xform_user_krb(entry):
     return entry
 
 
+def convert_value(value, cast=None, message=None, exc_type=None, none_allowed=False):
+    if exc_type is None:
+        exc_type = koji.ParameterError
+    if value is None:
+        if not none_allowed:
+            raise(exc_type(message or "Invalid type"))
+        else:
+            return value
+    try:
+        return cast(value)
+    except (ValueError, TypeError):
+        raise(exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
+
+
+def check_value_type(value, cast=None, message=None, exc_type=None, none_allowed=False):
+    if none_allowed:
+        if value is None:
+            return value
+    if exc_type is None:
+        exc_type = koji.ParameterError
+    if not isinstance(value, cast):
+        raise (exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
+
+
 class Task(object):
     """A task for the build hosts"""
 
@@ -116,7 +140,7 @@ class Task(object):
         ('task.weight', 'weight'))
 
     def __init__(self, id):
-        self.id = int(id)
+        self.id = convert_value(id, cast=int)
         self.logger = logging.getLogger("koji.hub.Task")
 
     def _split_fields(self, fields=None):
@@ -288,7 +312,7 @@ class Task(object):
     def setWeight(self, weight):
         """Set weight for task"""
         task_id = self.id
-        weight = float(weight)
+        weight = convert_value(weight, cast=float)
         info = self.getInfo(request=True)
         self.runCallbacks('preTaskStateChange', info, 'weight', weight)
         # access checks should be performed by calling function
@@ -299,7 +323,7 @@ class Task(object):
     def setPriority(self, priority, recurse=False):
         """Set priority for task"""
         task_id = self.id
-        priority = int(priority)
+        priority = convert_value(priority, cast=int)
         info = self.getInfo(request=True)
         self.runCallbacks('preTaskStateChange', info, 'priority', priority)
         # access checks should be performed by calling function
@@ -540,6 +564,22 @@ def make_task(method, arglist, **opts):
         priority: the priority of the task
         assign: a host_id to assign the task to
     """
+    check_value_type(method, cast=str)
+    if 'parent' in opts:
+        opts['parent'] = convert_value(opts['parent'], cast=int)
+    if 'label' in opts:
+        check_value_type(opts['label'], cast=str)
+    if 'owner' in opts:
+        if not isinstance(opts['owner'], int):
+            opts['owner'] = get_user(opts['owner'], strict=True)['id']
+    if 'arch' in opts:
+        opts['arch'] = koji.parse_arches(opts['arch'], strict=True, allow_none=True)
+    if 'priority' in opts:
+        opts['priority'] = \
+            convert_value(opts['priority'], cast=int)
+    if 'assign' in opts:
+        if not isinstance(opts['assign'], int):
+            opts['assign'] = get_host(opts['assign'], strict=True)['id']
     if 'parent' in opts:
         # for subtasks, we use some of the parent's options as defaults
         fields = ('state', 'owner', 'channel_id', 'priority', 'arch')
@@ -1389,6 +1429,8 @@ def readTaggedBuilds(tag, event=None, inherit=False, latest=False, package=None,
         'build.state = %(st_complete)i'
     ]
     if package:
+        if not isinstance(package, str):
+            package = lookup_package(package, strict=True)['name']
         clauses.append('package.name = %(package)s')
     if owner:
         clauses.append('users.name = %(owner)s')
@@ -1636,8 +1678,10 @@ def check_tag_access(tag_id, user_id=None):
     """
     if user_id is None:
         user_id = context.session.user_id
-    if user_id is None:
-        raise koji.GenericError("a user_id is required")
+        if not user_id:
+            raise koji.GenericError("a user_id is required")
+    else:
+        user_id = convert_value(user_id, cast=int)
     perms = koji.auth.get_user_perms(user_id)
     override = False
     if 'admin' in perms:
@@ -2376,12 +2420,14 @@ def edit_channel(channelInfo, **kw):
         return False
 
     if kw.get('name'):
-        if not isinstance(kw['name'], str):
-            raise koji.GenericError("new channel name must be a string")
         verify_name_internal(kw['name'])
         dup_check = get_channel(kw['name'], strict=False)
         if dup_check:
             raise koji.GenericError("channel %(name)s already exists (id=%(id)i)" % dup_check)
+    if kw.get('description'):
+        check_value_type(kw['description'], cast=str)
+    if kw.get('comment'):
+        check_value_type(kw['comment'], cast=str)
 
     update = UpdateProcessor('channels',
                              values={'channelID': channel['id']},
@@ -2433,8 +2479,7 @@ def add_channel(channel_name, description=None):
     :param str description: description of channel
     """
     context.session.assertPerm('admin')
-    if not isinstance(channel_name, str):
-        raise koji.GenericError("Channel name must be a string")
+    check_value_type(description, cast=str, none_allowed=True)
     verify_name_internal(channel_name)
     dup_check = get_channel(channel_name, strict=False)
     if dup_check:
@@ -2449,6 +2494,7 @@ def add_channel(channel_name, description=None):
 
 def set_channel_enabled(channelname, enabled=True, comment=None):
     context.session.assertPerm('host')
+    check_value_type(comment, cast=str, none_allowed=True)
     channel = get_channel(channelname)
     if not channel:
         raise koji.GenericError('No such channel: %s' % channelname)
@@ -2640,6 +2686,7 @@ def repo_init(tag, task_id=None, with_src=False, with_debuginfo=False, event=Non
     Returns a dictionary containing
         repo_id, event_id
     """
+    task_id = convert_value(task_id, cast=int, none_allowed=True)
     logger = logging.getLogger("koji.hub.repo_init")
     state = koji.REPO_INIT
     tinfo = get_tag(tag, strict=True, event=event)
@@ -2694,9 +2741,9 @@ def repo_init(tag, task_id=None, with_src=False, with_debuginfo=False, event=Non
         'tag_id': tinfo['id'],
         'task_id': task_id,
         'event_id': event_id,
-        'with_src': with_src,
-        'with_separate_src': with_separate_src,
-        'with_debuginfo': with_debuginfo,
+        'with_src': bool(with_src),
+        'with_separate_src': bool(with_separate_src),
+        'with_debuginfo': bool(with_debuginfo),
     }
     with open('%s/repo.json' % repodir, 'wt', encoding='utf-8') as fp:
         json.dump(repo_info, fp, indent=2)
@@ -2823,9 +2870,12 @@ def _write_maven_repo_metadata(destdir, artifacts):
 def dist_repo_init(tag, keys, task_opts):
     """Create a new repo entry in the INIT state, return full repo data"""
     state = koji.REPO_INIT
+    check_value_type(keys, cast=list)
     tinfo = get_tag(tag, strict=True)
     tag_id = tinfo['id']
+    check_value_type(task_opts, cast=dict)
     event = task_opts.get('event')
+    event = convert_value(event, cast=int, none_allowed=True)
     volume = task_opts.get('volume')
     if volume is not None:
         volume = lookup_name('volume', volume, strict=True)['name']
@@ -2853,6 +2903,7 @@ def dist_repo_init(tag, keys, task_opts):
         os.symlink(relpath, basedir)
     # handle comps
     if task_opts.get('comps'):
+        check_value_type(task_opts['comps'], cast=str)
         groupsdir = joinpath(repodir, 'groups')
         koji.ensuredir(groupsdir)
         shutil.copyfile(joinpath(koji.pathinfo.work(),
@@ -2877,6 +2928,7 @@ def dist_repo_init(tag, keys, task_opts):
 
 def repo_set_state(repo_id, state, check=True):
     """Set repo state"""
+    repo_id = convert_value(repo_id, cast=int)
     if check:
         # The repo states are sequential, going backwards makes no sense
         q = """SELECT state FROM repo WHERE id = %(repo_id)s FOR UPDATE"""
@@ -2934,6 +2986,7 @@ def repo_delete(repo_id):
     """Attempt to mark repo deleted, return number of references
 
     If the number of references is nonzero, no change is made"""
+    repo_id = convert_value(repo_id, cast=int)
     # get a row lock on the repo
     q = """SELECT state FROM repo WHERE id = %(repo_id)s FOR UPDATE"""
     _singleValue(q, locals())
@@ -3235,14 +3288,12 @@ def name_or_id_clause(table, info):
     """
     if isinstance(info, dict):
         if 'id' in info:
-            try:
-                info = int(info['id'])
-            except (ValueError, TypeError):
-                raise koji.ParameterError('Invalid name or id value: %r' % info)
+            info = convert_value(info['id'], cast=int,
+                                 message=fr"Invalid name or id value: {info}")
         elif 'name' in info:
             info = info['name']
         else:
-            raise koji.ParameterError('Invalid name or id value: %r' % info)
+            raise koji.ParameterError(fr'Invalid name or id value: {info}')
     if isinstance(info, int):
         clause = f"({table}.id = %({table}_id)s)"
         values = {f"{table}_id": info}
@@ -3250,7 +3301,7 @@ def name_or_id_clause(table, info):
         clause = f"({table}.name = %({table}_name)s)"
         values = {f"{table}_name": info}
     else:
-        raise koji.ParameterError('Invalid name or id value: %r' % info)
+        raise koji.ParameterError(fr"Invalid name or id value: {info}")
 
     return clause, values
 
@@ -3557,6 +3608,10 @@ def _edit_tag(tagInfo, **kwargs):
     if not context.opts.get('EnableMaven') \
             and dslice(kwargs, ['maven_support', 'maven_include_all'], strict=False):
         raise koji.GenericError("Maven support not enabled")
+    if kwargs.get('remove_extra'):
+        check_value_type(kwargs['remove_extra'], cast=list, none_allowed=True)
+    if kwargs.get('block_extra'):
+        check_value_type(kwargs['block_extra'], cast=list, none_allowed=True)
 
     tag = get_tag(tagInfo, strict=True)
     if 'perm' in kwargs and 'perm_id' not in kwargs:
@@ -3882,6 +3937,8 @@ def add_external_repo_to_tag(tag_info, repo_info, priority, merge_mode='koji', a
     if arches is not None:
         arches = koji.parse_arches(arches, strict=True)
 
+    priority = convert_value(priority, cast=int)
+
     tag_repos = get_tag_external_repos(tag_info=tag_id)
     if [tr for tr in tag_repos if tr['external_repo_id'] == repo_id]:
         raise koji.GenericError('tag %s already associated with external repo %s' %
@@ -4442,8 +4499,7 @@ def get_next_release(build_info, incr=1):
     :raises: BuildError if the latest build uses a release value that Koji
              does not know how to increment.
     """
-    if not isinstance(incr, int):
-        raise koji.ParameterError("incr parameter must be an integer")
+    incr = convert_value(incr, cast=int, message='incr parameter must be an integer')
     values = {
         'name': build_info['name'],
         'version': build_info['version'],
@@ -5522,6 +5578,10 @@ def edit_host(hostInfo, **kw):
     if not changes:
         return False
 
+    for change in changes:
+        if change in ['description', 'comment', 'arches']:
+            check_value_type(kw[change], cast=str)
+
     update = UpdateProcessor('host_config', values=host, clauses=['host_id = %(id)i'])
     update.make_revoke()
     update.execute()
@@ -5675,7 +5735,7 @@ def query_buildroots(hostID=None, tagID=None, state=None, rpmID=None, archiveID=
 
 def get_buildroot(buildrootID, strict=False):
     """Return information about a buildroot.  buildrootID must be an int ID."""
-
+    buildrootID = convert_value(buildrootID, cast=int)
     result = query_buildroots(buildrootID=buildrootID)
     if len(result) == 0:
         if strict:
@@ -5741,6 +5801,7 @@ def list_channels(hostID=None, event=None, enabled=None):
 
 
 def new_package(name, strict=True):
+    verify_name_internal(name)
     c = context.cnx.cursor()
     # TODO - table lock?
     # check for existing
@@ -6171,6 +6232,10 @@ def import_build(srpm, rpms, brmap=None, task_id=None, build_id=None, logs=None)
     """
     if brmap is None:
         brmap = {}
+    else:
+        check_value_type(brmap, cast=dict)
+    check_value_type(srpm, cast=str)
+    check_value_type(rpms, cast=list)
     koji.plugin.run_callbacks('preImport', type='build', srpm=srpm, rpms=rpms, brmap=brmap,
                               task_id=task_id, build_id=build_id, build=None, logs=logs)
     uploadpath = koji.pathinfo.work()
@@ -6536,7 +6601,6 @@ class CG_Importer(object):
 
     def get_metadata(self, metadata, directory):
         """Get the metadata from the args"""
-
         if isinstance(metadata, dict):
             self.metadata = metadata
             try:
@@ -6548,8 +6612,7 @@ class CG_Importer(object):
         if metadata is None:
             # default to looking for uploaded file
             metadata = 'metadata.json'
-        if not isinstance(metadata, str):
-            raise koji.GenericError("Invalid type for metadata value: %r" % type(metadata))
+        check_value_type(metadata, cast=str)
         if metadata.endswith('.json'):
             # handle uploaded metadata
             workdir = koji.pathinfo.work()
@@ -7324,8 +7387,7 @@ def get_archive_type(filename=None, type_name=None, type_id=None, strict=False):
     elif type_name:
         return _get_archive_type_by_name(type_name, strict)
     elif filename:
-        # we handle that below
-        pass
+        check_value_type(filename, cast=str)
     else:
         raise koji.GenericError('one of filename, type_name, or type_id must be specified')
 
@@ -7365,6 +7427,8 @@ def add_archive_type(name, description, extensions):
     """
     context.session.assertPerm('admin')
     verify_name_internal(name)
+    check_value_type(description, cast=str)
+    check_value_type(extensions, cast=str)
     data = {'name': name,
             'description': description,
             'extensions': extensions,
@@ -8774,6 +8838,9 @@ def tag_notification(is_successful, tag_id, from_id, build_id, user_id, ignore_s
                      failure_msg=''):
     if context.opts.get('DisableNotifications'):
         return
+    if user_id:
+        if not isinstance(user_id, int):
+            user_id = get_user(user_id, strict=True)
     if is_successful:
         state = koji.BUILD_STATES['COMPLETE']
     else:
@@ -10320,6 +10387,7 @@ def policy_data_from_task_args(method, arglist):
             # newRepo has a 'src' parameter that means something else
             break
         if k in params:
+            check_value_type(params[k], cast=str)
             policy_data['source'] = params.get(k)
             break
     # parameters that indicate build target
@@ -10482,8 +10550,10 @@ class RootExports(object):
         context.session.assertPerm('host')
         if options is None:
             args = []
-        else:
+        elif isinstance(options, dict):
             args = [options]
+        else:
+            raise koji.ParameterError('Invalid type of options: %s' % type(options))
         return make_task('restartHosts', args, priority=priority)
 
     def build(self, src, target, opts=None, priority=None, channel=None):
@@ -10499,6 +10569,7 @@ class RootExports(object):
         if not opts:
             opts = {}
         taskOpts = {}
+        check_value_type(src, cast=str)
         if priority:
             if priority < 0:
                 if not context.session.hasPerm('admin'):
@@ -10522,6 +10593,7 @@ class RootExports(object):
         Returns a list of all the dependent task ids
         """
         context.session.assertLogin()
+        check_value_type(srcs, cast=list)
         if not opts:
             opts = {}
         taskOpts = {}
@@ -10550,6 +10622,7 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableMaven'):
             raise koji.GenericError("Maven support not enabled")
+        check_value_type(url, cast=str)
         if not opts:
             opts = {}
         taskOpts = {}
@@ -10586,13 +10659,13 @@ class RootExports(object):
         if not opts:
             opts = {}
 
+        check_value_type(url, cast=str)
+
         build = self.getBuild(build, strict=True)
         if list_rpms(build['id']) and not (opts.get('scratch') or opts.get('create_build')):
             raise koji.PreBuildError('wrapper rpms for %s have already been built' %
                                      koji.buildLabel(build))
-        build_target = self.getBuildTarget(target)
-        if not build_target:
-            raise koji.PreBuildError('no such build target: %s' % target)
+        build_target = self.getBuildTarget(target, strict=True)
         build_tag = self.getTag(build_target['build_tag'], strict=True)
         repo_info = self.getRepo(build_tag['id'])
         if not repo_info:
@@ -10602,7 +10675,8 @@ class RootExports(object):
         taskOpts = {}
         if priority:
             taskOpts['priority'] = koji.PRIO_DEFAULT + priority
-        taskOpts['channel'] = channel
+        if channel:
+            taskOpts['channel'] = channel
 
         return make_task('wrapperRPM', [url, build_target, build, None, opts], **taskOpts)
 
@@ -10621,6 +10695,7 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableMaven'):
             raise koji.GenericError("Maven support not enabled")
+        check_value_type(builds, cast=list)
         taskOpts = {}
         if priority:
             if priority < 0:
@@ -10650,7 +10725,9 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableWin'):
             raise koji.GenericError("Windows support not enabled")
-        targ_info = self.getBuildTarget(target)
+        check_value_type(vm, cast=str)
+        check_value_type(url, cast=str)
+        targ_info = get_build_target(target, strict=True)
         policy_data = {'vm_name': vm,
                        'tag': targ_info['dest_tag']}
         assert_policy('vm', policy_data)
@@ -10676,6 +10753,8 @@ class RootExports(object):
 
         if img_type not in ('livecd', 'appliance', 'livemedia'):
             raise koji.GenericError('Unrecognized image type: %s' % img_type)
+        for i in [name, ksfile, version]:
+            check_value_type(i, cast=str)
 
         context.session.assertPerm(img_type)
 
@@ -10723,6 +10802,8 @@ class RootExports(object):
         """
         Create an image using a kickstart file and group package list.
         """
+        for i in [name, inst_tree, version]:
+            check_value_type(i, cast=str)
         context.session.assertPerm('image')
         taskOpts = {'channel': 'image'}
         if priority:
@@ -10986,6 +11067,9 @@ class RootExports(object):
     def downloadTaskOutput(self, taskID, fileName, offset=0, size=-1, volume=None):
         """Download the file with the given name, generated by the task with the
         given ID."""
+        size = convert_value(size, cast=int)
+        if volume:
+            volume = self.getVolume(volume, strict=True)['name']
         if '..' in fileName:
             raise koji.GenericError('Invalid file name: %s' % fileName)
         filePath = '%s/%s/%s' % (koji.pathinfo.work(volume),
@@ -12493,6 +12577,7 @@ class RootExports(object):
             verify_name_internal(permission)
         if description is not None and not create:
             raise koji.GenericError('Description should be specified only with create.')
+        check_value_type(description, cast=str, none_allowed=True)
         user_id = get_user(userinfo, strict=True)['id']
         perm = lookup_perm(permission, strict=(not create), create=create)
         perm_id = perm['id']
@@ -12526,6 +12611,7 @@ class RootExports(object):
     def editPermission(self, permission, description):
         """Edit a permission description"""
         context.session.assertPerm('admin')
+        check_value_type(description, cast=str)
         perm = lookup_perm(permission, strict=True)
         perm_id = perm['id']
         update = UpdateProcessor('permissions', clauses=['id=%(perm_id)i'],
@@ -12550,22 +12636,18 @@ class RootExports(object):
             raise koji.GenericError('user already exists: %s' % username)
         if krb_principal and get_user_by_krb_principal(krb_principal):
             raise koji.GenericError(
-                'user with this Kerberos principal already exists: %s'
-                % krb_principal)
-
-        return context.session.createUser(username, status=status,
-                                          krb_principal=krb_principal)
+                'user with this Kerberos principal already exists: %s' % krb_principal)
+        status = convert_value(status, cast=int, none_allowed=True)
+        return context.session.createUser(username, status=status, krb_principal=krb_principal)
 
     def addUserKrbPrincipal(self, user, krb_principal):
         """Add a Kerberos principal for user"""
         context.session.assertPerm('admin')
         userinfo = get_user(user, strict=True)
-        if not krb_principal:
-            raise koji.GenericError('krb_principal must be specified')
+        verify_name_user(krb=krb_principal)
         if get_user_by_krb_principal(krb_principal):
             raise koji.GenericError(
-                'user with this Kerberos principal already exists: %s'
-                % krb_principal)
+                'user with this Kerberos principal already exists: %s' % krb_principal)
         return context.session.setKrbPrincipal(userinfo['name'], krb_principal)
 
     def removeUserKrbPrincipal(self, user, krb_principal):
@@ -13036,6 +13118,7 @@ class RootExports(object):
         # builder user can already exist, if host tried to log in before adding into db
         userinfo = {'name': hostname}
         if krb_principal:
+            verify_name_user(krb=krb_principal)
             userinfo['krb_principal'] = krb_principal
         user = get_user(userInfo=userinfo)
         if user:
@@ -13757,6 +13840,7 @@ class BuildRoot(object):
         self.is_standard = True
 
     def new(self, host, repo, arch, task_id=None, ctype='chroot'):
+        arch = koji.parse_arches(arch, strict=True, allow_none=True)
         state = koji.BR_STATES['INIT']
         br_id = _singleValue("SELECT nextval('buildroot_id_seq')", strict=True)
         insert = InsertProcessor('buildroot', data={'id': br_id})
@@ -14016,7 +14100,7 @@ class Host(object):
 
     def taskSetWait(self, parent, tasks):
         """Mark task waiting and subtasks awaited"""
-
+        check_value_type(tasks, cast=list, none_allowed=True)
         # mark parent as waiting
         update = UpdateProcessor('task', clauses=['id=%(parent)s'], values=locals())
         update.set(waiting=True)
@@ -14134,6 +14218,7 @@ class Host(object):
 
     def updateHost(self, task_load, ready):
         host_data = get_host(self.id)
+        task_load = convert_value(task_load, cast=float)
         if task_load != host_data['task_load'] or ready != host_data['ready']:
             c = context.cnx.cursor()
             id = self.id
@@ -14416,6 +14501,7 @@ class HostExports(object):
 
     def moveImageBuildToScratch(self, task_id, results):
         """move a completed image scratch build into place"""
+        check_value_type(results, cast=dict)
         host = Host()
         host.verify()
         task = Task(task_id)
@@ -14861,6 +14947,8 @@ class HostExports(object):
         pkg_id = build['package_id']
         tag_id = get_tag(tag, strict=True)['id']
         user_id = task.getOwner()
+        if not isinstance(fromtag, str):
+            tag = get_tag(tag, strict=True)['name']
         policy_data = {'tag': tag, 'build': build, 'fromtag': fromtag}
         policy_data['user_id'] = user_id
         if fromtag is None:
@@ -15156,6 +15244,7 @@ class HostExports(object):
         host = Host()
         host.verify()
         rinfo = repo_info(repo_id, strict=True)
+        check_value_type(data, cast=dict)
         koji.plugin.run_callbacks('preRepoDone', repo=rinfo, data=data, expire=expire)
         if rinfo['state'] != koji.REPO_INIT:
             raise koji.GenericError("Repo %(id)s not in INIT state (got %(state)s)" % rinfo)
diff --git a/plugins/hub/kiwi.py b/plugins/hub/kiwi.py
index fd4877c..57c0c96 100644
--- a/plugins/hub/kiwi.py
+++ b/plugins/hub/kiwi.py
@@ -18,10 +18,19 @@ koji.tasks.LEGACY_SIGNATURES['createKiwiImage'] = [
 def kiwiBuild(target, arches, desc_url, desc_path, optional_arches=None, profile=None,
               scratch=False, priority=None, make_prep=False, repos=None, release=None):
     context.session.assertPerm('image')
+    for i in [desc_url, desc_path, profile, release]:
+        if i is not None:
+            kojihub.check_value_type(i, cast=str)
+    if repos:
+        kojihub.check_value_type(repos, cast=list)
+    kojihub.get_build_targets(target, strict=True)
+    arches = koji.parse_arches(arches, strict=True, allow_none=False)
+    optional_arches = koji.parse_arches(optional_arches, strict=True, allow_none=True)
     taskOpts = {
         'channel': 'image',
     }
     if priority:
+        priority = kojihub.convert_value(priority, cast=int)
         if priority < 0:
             if not context.session.hasPerm('admin'):
                 raise koji.ActionNotAllowed(
@@ -31,10 +40,10 @@ def kiwiBuild(target, arches, desc_url, desc_path, optional_arches=None, profile
     opts = {
         'optional_arches': optional_arches,
         'profile': profile,
-        'scratch': scratch,
+        'scratch': bool(scratch),
         'release': release,
         'repos': repos or [],
-        'make_prep': make_prep,
+        'make_prep': bool(make_prep),
     }
     return kojihub.make_task('kiwiBuild',
                              [target, arches, desc_url, desc_path, opts],
diff --git a/plugins/hub/runroot_hub.py b/plugins/hub/runroot_hub.py
index 847cf44..c6f3639 100644
--- a/plugins/hub/runroot_hub.py
+++ b/plugins/hub/runroot_hub.py
@@ -30,12 +30,17 @@ def get_channel_arches(channel):
 def runroot(tagInfo, arch, command, channel=None, **opts):
     """ Create a runroot task """
     context.session.assertPerm('runroot')
+    arch = koji.parse_arches(arch, strict=True, allow_none=False)
+    kojihub.check_value_type(command, cast=str)
     taskopts = {
         'priority': 15,
         'arch': arch,
     }
 
-    taskopts['channel'] = channel or 'runroot'
+    if channel is None:
+        taskopts['channel'] = 'runroot'
+    else:
+        taskopts['channel'] = kojihub.get_channel(channel, strict=True)['name']
 
     tag = kojihub.get_tag(tagInfo, strict=True)
     if arch == 'noarch':
diff --git a/plugins/hub/save_failed_tree.py b/plugins/hub/save_failed_tree.py
index 855eaa8..45c5836 100644
--- a/plugins/hub/save_failed_tree.py
+++ b/plugins/hub/save_failed_tree.py
@@ -22,7 +22,7 @@ def saveFailedTree(buildrootID, full=False, **opts):
     global config, allowed_methods
 
     # let it raise errors
-    buildrootID = int(buildrootID)
+    buildrootID = kojihub.convert_value(buildrootID, cast=int)
     full = bool(full)
 
     # read configuration only once
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 5ce0e4d..2daf98a 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -21,7 +21,8 @@ from kojihub import (  # noqa: E402
     get_tag,
     get_user,
     nextval,
-    policy_get_user
+    policy_get_user,
+    check_value_type
 )
 
 CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
@@ -270,9 +271,11 @@ def editSideTag(sidetag, debuginfo=None, rpm_macros=None, remove_rpm_macros=None
     if debuginfo is not None:
         kwargs['extra']['with_debuginfo'] = bool(debuginfo)
     if rpm_macros is not None:
+        check_value_type(rpm_macros, cast=dict)
         for macro, value in rpm_macros.items():
             kwargs['extra']['rpm.macro.%s' % macro] = value
     if remove_rpm_macros is not None:
+        check_value_type(remove_rpm_macros, cast=list)
         kwargs['remove_extra'] = ['rpm.macro.%s' % m for m in remove_rpm_macros]
 
     _edit_tag(sidetag['id'], **kwargs)
diff --git a/tests/test_hub/test_add_archivetype.py b/tests/test_hub/test_add_archivetype.py
index 49cd35a..fabeab3 100644
--- a/tests/test_hub/test_add_archivetype.py
+++ b/tests/test_hub/test_add_archivetype.py
@@ -10,54 +10,74 @@ IP = kojihub.InsertProcessor
 
 class TestAddArchiveType(unittest.TestCase):
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub._multiRow')
-    @mock.patch('kojihub.get_archive_type')
-    @mock.patch('kojihub.InsertProcessor')
-    def test_add_archive_type(self, InsertProcessor, get_archive_type, _multiRow,
-                              verify_name_internal):
-        # Not sure why mock can't patch kojihub.context, so we do this
-        session = kojihub.context.session = mock.MagicMock()
-        mocks = [InsertProcessor, get_archive_type, session]
-        # It seems MagicMock will not automatically handle attributes that
-        # start with "assert"
-        session.assertPerm = mock.MagicMock()
-        verify_name_internal.return_value = None
-
-        # expected case
-        get_archive_type.return_value = None
-        insert = InsertProcessor.return_value
+    def setUp(self):
+
+        self.context = mock.patch('kojihub.context').start()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.exports = kojihub.RootExports()
+        self.channel_name = 'test-channel'
+        self.description = 'test-description'
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.insert_execute = mock.MagicMock()
+        self.verify_name_internal = mock.patch('kojihub.verify_name_internal').start()
+        self.get_archive_type = mock.patch('kojihub.get_archive_type').start()
+        self._multiRow = mock.patch('kojihub._multiRow').start()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = self.insert_execute
+        self.inserts.append(insert)
+        return insert
+
+    def test_add_archive_type_valid(self):
+        self.verify_name_internal.return_value = None
+        self.get_archive_type.return_value = None
         kojihub.add_archive_type('deb', 'Debian package', 'deb')
-        InsertProcessor.assert_called_once()
-        insert.execute.assert_called_once()
-
-        args, kwargs = InsertProcessor.call_args
-        ip = IP(*args, **kwargs)
-        self.assertEqual(ip.table, 'archivetypes')
-        self.assertEqual(ip.data, {'name': 'deb',
-                                   'description': 'Debian package',
-                                   'extensions': 'deb'})
-        self.assertEqual(ip.rawdata, {})
-        session.assertPerm.assert_called_with('admin')
-
-        for m in mocks:
-            m.reset_mock()
-        session.assertPerm = mock.MagicMock()
-
-        # already exists
-        get_archive_type.return_value = True
+
+        self.assertEqual(len(self.inserts), 1)
+        insert = self.inserts[0]
+        self.assertEqual(insert.table, 'archivetypes')
+        self.assertEqual(insert.data, {'name': 'deb',
+                                       'description': 'Debian package',
+                                       'extensions': 'deb'})
+        self.assertEqual(insert.rawdata, {})
+        self.context.session.assertPerm.assert_called_with('admin')
+
+    def test_add_archive_type_already_exists(self):
+        self.get_archive_type.return_value = True
         with self.assertRaises(koji.GenericError):
             kojihub.add_archive_type('deb', 'Debian package', 'deb')
-        InsertProcessor.assert_not_called()
-        session.assertPerm.assert_called_with('admin')
+        self.assertEqual(len(self.inserts), 0)
+        self.context.session.assertPerm.assert_called_with('admin')
+
+    def test_add_archive_type_invalid_value_type(self):
+        self.verify_name_internal.return_value = None
+        description = ['Debian package']
+        with self.assertRaises(koji.ParameterError) as ex:
+            kojihub.add_archive_type('deb', description, 'deb')
+        self.assertEqual(f"Invalid type for value '{description}': {type(description)}",
+                         str(ex.exception))
+
+    def test_add_archive_type_invalid_value_extensions(self):
+        extensions = ['deb']
+        with self.assertRaises(koji.ParameterError) as ex:
+            kojihub.add_archive_type('deb', 'Debian package', extensions)
+        self.assertEqual(f"Invalid type for value '{extensions}': {type(extensions)}",
+                         str(ex.exception))
 
+    def test_add_archive_type_wrong_name_verify(self):
         # name is longer as expected
         new_archive_type = 'new-archive-type+'
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             kojihub.add_archive_type(new_archive_type, 'Debian package', 'deb')
 
         # not except regex rules
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             kojihub.add_archive_type(new_archive_type, 'Debian package', 'deb')
diff --git a/tests/test_hub/test_add_channel.py b/tests/test_hub/test_add_channel.py
index 5588c7b..a2506c6 100644
--- a/tests/test_hub/test_add_channel.py
+++ b/tests/test_hub/test_add_channel.py
@@ -22,6 +22,9 @@ class TestAddChannel(unittest.TestCase):
                                           side_effect=self.getInsert).start()
         self.inserts = []
         self.insert_execute = mock.MagicMock()
+        self.verify_name_internal = mock.patch('kojihub.verify_name_internal').start()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self._singleValue = mock.patch('kojihub._singleValue').start()
 
     def tearDown(self):
         mock.patch.stopall()
@@ -32,25 +35,19 @@ class TestAddChannel(unittest.TestCase):
         self.inserts.append(insert)
         return insert
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    @mock.patch('kojihub._singleValue')
-    def test_add_channel_exists(self, _singleValue, get_channel, verify_name_internal):
-        verify_name_internal.return_value = None
-        get_channel.return_value = {'id': 123, 'name': self.channel_name}
+    def test_add_channel_exists(self):
+        self.verify_name_internal.return_value = None
+        self.get_channel.return_value = {'id': 123, 'name': self.channel_name}
         with self.assertRaises(koji.GenericError):
             self.exports.addChannel(self.channel_name)
-        get_channel.assert_called_once_with(self.channel_name, strict=False)
-        _singleValue.assert_not_called()
+        self.get_channel.assert_called_once_with(self.channel_name, strict=False)
+        self._singleValue.assert_not_called()
         self.assertEqual(len(self.inserts), 0)
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    @mock.patch('kojihub._singleValue')
-    def test_add_channel_valid(self, _singleValue, get_channel, verify_name_internal):
-        get_channel.return_value = {}
-        _singleValue.side_effect = [12]
-        verify_name_internal.return_value = None
+    def test_add_channel_valid(self):
+        self.get_channel.return_value = {}
+        self._singleValue.side_effect = [12]
+        self.verify_name_internal.return_value = None
 
         r = self.exports.addChannel(self.channel_name, description=self.description)
         self.assertEqual(r, 12)
@@ -62,21 +59,20 @@ class TestAddChannel(unittest.TestCase):
         self.assertEqual(insert.table, 'channels')
 
         self.context.session.assertPerm.assert_called_once_with('admin')
-        get_channel.assert_called_once_with(self.channel_name, strict=False)
-        self.assertEqual(_singleValue.call_count, 1)
-        _singleValue.assert_has_calls([
+        self.get_channel.assert_called_once_with(self.channel_name, strict=False)
+        self.assertEqual(self._singleValue.call_count, 1)
+        self._singleValue.assert_has_calls([
             mock.call("SELECT nextval('channels_id_seq')", strict=True)
         ])
 
-    @mock.patch('kojihub.verify_name_internal')
-    def test_add_channel_wrong_format(self, verify_name_internal):
+    def test_add_channel_wrong_name(self):
         # name is longer as expected
         channel_name = 'test-channel+'
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.addChannel(channel_name)
 
         # not except regex rules
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.addChannel(channel_name)
diff --git a/tests/test_hub/test_add_external_repo_to_tag.py b/tests/test_hub/test_add_external_repo_to_tag.py
index 0a47b36..189516e 100644
--- a/tests/test_hub/test_add_external_repo_to_tag.py
+++ b/tests/test_hub/test_add_external_repo_to_tag.py
@@ -1,4 +1,5 @@
 import unittest
+import mock
 
 import koji
 import kojihub
@@ -6,8 +7,26 @@ import kojihub
 
 class TestAddExternalRepoToTag(unittest.TestCase):
 
+    def setUp(self):
+        self.tag_name = 'test-tag'
+        self.get_tag = mock.patch('kojihub.get_tag').start()
+        self.get_external_repo = mock.patch('kojihub.get_external_repo').start()
+        self.tag_info = {'id': 1, 'name': self.tag_name}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
     def test_with_wrong_merge_mode(self):
         merge_mode = 'test-mode'
         with self.assertRaises(koji.GenericError) as cm:
-            kojihub.add_external_repo_to_tag('tag', 'repo', 1, merge_mode=merge_mode)
-        self.assertEqual('No such merge mode: %s' % merge_mode, str(cm.exception))
+            kojihub.add_external_repo_to_tag(self.tag_name, 'repo', 1, merge_mode=merge_mode)
+        self.assertEqual(f"No such merge mode: {merge_mode}", str(cm.exception))
+
+    def test_priority_not_int(self):
+        priority = 'test-priority'
+        self.get_tag.return_value = self.tag_info
+        self.get_external_repo.return_value = {'id': 123}
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.add_external_repo_to_tag(self.tag_name, 'repo', priority, merge_mode=None)
+        self.assertEqual(f"Invalid type for value '{priority}': {type(priority)}",
+                         str(cm.exception))
diff --git a/tests/test_hub/test_add_external_rpm.py b/tests/test_hub/test_add_external_rpm.py
index 7aa928b..e36f808 100644
--- a/tests/test_hub/test_add_external_rpm.py
+++ b/tests/test_hub/test_add_external_rpm.py
@@ -20,20 +20,20 @@ class TestAddExternalRPM(unittest.TestCase):
         self.nextval = mock.patch('kojihub.nextval').start()
         self.Savepoint = mock.patch('kojihub.Savepoint').start()
         self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
-                side_effect=self.getInsert).start()
+                                          side_effect=self.getInsert).start()
         self.inserts = []
         self.insert_execute = mock.MagicMock()
 
         self.rpminfo = {
-                'name': 'NAME',
-                'version': 'VERSION',
-                'release': 'RELEASE',
-                'epoch': None,
-                'arch': 'noarch',
-                'payloadhash': 'fakehash',
-                'size': 42,
-                'buildtime': 0,
-                }
+            'name': 'NAME',
+            'version': 'VERSION',
+            'release': 'RELEASE',
+            'epoch': None,
+            'arch': 'noarch',
+            'payloadhash': 'fakehash',
+            'size': 42,
+            'buildtime': 0,
+        }
         self.repo = 'myrepo'
 
     def tearDown(self):
@@ -63,8 +63,9 @@ class TestAddExternalRPM(unittest.TestCase):
         rpminfo = self.rpminfo.copy()
         del rpminfo['size']
 
-        with self.assertRaises(koji.GenericError):
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(rpminfo, self.repo)
+        self.assertEqual(f"size field missing: {rpminfo}", str(ex.exception))
 
         self.get_rpm.assert_not_called()
         self.nextval.assert_not_called()
@@ -73,8 +74,10 @@ class TestAddExternalRPM(unittest.TestCase):
         rpminfo = self.rpminfo.copy()
         rpminfo['size'] = ['invalid type']
 
-        with self.assertRaises(koji.GenericError):
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(rpminfo, self.repo)
+        self.assertEqual(f"Invalid value for size: {rpminfo['size']}",
+                         str(ex.exception))
 
         self.get_rpm.assert_not_called()
         self.nextval.assert_not_called()
@@ -88,8 +91,11 @@ class TestAddExternalRPM(unittest.TestCase):
         self.get_external_repo_id.return_value = mock.sentinel.repo_id
 
         # call it (default is strict)
-        with self.assertRaises(koji.GenericError):
+        nvra = "%(name)s-%(version)s-%(release)s.%(arch)s" % self.rpminfo
+        disp = f"{nvra}@{self.repo}"
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(self.rpminfo, self.repo)
+        self.assertEqual(f"external rpm already exists: {disp}", str(ex.exception))
 
         self.assertEqual(len(self.inserts), 0)
         self.nextval.assert_not_called()
@@ -103,8 +109,12 @@ class TestAddExternalRPM(unittest.TestCase):
 
         # different hash
         prev['payloadhash'] = 'different hash'
-        with self.assertRaises(koji.GenericError):
+        nvra = "%(name)s-%(version)s-%(release)s.%(arch)s" % self.rpminfo
+        disp = f"{nvra}@{self.repo}"
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(self.rpminfo, self.repo, strict=False)
+        self.assertEqual(f"hash changed for external rpm: {disp} (different hash -> fakehash)",
+                         str(ex.exception))
 
         self.assertEqual(len(self.inserts), 0)
         self.nextval.assert_not_called()
@@ -118,8 +128,11 @@ class TestAddExternalRPM(unittest.TestCase):
         self.insert_execute.side_effect = FakeException('insert failed')
 
         # call it (default is strict)
-        with self.assertRaises(koji.GenericError):
+        nvra = "%(name)s-%(version)s-%(release)s.%(arch)s" % self.rpminfo
+        disp = f"{nvra}@{self.repo}"
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(self.rpminfo, self.repo)
+        self.assertEqual(f"external rpm already exists: {disp}", str(ex.exception))
 
         self.assertEqual(len(self.inserts), 1)
         self.nextval.assert_called_once()
@@ -139,8 +152,12 @@ class TestAddExternalRPM(unittest.TestCase):
         self.nextval.reset_mock()
         self.get_rpm.side_effect = [None, prev]
         prev['payloadhash'] = 'different hash'
-        with self.assertRaises(koji.GenericError):
+        nvra = "%(name)s-%(version)s-%(release)s.%(arch)s" % self.rpminfo
+        disp = f"{nvra}@{self.repo}"
+        with self.assertRaises(koji.GenericError) as ex:
             kojihub.add_external_rpm(self.rpminfo, self.repo, strict=False)
+        self.assertEqual(f"hash changed for external rpm: {disp} (different hash -> fakehash)",
+                         str(ex.exception))
 
         self.assertEqual(len(self.inserts), 1)
         self.nextval.assert_called_once()
@@ -149,10 +166,9 @@ class TestAddExternalRPM(unittest.TestCase):
         self.inserts[:] = []
         self.nextval.reset_mock()
         self.get_rpm.side_effect = [None, None]
-        with self.assertRaises(FakeException):
+        with self.assertRaises(FakeException) as ex:
             kojihub.add_external_rpm(self.rpminfo, self.repo, strict=False)
+        self.assertEqual('insert failed', str(ex.exception))
 
         self.assertEqual(len(self.inserts), 1)
         self.nextval.assert_called_once()
-
-
diff --git a/tests/test_hub/test_add_host.py b/tests/test_hub/test_add_host.py
index 93a0ae5..0592d1f 100644
--- a/tests/test_hub/test_add_host.py
+++ b/tests/test_hub/test_add_host.py
@@ -36,32 +36,31 @@ class TestAddHost(unittest.TestCase):
         self.context.session.assertPerm = mock.MagicMock()
         self.context.opts = {'HostPrincipalFormat': '-%s-'}
         self.exports = kojihub.RootExports()
+        self.verify_host_name = mock.patch('kojihub.verify_host_name').start()
+        self.verify_name_user = mock.patch('kojihub.verify_name_user').start()
+        self._dml = mock.patch('kojihub._dml').start()
+        self.get_host = mock.patch('kojihub.get_host').start()
+        self._singleValue = mock.patch('kojihub._singleValue').start()
+        self.get_user = mock.patch('kojihub.get_user').start()
 
     def tearDown(self):
         mock.patch.stopall()
 
-    @mock.patch('kojihub.verify_host_name')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub.get_host')
-    @mock.patch('kojihub._singleValue')
-    def test_add_host_exists(self, _singleValue, get_host, _dml, verify_host_name):
-        verify_host_name.return_value = None
-        get_host.return_value = {'id': 123}
+    def test_add_host_exists(self):
+        self.verify_host_name.return_value = None
+        self.get_host.return_value = {'id': 123}
         with self.assertRaises(koji.GenericError):
             self.exports.addHost('hostname', ['i386', 'x86_64'])
-        _dml.assert_not_called()
-        get_host.assert_called_once_with('hostname')
-        _singleValue.assert_not_called()
-
-    @mock.patch('kojihub.verify_host_name')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub.get_host')
-    @mock.patch('kojihub._singleValue')
-    def test_add_host_valid(self, _singleValue, get_host, _dml, verify_host_name):
-        verify_host_name.return_value = None
-        get_host.return_value = {}
-        _singleValue.side_effect = [333, 12]
+        self._dml.assert_not_called()
+        self.get_host.assert_called_once_with('hostname')
+        self._singleValue.assert_not_called()
+
+    def test_add_host_valid(self):
+        self.verify_host_name.return_value = None
+        self.get_host.return_value = {}
+        self._singleValue.side_effect = [333, 12]
         self.context.session.createUser.return_value = 456
+        self.get_user.return_value = None
 
         r = self.exports.addHost('hostname', ['i386', 'x86_64'])
         self.assertEqual(r, 12)
@@ -70,59 +69,48 @@ class TestAddHost(unittest.TestCase):
         kojihub.get_host.assert_called_once_with('hostname')
         self.context.session.createUser.assert_called_once_with(
             'hostname', usertype=koji.USERTYPES['HOST'], krb_principal='-hostname-')
-        self.assertEqual(_singleValue.call_count, 2)
-        _singleValue.assert_has_calls([
+        self.assertEqual(self._singleValue.call_count, 2)
+        self._singleValue.assert_has_calls([
             mock.call("SELECT id FROM channels WHERE name = 'default'"),
             mock.call("SELECT nextval('host_id_seq')", strict=True)
         ])
-        self.assertEqual(_dml.call_count, 1)
-        _dml.assert_called_once_with("INSERT INTO host (id, user_id, name) "
-                                     "VALUES (%(hostID)i, %(userID)i, %(hostname)s)",
-                                     {'hostID': 12, 'userID': 456, 'hostname': 'hostname'})
-
-    @mock.patch('kojihub.verify_host_name')
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub.get_host')
-    @mock.patch('kojihub._singleValue')
-    def test_add_host_wrong_user(self, _singleValue, get_host, _dml, get_user, verify_host_name):
-        verify_host_name.return_value = None
-        get_user.return_value = {
+        self.assertEqual(self._dml.call_count, 1)
+        self._dml.assert_called_once_with("INSERT INTO host (id, user_id, name) "
+                                          "VALUES (%(hostID)i, %(userID)i, %(hostname)s)",
+                                          {'hostID': 12, 'userID': 456, 'hostname': 'hostname'})
+
+    def test_add_host_wrong_user(self):
+        self.verify_host_name.return_value = None
+        self.get_user.return_value = {
             'id': 1,
             'name': 'hostname',
             'usertype': koji.USERTYPES['NORMAL']
         }
-        get_host.return_value = {}
+        self.get_host.return_value = {}
         with self.assertRaises(koji.GenericError):
             self.exports.addHost('hostname', ['i386', 'x86_64'])
-        _dml.assert_not_called()
-        get_user.assert_called_once_with(userInfo={'name': 'hostname'})
-        get_host.assert_called_once_with('hostname')
-        _singleValue.assert_called_once()
+        self._dml.assert_not_called()
+        self.get_user.assert_called_once_with(userInfo={'name': 'hostname'})
+        self.get_host.assert_called_once_with('hostname')
+        self._singleValue.assert_called_once()
         self.assertEqual(len(self.inserts), 0)
         self.assertEqual(len(self.updates), 0)
 
-    @mock.patch('kojihub.verify_host_name')
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub.get_host')
-    @mock.patch('kojihub._singleValue')
-    def test_add_host_wrong_user_forced(self, _singleValue, get_host, _dml, get_user,
-                                        verify_host_name):
-        verify_host_name.return_value = None
-        get_user.return_value = {
+    def test_add_host_wrong_user_forced(self):
+        self.verify_host_name.return_value = None
+        self.get_user.return_value = {
             'id': 123,
             'name': 'hostname',
             'usertype': koji.USERTYPES['NORMAL']
         }
-        get_host.return_value = {}
+        self.get_host.return_value = {}
 
         self.exports.addHost('hostname', ['i386', 'x86_64'], force=True)
 
-        _dml.assert_called_once()
-        get_user.assert_called_once_with(userInfo={'name': 'hostname'})
-        get_host.assert_called_once_with('hostname')
-        _singleValue.assert_called()
+        self._dml.assert_called_once()
+        self.get_user.assert_called_once_with(userInfo={'name': 'hostname'})
+        self.get_host.assert_called_once_with('hostname')
+        self._singleValue.assert_called()
         self.assertEqual(len(self.inserts), 2)
         self.assertEqual(len(self.updates), 1)
         update = self.updates[0]
@@ -131,40 +119,50 @@ class TestAddHost(unittest.TestCase):
         self.assertEqual(update.clauses, ['id = %(userID)i'])
         self.assertEqual(update.data, {'usertype': koji.USERTYPES['HOST']})
 
-    @mock.patch('kojihub.verify_host_name')
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub.get_host')
-    @mock.patch('kojihub._singleValue')
-    def test_add_host_superwrong_user_forced(self, _singleValue, get_host, _dml, get_user,
-                                             verify_host_name):
-        verify_host_name.return_value = None
-        get_user.return_value = {
+    def test_add_host_superwrong_user_forced(self):
+        self.verify_host_name.return_value = None
+        self.get_user.return_value = {
             'id': 123,
             'name': 'hostname',
             'usertype': koji.USERTYPES['GROUP']
         }
-        get_host.return_value = {}
+        self.get_host.return_value = {}
 
         with self.assertRaises(koji.GenericError):
             self.exports.addHost('hostname', ['i386', 'x86_64'], force=True)
 
-        _dml.assert_not_called()
-        get_user.assert_called_once_with(userInfo={'name': 'hostname'})
-        get_host.assert_called_once_with('hostname')
-        _singleValue.assert_called()
+        self._dml.assert_not_called()
+        self.get_user.assert_called_once_with(userInfo={'name': 'hostname'})
+        self.get_host.assert_called_once_with('hostname')
+        self._singleValue.assert_called()
         self.assertEqual(len(self.inserts), 0)
         self.assertEqual(len(self.updates), 0)
 
-    @mock.patch('kojihub.verify_host_name')
-    def test_add_host_wrong_format(self, verify_host_name):
+    def test_add_host_wrong_format(self):
         # name is longer as expected
         hostname = 'host-name+'
-        verify_host_name.side_effect = koji.GenericError
+        self.verify_host_name.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.addHost(hostname, ['i386', 'x86_64'], force=True)
 
         # not except regex rules
-        verify_host_name.side_effect = koji.GenericError
+        self.verify_host_name.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.addHost(hostname, ['i386', 'x86_64'], force=True)
+
+    def test_add_host_krbprincipal_wrong_type(self):
+        krb_principal = ['test-krb']
+        self.verify_host_name.return_value = None
+        self.get_host.return_value = {}
+        self._singleValue.side_effect = [333, 12]
+        self.verify_name_user.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            self.exports.addHost('hostname', ['i386', 'x86_64'], krb_principal=krb_principal)
+
+        self.context.session.assertPerm.assert_called_once_with('host')
+        kojihub.get_host.assert_called_once_with('hostname')
+        self.context.session.createUser.assert_not_called()
+        self.assertEqual(self._singleValue.call_count, 1)
+        self._singleValue.assert_called_once_with("SELECT id FROM channels WHERE name = 'default'")
+        self.verify_host_name.assert_called_once_with('hostname')
+        self.verify_name_user.assert_called_once_with(krb=krb_principal)
diff --git a/tests/test_hub/test_add_user_krb_principal.py b/tests/test_hub/test_add_user_krb_principal.py
new file mode 100644
index 0000000..dcaeee5
--- /dev/null
+++ b/tests/test_hub/test_add_user_krb_principal.py
@@ -0,0 +1,42 @@
+import mock
+import unittest
+import koji
+import kojihub
+import copy
+
+
+class TestAddUserKrbPrincipal(unittest.TestCase):
+
+    def setUp(self):
+        self.get_user = mock.patch('kojihub.get_user').start()
+        self.verify_name_user = mock.patch('kojihub.verify_name_user').start()
+        self.get_user_by_krb_principal = mock.patch('kojihub.get_user_by_krb_principal').start()
+        self.username = 'testuser'
+        self.krbprincipal = '%s@TEST.COM' % self.username
+        self.userinfo = {'id': 1, 'name': self.username}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_non_exist_user(self):
+        self.get_user.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            kojihub.RootExports().addUserKrbPrincipal(self.username, self.krbprincipal)
+
+    def test_wrong_krbprincipal_format(self):
+        krbprincipal = 'test-krbprincipal+'
+        self.get_user.return_value = self.userinfo
+        self.verify_name_user.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            kojihub.RootExports().addUserKrbPrincipal(self.username, krbprincipal)
+
+    def test_existing_krb(self):
+        userinfo = copy.deepcopy(self.userinfo)
+        userinfo['krb_principal'] = self.krbprincipal
+        self.get_user.return_value = self.userinfo
+        self.verify_name_user.return_value = None
+        self.get_user_by_krb_principal.return_value = userinfo
+        with self.assertRaises(koji.GenericError) as ex:
+            kojihub.RootExports().addUserKrbPrincipal(self.username, self.krbprincipal)
+        self.assertEqual('user with this Kerberos principal already exists: %s'
+                         % self.krbprincipal, str(ex.exception))
diff --git a/tests/test_hub/test_build.py b/tests/test_hub/test_build.py
new file mode 100644
index 0000000..e2c7382
--- /dev/null
+++ b/tests/test_hub/test_build.py
@@ -0,0 +1,40 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestBuild(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.src = 'test-src'
+        self.target = 'test-target'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_src_wrong_type(self):
+        src = ['test-priority']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.build(src, self.target)
+        self.assertEqual(f"Invalid type for value '{src}': {type(src)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.build(self.src, self.target, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_channel_not_str(self):
+        priority = 10
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 2, 'name': 'maven'}
+        self.make_task.return_value = 123
+        self.exports.build(self.src, self.target, priority=priority, channel=2)
diff --git a/tests/test_hub/test_build_image.py b/tests/test_hub/test_build_image.py
new file mode 100644
index 0000000..0f81fc6
--- /dev/null
+++ b/tests/test_hub/test_build_image.py
@@ -0,0 +1,69 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestBuildImage(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.mock_parse_arches = mock.patch('koji.parse_arches').start()
+        self.name = 'image-name'
+        self.version = 'test-version'
+        self.arch = 'x86_64'
+        self.target = 'test-target'
+        self.ksfile = 'test-ksfile'
+        self.image_type = 'livecd'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_img_type_not_supported(self):
+        image_type = 'test-type'
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.buildImage(self.name, self.version, self.arch, self.target,
+                                    self.ksfile, image_type)
+        self.assertEqual(f"Unrecognized image type: {image_type}", str(cm.exception))
+
+    def test_name_wrong_type(self):
+        name = ['test-name']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.buildImage(name, self.version, self.arch, self.target, self.ksfile,
+                                    self.image_type)
+        self.assertEqual(f"Invalid type for value '{name}': {type(name)}", str(cm.exception))
+
+    def test_version_wrong_type(self):
+        version = ['test-version']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.buildImage(self.name, version, self.arch, self.target, self.ksfile,
+                                    self.image_type)
+        self.assertEqual(f"Invalid type for value '{version}': {type(version)}", str(cm.exception))
+
+    def test_ksfile_wrong_type(self):
+        ksfile = ['test-ksfile']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.buildImage(self.name, self.version, self.arch, self.target, ksfile,
+                                    self.image_type)
+        self.assertEqual(f"Invalid type for value '{ksfile}': {type(ksfile)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        image_type = 'livemedia'
+        self.context.session.assertPerm.side_effect = None
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.ActionNotAllowed) as cm:
+            self.exports.buildImage(self.name, self.version, self.arch, self.target, self.ksfile,
+                                    image_type, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_valid(self):
+        priority = 10
+        self.context.session.assertPerm.side_effect = None
+        self.make_task.return_value = 123
+        self.exports.buildImage(self.name, self.version, self.arch, self.target, self.ksfile,
+                                self.image_type, priority=priority)
diff --git a/tests/test_hub/test_build_image_indirection.py b/tests/test_hub/test_build_image_indirection.py
new file mode 100644
index 0000000..b963781
--- /dev/null
+++ b/tests/test_hub/test_build_image_indirection.py
@@ -0,0 +1,33 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestBuildImageIndirection(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.session.assertPerm.side_effect = None
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.ActionNotAllowed) as cm:
+            self.exports.buildImageIndirection(priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_opts_without_expected_keys(self):
+        priority = 10
+        opts = {}
+        self.context.session.assertPerm.side_effect = None
+        with self.assertRaises(koji.ActionNotAllowed) as cm:
+            self.exports.buildImageIndirection(opts=opts, priority=priority)
+        self.assertEqual("Non-scratch builds must provide url for the indirection template",
+                         str(cm.exception))
diff --git a/tests/test_hub/test_build_image_oz.py b/tests/test_hub/test_build_image_oz.py
new file mode 100644
index 0000000..aa5485b
--- /dev/null
+++ b/tests/test_hub/test_build_image_oz.py
@@ -0,0 +1,59 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestBuildImageOz(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.parse_arches = mock.patch('koji.parse_arches').start()
+        self.name = 'image-name'
+        self.version = 'test-version'
+        self.arches = ['x86_64', 'i386']
+        self.target = 'test-target'
+        self.inst_tree = 'test-tree'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_name_wrong_type(self):
+        name = ['image-name']
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.buildImageOz(name, self.version, self.arches, self.target, self.inst_tree)
+        self.assertEqual(f"Invalid type for value '{name}': {type(name)}", str(cm.exception))
+
+    def test_inst_tree_wrong_type(self):
+        inst_tree = ['test-tree']
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.buildImageOz(self.name, self.version, self.arches, self.target, inst_tree)
+        self.assertEqual(f"Invalid type for value '{inst_tree}': {type(inst_tree)}",
+                         str(cm.exception))
+
+    def test_version_wrong_type(self):
+        version = ['test-version']
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.buildImageOz(self.name, version, self.arches, self.target, self.inst_tree)
+        self.assertEqual(f"Invalid type for value '{version}': {type(version)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.session.assertPerm.side_effect = None
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.ActionNotAllowed) as cm:
+            self.exports.buildImageOz(self.name, self.version, self.arches, self.target,
+                                      self.inst_tree, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_opts_without_expected_keys(self):
+        priority = 10
+        opts = {}
+        self.context.session.assertPerm.side_effect = None
+        with self.assertRaises(koji.ActionNotAllowed) as cm:
+            self.exports.buildImageOz(self.name, self.version, self.arches, self.target,
+                                      self.inst_tree, opts=opts, priority=priority)
+        self.assertEqual("Non-scratch builds must provide ksurl", str(cm.exception))
diff --git a/tests/test_hub/test_cg_importer.py b/tests/test_hub/test_cg_importer.py
index 1717963..3f236e9 100644
--- a/tests/test_hub/test_cg_importer.py
+++ b/tests/test_hub/test_cg_importer.py
@@ -10,6 +10,7 @@ from koji import GenericError
 IP = kojihub.InsertProcessor
 UP = kojihub.UpdateProcessor
 
+
 class TestCGImporter(unittest.TestCase):
     TMP_PATH = os.path.join(os.path.dirname(__file__), 'tmptest')
 
@@ -36,7 +37,8 @@ class TestCGImporter(unittest.TestCase):
         metadata = 42
         with self.assertRaises(GenericError) as ex:
             x.get_metadata(metadata, '')
-        self.assertEqual('Invalid type for metadata value: %s' % type(metadata), str(ex.exception))
+        self.assertEqual(f"Invalid type for value '{metadata}': {type(metadata)}",
+                         str(ex.exception))
 
     def test_get_metadata_is_none(self):
         x = kojihub.CG_Importer()
@@ -158,7 +160,7 @@ class TestCGImporter(unittest.TestCase):
                                   'start_time': None, 'start_ts': None,
                                   'completion_time': None, 'completion_ts': None,
                                   'source': 'https://example.com', 'extra': {}
-                                 }
+                                  }
         new_build_id.return_value = 43
         x.get_build()
         assert x.buildinfo
@@ -187,7 +189,7 @@ class TestCGImporter(unittest.TestCase):
         get_metadata.return_value = metadata
         with self.assertRaises(koji.GenericError) as ex:
             x.do_import(metadata, '/test/dir')
-        self.assertEqual('No such metadata version: %s' % metadata['metadata_version'],
+        self.assertEqual(f"No such metadata version: {metadata['metadata_version']}",
                          str(ex.exception))
 
     def test_match_componemt_wrong_component(self):
@@ -195,7 +197,7 @@ class TestCGImporter(unittest.TestCase):
         components = [{'type': 'type'}]
         with self.assertRaises(koji.GenericError) as ex:
             x.match_components(components)
-        self.assertEqual('No such component type: %s' % components[0]['type'], str(ex.exception))
+        self.assertEqual(f"No such component type: {components[0]['type']}", str(ex.exception))
 
 
 class TestMatchKojiFile(unittest.TestCase):
@@ -203,23 +205,23 @@ class TestMatchKojiFile(unittest.TestCase):
     def setUp(self):
         self.importer = kojihub.CG_Importer()
         self.archive1 = {
-                'id': 99,
-                'build_id': 42,
-                'checksum': 'e1f95555eae04b8e1ebdc5555c5555f0',
-                'checksum_type': 0,
-                'filename': 'foo-bar-3.0.jar',
-                'size': 42710,
-                }
+            'id': 99,
+            'build_id': 42,
+            'checksum': 'e1f95555eae04b8e1ebdc5555c5555f0',
+            'checksum_type': 0,
+            'filename': 'foo-bar-3.0.jar',
+            'size': 42710,
+        }
         self.build1 = {
-                'id': 79218,
-                'nvr': 'foo-3.0-1',
-                }
+            'id': 79218,
+            'nvr': 'foo-3.0-1',
+        }
         self.comp1 = {
-                'type': 'kojifile',
-                'archive_id': self.archive1['id'],
-                'nvr': self.build1['nvr'],
-                'filename': self.archive1['filename'],
-                }
+            'type': 'kojifile',
+            'archive_id': self.archive1['id'],
+            'nvr': self.build1['nvr'],
+            'filename': self.archive1['filename'],
+        }
         self.get_archive = mock.patch('kojihub.get_archive').start()
         self.get_build = mock.patch('kojihub.get_build').start()
 
@@ -270,7 +272,6 @@ class TestCGReservation(unittest.TestCase):
         self.updates.append(update)
         return update
 
-
     def setUp(self):
         self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
                                           side_effect=self.getInsert).start()
@@ -298,8 +299,8 @@ class TestCGReservation(unittest.TestCase):
         new_build.return_value = 654
         cg = 'content_generator_name'
         self.mock_cursor.fetchone.side_effect = [
-            [333], # get pkg_id
-            [1234], # get nextval pkg_id
+            [333],  # get pkg_id
+            [1234],  # get nextval pkg_id
         ]
         self.mock_cursor.fetchall.side_effect = [
             [[]],
diff --git a/tests/test_hub/test_chain_build.py b/tests/test_hub/test_chain_build.py
new file mode 100644
index 0000000..42ae5fe
--- /dev/null
+++ b/tests/test_hub/test_chain_build.py
@@ -0,0 +1,40 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestChainBuild(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.srcs = ['pkg1']
+        self.target = 'test-target'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_srcs_wrong_type(self):
+        srcs = 'pkg'
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.chainBuild(srcs, self.target)
+        self.assertEqual(f"Invalid type for value '{srcs}': {type(srcs)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.chainBuild(self.srcs, self.target, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_channel_not_str(self):
+        priority = 10
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 2, 'name': 'maven'}
+        self.make_task.return_value = 123
+        self.exports.chainBuild(self.srcs, self.target, priority=priority, channel=2)
diff --git a/tests/test_hub/test_chain_maven.py b/tests/test_hub/test_chain_maven.py
new file mode 100644
index 0000000..f5b737d
--- /dev/null
+++ b/tests/test_hub/test_chain_maven.py
@@ -0,0 +1,48 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestChainMaven(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.builds = ['build1', 'build2', 'build3']
+        self.target = 'test-target'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_maven_not_supported(self):
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.chainMaven(self.builds, self.target)
+        self.assertEqual("Maven support not enabled", str(cm.exception))
+
+    def test_builds_wrong_type(self):
+        builds = 'test-builds'
+        self.context.opts.get.return_value = True
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.chainMaven(builds, self.target)
+        self.assertEqual(f"Invalid type for value '{builds}': {type(builds)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.opts.get.return_value = True
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.chainMaven(self.builds, self.target, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_channel_not_str(self):
+        self.context.opts.get.return_value = True
+        self.make_task.return_value = 123
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 2, 'name': 'maven'}
+        self.exports.chainMaven(self.builds, self.target, channel=2, priority=10)
diff --git a/tests/test_hub/test_create_notification.py b/tests/test_hub/test_create_notification.py
index e3ff819..7b0dacf 100644
--- a/tests/test_hub/test_create_notification.py
+++ b/tests/test_hub/test_create_notification.py
@@ -4,57 +4,168 @@ import mock
 import koji
 import kojihub
 
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
 
 class TestCreateNotification(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
 
     def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self.get_build_notifications = mock.patch('kojihub.get_build_notifications').start()
+        self.get_tag_id = mock.patch('kojihub.get_tag_id').start()
+        self.get_package_id = mock.patch('kojihub.get_package_id').start()
+
         self.exports = kojihub.RootExports()
         self.exports.getLoggedInUser = mock.MagicMock()
-        self.context = mock.patch('kojihub.context').start()
+        self.exports.getUser = mock.MagicMock()
+        self.exports.hasPerm = mock.MagicMock()
         self.cursor = mock.MagicMock()
+        self.user_id = 1
+        self.package_id = 345
+        self.tag_id = 345
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_createNotification(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'username'}
+        self.exports.hasPerm.return_value = True
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notifications.return_value = []
+
+        r = self.exports.createNotification(
+            self.user_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual(r, None)
+
+        self.exports.getLoggedInUser.assert_called_once()
+        self.exports.getUser.asssert_called_once_with(self.user_id)
+        self.exports.hasPerm.asssert_called_once_with('admin')
+        self.get_package_id.assert_called_once_with(self.package_id, strict=True)
+        self.get_tag_id.assert_called_once_with(self.tag_id, strict=True)
+        self.get_build_notifications.assert_called_once_with(2)
+        self.assertEqual(len(self.inserts), 1)
+        insert = self.inserts[0]
+        self.assertEqual(insert.table, 'build_notifications')
+        self.assertEqual(insert.data, {
+            'package_id': self.package_id,
+            'user_id': 2,
+            'tag_id': self.tag_id,
+            'success_only': success_only,
+            'email': 'username@test.domain.com',
+        })
+        self.assertEqual(insert.rawdata, {})
+
+    def test_createNotification_unauthentized(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = None
 
-    def test_non_exist_user(self):
-        user_id = 999
-        package_id = 555
-        tag_id = 111
-        success_only = False
-        logged_user = {'authtype': 2,
-                       'id': 1,
-                       'krb_principal': None,
-                       'krb_principals': [],
-                       'name': 'kojiadmin',
-                       'status': 0,
-                       'usertype': 0}
-        self.cursor.fetchone.return_value = None
-        self.context.cnx.cursor.return_value = self.cursor
-        self.exports.getLoggedInUser.return_value = logged_user
         with self.assertRaises(koji.GenericError) as cm:
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-        self.assertEqual('No such user ID: %s' % user_id, str(cm.exception))
+            self.exports.createNotification(
+                self.user_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual('Not logged-in', str(cm.exception))
 
+        self.assertEqual(len(self.inserts), 0)
 
-class TestCreateNotificationBlock(unittest.TestCase):
+    def test_createNotification_invalid_user(self):
+        user_id = 2
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.getUser.return_value = None
 
-    def setUp(self):
-        self.exports = kojihub.RootExports()
-        self.exports.getLoggedInUser = mock.MagicMock()
-        self.context = mock.patch('kojihub.context').start()
-        self.cursor = mock.MagicMock()
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotification(user_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual(f'No such user ID: {user_id}', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotification_no_perm(self):
+        user_id = 2
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'b'}
+        self.exports.hasPerm.return_value = False
 
-    def test_non_exist_user(self):
-        user_id = 999
-        package_id = 555
-        tag_id = 111
-        logged_user = {'authtype': 2,
-                       'id': 1,
-                       'krb_principal': None,
-                       'krb_principals': [],
-                       'name': 'kojiadmin',
-                       'status': 0,
-                       'usertype': 0}
-        self.cursor.fetchone.return_value = None
-        self.context.cnx.cursor.return_value = self.cursor
-        self.exports.getLoggedInUser.return_value = logged_user
         with self.assertRaises(koji.GenericError) as cm:
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-        self.assertEqual('No such user ID: %s' % user_id, str(cm.exception))
+            self.exports.createNotification(user_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual('user a cannot create notifications for user b', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotification_invalid_pkg(self):
+        user_id = 2
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.side_effect = ValueError
+
+        with self.assertRaises(ValueError):
+            self.exports.createNotification(user_id, self.package_id, self.tag_id, success_only)
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotification_invalid_tag(self):
+        user_id = 2
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.side_effect = ValueError
+
+        with self.assertRaises(ValueError):
+            self.exports.createNotification(user_id, self.package_id, self.tag_id, success_only)
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotification_exists(self):
+        user_id = 2
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notifications.return_value = [{
+            'package_id': self.package_id,
+            'tag_id': self.tag_id,
+            'success_only': success_only,
+        }]
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotification(user_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual('notification already exists', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
diff --git a/tests/test_hub/test_create_notification_block.py b/tests/test_hub/test_create_notification_block.py
new file mode 100644
index 0000000..278bf54
--- /dev/null
+++ b/tests/test_hub/test_create_notification_block.py
@@ -0,0 +1,161 @@
+import unittest
+import mock
+
+import koji
+import kojihub
+
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
+
+class TestCreateNotificationBlock(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self.get_tag_id = mock.patch('kojihub.get_tag_id').start()
+        self.get_package_id = mock.patch('kojihub.get_package_id').start()
+        self.get_build_notification_blocks = mock.patch(
+            'kojihub.get_build_notification_blocks').start()
+
+        self.exports = kojihub.RootExports()
+        self.exports.getLoggedInUser = mock.MagicMock()
+        self.exports.getUser = mock.MagicMock()
+        self.exports.hasPerm = mock.MagicMock()
+        self.cursor = mock.MagicMock()
+        self.user_id = 1
+        self.package_id = 555
+        self.tag_id = 111
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_createNotificationBlock(self):
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'username'}
+        self.exports.hasPerm.return_value = True
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notification_blocks.return_value = []
+
+        r = self.exports.createNotificationBlock(self.user_id, self.package_id, self.tag_id)
+        self.assertEqual(r, None)
+
+        self.exports.getLoggedInUser.assert_called_once()
+        self.exports.getUser.asssert_called_once_with(self.user_id)
+        self.exports.hasPerm.asssert_called_once_with('admin')
+        self.get_package_id.assert_called_once_with(self.package_id, strict=True)
+        self.get_tag_id.assert_called_once_with(self.tag_id, strict=True)
+        self.get_build_notification_blocks.assert_called_once_with(2)
+        self.assertEqual(len(self.inserts), 1)
+        insert = self.inserts[0]
+        self.assertEqual(insert.table, 'build_notifications_block')
+        self.assertEqual(insert.data, {
+            'package_id': self.package_id,
+            'user_id': 2,
+            'tag_id': self.tag_id,
+        })
+        self.assertEqual(insert.rawdata, {})
+
+    def test_createNotificationBlock_unauthentized(self):
+        self.exports.getLoggedInUser.return_value = None
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotificationBlock(self.user_id, self.package_id, self.tag_id)
+        self.assertEqual('Not logged-in', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotificationBlock_invalid_user(self):
+        user_id = 2
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.getUser.return_value = None
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotificationBlock(user_id, self.package_id, self.tag_id)
+        self.assertEqual(f'No such user ID: {user_id}', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotificationBlock_no_perm(self):
+        user_id = 2
+        self.exports.getLoggedInUser.return_value = {'id': 1, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'b'}
+        self.exports.hasPerm.return_value = False
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotificationBlock(user_id, self.package_id, self.tag_id)
+        self.assertEqual('user a cannot create notification blocks for user b', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotificationBlock_invalid_pkg(self):
+        user_id = 2
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.side_effect = ValueError
+
+        with self.assertRaises(ValueError):
+            self.exports.createNotificationBlock(user_id, self.package_id, self.tag_id)
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotificationBlock_invalid_tag(self):
+        user_id = 2
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.side_effect = ValueError
+
+        with self.assertRaises(ValueError):
+            self.exports.createNotificationBlock(user_id, self.package_id, self.tag_id)
+
+        self.assertEqual(len(self.inserts), 0)
+
+    def test_createNotificationBlock_exists(self):
+        user_id = 2
+        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
+        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notification_blocks.return_value = [{
+            'package_id': self.package_id,
+            'tag_id': self.tag_id,
+        }]
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.createNotificationBlock(user_id, self.package_id, self.tag_id)
+        self.assertEqual('notification already exists', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
diff --git a/tests/test_hub/test_create_tag.py b/tests/test_hub/test_create_tag.py
index 22e2777..e5498e8 100644
--- a/tests/test_hub/test_create_tag.py
+++ b/tests/test_hub/test_create_tag.py
@@ -24,8 +24,9 @@ class TestCreateTag(unittest.TestCase):
         self._dml = mock.patch('kojihub._dml').start()
         self.get_tag = mock.patch('kojihub.get_tag').start()
         self.get_tag_id = mock.patch('kojihub.get_tag_id').start()
+        self.get_perm_id = mock.patch('kojihub.get_perm_id').start()
         self.verify_name_internal = mock.patch('kojihub.verify_name_internal').start()
-        self.writeInheritanceData = mock.patch('kojihub.writeInheritanceData').start()
+        self.writeInheritanceData = mock.patch('kojihub._writeInheritanceData').start()
         self.context = mock.patch('kojihub.context').start()
         # It seems MagicMock will not automatically handle attributes that
         # start with "assert"
@@ -42,12 +43,13 @@ class TestCreateTag(unittest.TestCase):
             kojihub.create_tag('duptag')
 
     def test_simple_create(self):
-        self.get_tag.return_value = None
+        self.get_tag.side_effect = [None, {'id': 1, 'name': 'parent-tag'}]
         self.get_tag_id.return_value = 99
         self.verify_name_internal.return_value = None
         self.context.event_id = 42
         self.context.session.user_id = 23
-        kojihub.create_tag('newtag')
+        self.writeInheritanceData.return_value = None
+        kojihub.create_tag('newtag', parent='parent-tag')
 
         # check the insert
         self.assertEqual(len(self.inserts), 1)
@@ -97,3 +99,21 @@ class TestCreateTag(unittest.TestCase):
         self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             kojihub.create_tag(tag_name)
+
+    def test_tag_non_exist_parent(self):
+        parent_tag = 'parent-tag'
+        self.verify_name_internal.return_value = None
+        self.get_tag.side_effect = [None, None]
+        with self.assertRaises(koji.GenericError) as ex:
+            kojihub.create_tag('new-tag', parent=parent_tag)
+        self.assertEqual("Parent tag '%s' could not be found" % parent_tag, str(ex.exception))
+
+    def test_tag_not_maven_support(self):
+        self.verify_name_internal.return_value = None
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as ex:
+            kojihub.create_tag('new-tag', maven_support=True)
+        self.assertEqual("Maven support not enabled", str(ex.exception))
+        with self.assertRaises(koji.GenericError) as ex:
+            kojihub.create_tag('new-tag', maven_include_all=True)
+        self.assertEqual("Maven support not enabled", str(ex.exception))
diff --git a/tests/test_hub/test_create_user.py b/tests/test_hub/test_create_user.py
index 2ba9f33..d991aa8 100644
--- a/tests/test_hub/test_create_user.py
+++ b/tests/test_hub/test_create_user.py
@@ -38,19 +38,27 @@ class TestCreateUser(unittest.TestCase):
             self.exports.createUser(user_name)
 
     def test_create_user_exists(self):
-        expected = 'user already exists: %s' % self.user_name
         self.verify_name_user.return_value = None
         self.get_user.return_value = self.user_info
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.createUser(self.user_name)
-        self.assertEqual(expected, str(cm.exception))
+        self.assertEqual(f"user already exists: {self.user_name}", str(cm.exception))
 
     def test_create_user_exists_krb(self):
         krb_principal = 'test_user@fedora.org'
-        expected = 'user with this Kerberos principal already exists: %s' % krb_principal
+        expected = f"user with this Kerberos principal already exists: {krb_principal}"
         self.verify_name_user.return_value = None
         self.get_user.return_value = None
         self.get_user_by_krb_principal.return_value = self.user_info_krb
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.createUser(self.user_name, krb_principal=krb_principal)
         self.assertEqual(expected, str(cm.exception))
+
+    def test_create_user_wrong_type_status(self):
+        status = 'test-status'
+        self.verify_name_user.return_value = None
+        self.get_user.return_value = None
+        self.get_user_by_krb_principal.return_value = self.user_info_krb
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.createUser(self.user_name, status=status)
+        self.assertEqual(f"Invalid type for value '{status}': {type(status)}", str(cm.exception))
diff --git a/tests/test_hub/test_delete_notification.py b/tests/test_hub/test_delete_notification.py
new file mode 100644
index 0000000..90da193
--- /dev/null
+++ b/tests/test_hub/test_delete_notification.py
@@ -0,0 +1,103 @@
+import mock
+import unittest
+
+import koji
+import kojihub
+
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
+
+class TestDeleteNotifications(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self._dml = mock.patch('kojihub._dml').start()
+
+        self.exports = kojihub.RootExports()
+        self.exports.getLoggedInUser = mock.MagicMock()
+        self.exports.hasPerm = mock.MagicMock()
+        self.exports.getBuildNotification = mock.MagicMock()
+        self.user_id = 752
+        self.n_id = 543
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_deleteNotification(self):
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+
+        self.exports.deleteNotification(self.n_id)
+
+        self.exports.getBuildNotification.assert_called_once_with(self.n_id, strict=True)
+        self.exports.getLoggedInUser.assert_called_once_with()
+        self._dml.assert_called_once()
+
+    def test_deleteNotification_missing(self):
+        self.exports.getBuildNotification.side_effect = koji.GenericError
+
+        with self.assertRaises(koji.GenericError):
+            self.exports.deleteNotification(self.n_id)
+
+        self.exports.getBuildNotification.assert_called_once_with(self.n_id, strict=True)
+
+    def test_deleteNotification_not_logged(self):
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+        self.exports.getLoggedInUser.return_value = None
+        # self.set_queries = ([
+        #    [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+        # ])
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.deleteNotification(self.n_id)
+        self.assertEqual('Not logged-in', str(cm.exception))
+
+        self.exports.getBuildNotification.assert_called_once_with(self.n_id, strict=True)
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
+        self.assertEqual(len(self.queries), 0)
+
+    def test_deleteNotification_no_perm(self):
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.hasPerm.return_value = False
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.deleteNotification(self.n_id)
+        self.assertEqual(f'user 1 cannot delete notifications for user {self.user_id}',
+                         str(cm.exception))
+
+        self.exports.getBuildNotification.assert_called_once_with(self.n_id, strict=True)
+        self._dml.assert_not_called()
diff --git a/tests/test_hub/test_delete_notification_block.py b/tests/test_hub/test_delete_notification_block.py
new file mode 100644
index 0000000..5f5379b
--- /dev/null
+++ b/tests/test_hub/test_delete_notification_block.py
@@ -0,0 +1,104 @@
+import mock
+import unittest
+
+import koji
+import kojihub
+
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
+
+class TestDeleteNotificationsBlocks(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self.get_user = mock.patch('kojihub.get_user').start()
+        self._dml = mock.patch('kojihub._dml').start()
+
+        self.exports = kojihub.RootExports()
+        self.exports.getLoggedInUser = mock.MagicMock()
+        self.exports.hasPerm = mock.MagicMock()
+        self.exports.getBuildNotificationBlock = mock.MagicMock()
+        self.user_id = 752
+        self.n_id = 543
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_deleteNotificationBlock(self):
+        self.exports.getBuildNotificationBlock.return_value = {'user_id': self.user_id}
+
+        self.exports.deleteNotificationBlock(self.n_id)
+
+        self.exports.getBuildNotificationBlock.assert_called_once_with(self.n_id, strict=True)
+        self.exports.getLoggedInUser.assert_called_once_with()
+        self._dml.assert_called_once()
+
+    def test_deleteNotificationBlock_missing(self):
+        self.exports.getBuildNotificationBlock.side_effect = koji.GenericError
+
+        with self.assertRaises(koji.GenericError):
+            self.exports.deleteNotificationBlock(self.n_id)
+
+        self.exports.getBuildNotificationBlock.assert_called_once_with(self.n_id, strict=True)
+
+    def test_deleteNotificationBlock_not_logged(self):
+        self.exports.getBuildNotificationBlock.return_value = {'user_id': self.user_id}
+        self.exports.getLoggedInUser.return_value = None
+        # self.set_queries = ([
+        #    [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+        # ])
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.deleteNotificationBlock(self.n_id)
+        self.assertEqual('Not logged-in', str(cm.exception))
+
+        self.exports.getBuildNotificationBlock.assert_called_once_with(self.n_id, strict=True)
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
+        self.assertEqual(len(self.queries), 0)
+
+    def test_deleteNotificationBlock_no_perm2(self):
+        self.exports.getBuildNotificationBlock.return_value = {'user_id': self.user_id}
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.hasPerm.return_value = False
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.deleteNotificationBlock(self.n_id)
+        self.assertEqual(f'user 1 cannot delete notification blocks for user {self.user_id}',
+                         str(cm.exception))
+
+        self.exports.getBuildNotificationBlock.assert_called_once_with(self.n_id, strict=True)
+        self._dml.assert_not_called()
diff --git a/tests/test_hub/test_disable_channel.py b/tests/test_hub/test_disable_channel.py
index 61e28d6..3d967ac 100644
--- a/tests/test_hub/test_disable_channel.py
+++ b/tests/test_hub/test_disable_channel.py
@@ -28,7 +28,13 @@ class TestDisableChannel(unittest.TestCase):
         self.get_channel.return_value = None
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.disableChannel(self.channelname)
-        self.assertEqual("No such channel: %s" % self.channelname, str(cm.exception))
+        self.assertEqual(f"No such channel: {self.channelname}", str(cm.exception))
+
+    def test_wrong_type_channel(self):
+        comment = ['test-comment']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.disableChannel(self.channelname, comment=comment)
+        self.assertEqual(f"Invalid type for value '{comment}': {type(comment)}", str(cm.exception))
 
     def test_valid(self):
         self.get_channel.return_value = {'comment': None, 'description': None,
diff --git a/tests/test_hub/test_dist_repo.py b/tests/test_hub/test_dist_repo.py
index 9ba1be0..40b69e1 100644
--- a/tests/test_hub/test_dist_repo.py
+++ b/tests/test_hub/test_dist_repo.py
@@ -31,6 +31,7 @@ class TestDistRepoInit(unittest.TestCase):
         self.get_event = mock.patch('kojihub.get_event').start()
         self.nextval = mock.patch('kojihub.nextval').start()
         self.copyfile = mock.patch('shutil.copyfile').start()
+        self.lookup_name = mock.patch('kojihub.lookup_name').start()
 
         self.get_tag.return_value = {'id': 42, 'name': 'tag'}
         self.get_event.return_value = 12345
@@ -70,6 +71,34 @@ class TestDistRepoInit(unittest.TestCase):
 
         self.copyfile.assert_called_once()
 
+    def test_simple_dist_repo_init_wrong_type_keys(self):
+        keys = 'key1 key2'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.dist_repo_init('tag', keys, {'arch': ['x86_64']})
+        self.assertEqual(f"Invalid type for value '{keys}': {type(keys)}", str(cm.exception))
+        self.InsertProcessor.assert_not_called()
+
+    def test_simple_dist_repo_init_wrong_type_task_opts(self):
+        task_opts = 'opts'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.dist_repo_init('tag', ['key'], task_opts)
+        self.assertEqual(f"Invalid type for value '{task_opts}': {type(task_opts)}",
+                         str(cm.exception))
+        self.InsertProcessor.assert_not_called()
+
+    def test_simple_dist_repo_init_wrong_type_event(self):
+        event = 'test-event'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.dist_repo_init('tag', ['key'], {'arch': ['x86_64'], 'event': event})
+        self.assertEqual(f"Invalid type for value '{event}': {type(event)}", str(cm.exception))
+        self.InsertProcessor.assert_not_called()
+
+    def test_simple_dist_repo_init_wrong_type_volume(self):
+        self.lookup_name.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            kojihub.dist_repo_init('tag', ['key'], {'arch': ['x86_64'], 'volume': 'test-volume'})
+        self.InsertProcessor.assert_not_called()
+
 
 class TestDistRepo(unittest.TestCase):
 
@@ -206,7 +235,7 @@ class TestDistRepoMove(unittest.TestCase):
             path = os.path.join(repodir, relpath)
             basename = os.path.basename(path)
             if not os.path.exists(path):
-                raise Exception("Missing file: %s" % path)
+                raise Exception(f"Missing file: {path}")
             data = open(path, 'rt', encoding='utf-8').read()
             data.strip()
             self.assertEqual(data, basename)
diff --git a/tests/test_hub/test_download_task_output.py b/tests/test_hub/test_download_task_output.py
new file mode 100644
index 0000000..d7c9c74
--- /dev/null
+++ b/tests/test_hub/test_download_task_output.py
@@ -0,0 +1,36 @@
+import mock
+import unittest
+import koji
+import kojihub
+
+
+class TestDownloadTaskOutput(unittest.TestCase):
+
+    def setUp(self):
+        self.exports = kojihub.RootExports()
+        self.exports.getVolume = mock.MagicMock()
+        self.task_id = 1
+        self.filename = 'test-file'
+        self.volumename = 'test-volume'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_size_wrong_type(self):
+        size = 'test-size'
+        with self.assertRaises(koji.ParameterError) as cm:
+            self.exports.downloadTaskOutput(self.task_id, self.filename, size=size)
+        self.assertEqual(f"Invalid type for value '{size}': {type(size)}", str(cm.exception))
+
+    def test_volume_non_exist_wrong_type(self):
+        self.exports.getVolume.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            self.exports.downloadTaskOutput(self.task_id, self.filename, volume=self.volumename)
+
+    def test_filename_wrong_format(self):
+        filename = '../test-file'
+        volumeinfo = {'id': 1, 'name': self.volumename}
+        self.exports.getVolume.return_value = volumeinfo
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.downloadTaskOutput(self.task_id, filename, volume=self.volumename)
+        self.assertEqual(f"Invalid file name: {filename}", str(cm.exception))
diff --git a/tests/test_hub/test_edit_channel.py b/tests/test_hub/test_edit_channel.py
index 4196ba3..24c588a 100644
--- a/tests/test_hub/test_edit_channel.py
+++ b/tests/test_hub/test_edit_channel.py
@@ -34,32 +34,28 @@ class TestEditChannel(unittest.TestCase):
         self.exports = kojihub.RootExports()
         self.channel_name = 'test-channel'
         self.channel_name_new = 'test-channel-2'
+        self.channel_info = {'id': 123, 'name': self.channel_name, 'description': 'description',
+                             'comment': 'comment'}
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.verify_name_internal = mock.patch('kojihub.verify_name_internal').start()
 
     def tearDown(self):
         mock.patch.stopall()
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    def test_edit_channel_missing(self, get_channel, verify_name_internal):
+    def test_edit_channel_missing(self):
         expected = 'Invalid type for channelInfo: %s' % self.channel_name
-        get_channel.side_effect = koji.GenericError(expected)
+        self.get_channel.side_effect = koji.GenericError(expected)
         with self.assertRaises(koji.GenericError) as ex:
             self.exports.editChannel(self.channel_name, name=self.channel_name_new)
-        get_channel.assert_called_once_with(self.channel_name, strict=True)
+        self.get_channel.assert_called_once_with(self.channel_name, strict=True)
         self.assertEqual(self.inserts, [])
         self.assertEqual(self.updates, [])
         self.assertEqual(expected, str(ex.exception))
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    def test_edit_channel_already_exists(self, get_channel, verify_name_internal):
-        verify_name_internal.return_value = None
-        get_channel.side_effect = [
-            {
-                'id': 123,
-                'name': self.channel_name,
-                'description': 'description',
-            },
+    def test_edit_channel_already_exists(self):
+        self.verify_name_internal.return_value = None
+        self.get_channel.side_effect = [
+            self.channel_info,
             {
                 'id': 124,
                 'name': self.channel_name_new,
@@ -70,29 +66,22 @@ class TestEditChannel(unittest.TestCase):
             self.exports.editChannel(self.channel_name, name=self.channel_name_new)
         expected_calls = [mock.call(self.channel_name, strict=True),
                           mock.call(self.channel_name_new, strict=False)]
-        get_channel.assert_has_calls(expected_calls)
+        self.get_channel.assert_has_calls(expected_calls)
         self.assertEqual(self.inserts, [])
         self.assertEqual(self.updates, [])
-        self.assertEqual('channel %s already exists (id=124)' % self.channel_name_new,
+        self.assertEqual(f'channel {self.channel_name_new} already exists (id=124)',
                          str(ex.exception))
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    def test_edit_channel_valid(self, get_channel, verify_name_internal):
-        verify_name_internal.return_value = None
-        kojihub.get_channel.side_effect = [{
-            'id': 123,
-            'name': self.channel_name,
-            'description': 'description',
-        },
-            {}]
+    def test_edit_channel_valid(self):
+        self.verify_name_internal.return_value = None
+        kojihub.get_channel.side_effect = [self.channel_info, {}]
 
         r = self.exports.editChannel(self.channel_name, name=self.channel_name_new,
                                      description='description_new')
         self.assertTrue(r)
         expected_calls = [mock.call(self.channel_name, strict=True),
                           mock.call(self.channel_name_new, strict=False)]
-        get_channel.assert_has_calls(expected_calls)
+        self.get_channel.assert_has_calls(expected_calls)
 
         self.assertEqual(len(self.updates), 1)
         values = {'channelID': 123}
@@ -103,21 +92,56 @@ class TestEditChannel(unittest.TestCase):
         self.assertEqual(update.values, values)
         self.assertEqual(update.clauses, clauses)
 
-    @mock.patch('kojihub.verify_name_internal')
-    @mock.patch('kojihub.get_channel')
-    def test_edit_channel_wrong_format(self, get_channel, verify_name_internal):
+    def test_edit_channel_wrong_name(self):
         channel_name_new = 'test-channel+'
-        get_channel.return_value = {'id': 123,
-                                    'name': self.channel_name,
-                                    'description': 'description',
-                                    }
+        self.get_channel.return_value = self.channel_info
 
         # name is longer as expected
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.editChannel(self.channel_name, name=channel_name_new)
 
         # not except regex rules
-        verify_name_internal.side_effect = koji.GenericError
+        self.verify_name_internal.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            self.exports.editChannel(self.channel_name, name=channel_name_new)
+
+    def test_edit_channel_no_change(self):
+        self.verify_name_internal.return_value = None
+        kojihub.get_channel.return_value = self.channel_info
+
+        r = self.exports.editChannel(self.channel_name, description='description')
+        self.assertFalse(r)
+        self.assertEqual(self.updates, [])
+        self.get_channel.assert_called_once_with(self.channel_name, strict=True)
+        self.verify_name_internal.assert_not_called()
+
+    def test_edit_channel_wrong_format_new_name(self):
+        channel_name_new = 13568
+        self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.editChannel(self.channel_name, name=channel_name_new)
+        self.assertEqual(self.updates, [])
+        self.get_channel.assert_called_once_with(self.channel_name, strict=True)
+        self.verify_name_internal.assert_called_once_with(channel_name_new)
+
+    def test_edit_channel_wrong_format_description(self):
+        description = ['description']
+        self.get_channel.return_value = self.channel_info
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.editChannel(self.channel_name, description=description)
+        self.assertEqual(self.updates, [])
+        self.assertEqual(f"Invalid type for value '{description}': {type(description)}",
+                         str(ex.exception))
+        self.get_channel.assert_called_once_with(self.channel_name, strict=True)
+        self.verify_name_internal.assert_not_called()
+
+    def test_edit_channel_wrong_format_comment(self):
+        comment = ['comment']
+        self.get_channel.return_value = self.channel_info
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.editChannel(self.channel_name, comment=comment)
+        self.assertEqual(self.updates, [])
+        self.assertEqual(f"Invalid type for value '{comment}': {type(comment)}", str(ex.exception))
+        self.get_channel.assert_called_once_with(self.channel_name, strict=True)
+        self.verify_name_internal.assert_not_called()
diff --git a/tests/test_hub/test_edit_host.py b/tests/test_hub/test_edit_host.py
index a687bd0..fc9aea5 100644
--- a/tests/test_hub/test_edit_host.py
+++ b/tests/test_hub/test_edit_host.py
@@ -8,7 +8,7 @@ UP = kojihub.UpdateProcessor
 IP = kojihub.InsertProcessor
 
 
-class TestSetHostEnabled(unittest.TestCase):
+class TestEditHost(unittest.TestCase):
     def getInsert(self, *args, **kwargs):
         insert = IP(*args, **kwargs)
         insert.execute = mock.MagicMock()
@@ -22,6 +22,7 @@ class TestSetHostEnabled(unittest.TestCase):
         return update
 
     def setUp(self):
+        self.diff = None
         self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
                                           side_effect=self.getInsert).start()
         self.inserts = []
@@ -34,12 +35,22 @@ class TestSetHostEnabled(unittest.TestCase):
         self.context.session.assertLogin = mock.MagicMock()
         self.context.session.assertPerm = mock.MagicMock()
         self.exports = kojihub.RootExports()
+        self.get_host = mock.patch('kojihub.get_host').start()
+        self.hostinfo = {
+            'id': 123,
+            'user_id': 234,
+            'name': 'hostname',
+            'arches': 'x86_64',
+            'capacity': 100.0,
+            'description': 'description',
+            'comment': 'comment',
+            'enabled': False,
+        }
 
     def tearDown(self):
         mock.patch.stopall()
 
     def test_edit_host_missing(self):
-        kojihub.get_host = mock.MagicMock()
         kojihub.get_host.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             self.exports.editHost('hostname')
@@ -47,23 +58,47 @@ class TestSetHostEnabled(unittest.TestCase):
         self.assertEqual(self.inserts, [])
         self.assertEqual(self.updates, [])
 
+    def test_edit_host_invalid_description(self):
+        description = ['description']
+        kojihub.get_host.return_value = self.hostinfo
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.editHost('hostname', description=description)
+            self.assertEqual('Invalid type for description parameter: %s' % type(description),
+                             str(ex.exception))
+        kojihub.get_host.assert_called_once_with('hostname', strict=True)
+        self.assertEqual(self.inserts, [])
+        self.assertEqual(self.updates, [])
+
+    def test_edit_host_invalid_comment_parameter(self):
+        comment = ['comment']
+        kojihub.get_host.return_value = self.hostinfo
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.editHost('hostname', comment=comment)
+            self.assertEqual('Invalid type for comment parameter: %s' % type(comment),
+                             str(ex.exception))
+        kojihub.get_host.assert_called_once_with('hostname', strict=True)
+        self.assertEqual(self.inserts, [])
+        self.assertEqual(self.updates, [])
+
+    def test_edit_host_invalid_arches_parameter(self):
+        arches = ['arches arches']
+        kojihub.get_host.return_value = self.hostinfo
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.editHost('hostname', arches=arches)
+            self.assertEqual('Invalid type for arches parameter: %s' % type(arches),
+                             str(ex.exception))
+        kojihub.get_host.assert_called_once_with('hostname', strict=True)
+        self.assertEqual(self.inserts, [])
+        self.assertEqual(self.updates, [])
+
     def test_edit_host_valid(self):
         kojihub.get_host = mock.MagicMock()
-        kojihub.get_host.return_value = {
-            'id': 123,
-            'user_id': 234,
-            'name': 'hostname',
-            'arches': ['x86_64'],
-            'capacity': 100.0,
-            'description': 'description',
-            'comment': 'comment',
-            'enabled': False,
-        }
+        kojihub.get_host.return_value = self.hostinfo
         self.context.event_id = 42
         self.context.session.user_id = 23
 
-        r = self.exports.editHost('hostname', arches=['x86_64', 'i386'],
-                capacity=12.0, comment='comment_new', non_existing_kw='bogus')
+        r = self.exports.editHost('hostname', arches='x86_64 i386', capacity=12.0,
+                                  comment='comment_new', non_existing_kw='bogus')
 
         self.assertTrue(r)
         kojihub.get_host.assert_called_once_with('hostname', strict=True)
@@ -87,12 +122,11 @@ class TestSetHostEnabled(unittest.TestCase):
         # insert
         self.assertEqual(len(self.inserts), 1)
         insert = self.inserts[0]
-        #data = kojihub.get_host.return_value
         data = {
             'create_event': 42,
             'creator_id': 23,
             'host_id': 123,
-            'arches': ['x86_64', 'i386'],
+            'arches': 'x86_64 i386',
             'capacity': 12.0,
             'comment': 'comment_new',
             'description': 'description',
@@ -105,16 +139,7 @@ class TestSetHostEnabled(unittest.TestCase):
 
     def test_edit_host_no_change(self):
         kojihub.get_host = mock.MagicMock()
-        kojihub.get_host.return_value = {
-            'id': 123,
-            'user_id': 234,
-            'name': 'hostname',
-            'arches': ['x86_64'],
-            'capacity': 100.0,
-            'description': 'description',
-            'comment': 'comment',
-            'enabled': False,
-        }
+        kojihub.get_host.return_value = self.hostinfo
         self.context.event_id = 42
         self.context.session.user_id = 23
 
diff --git a/tests/test_hub/test_edit_permission.py b/tests/test_hub/test_edit_permission.py
index 39a2d02..0a6b8ff 100644
--- a/tests/test_hub/test_edit_permission.py
+++ b/tests/test_hub/test_edit_permission.py
@@ -42,3 +42,12 @@ class TestEditPermission(unittest.TestCase):
         self.assertEqual(up.table, 'permissions')
         self.assertEqual(up.rawdata, {})
         self.context.session.assertPerm.assert_called_with('admin')
+
+    def test_edit_permission_wrong_type_permission(self):
+        description = ['test-description']
+        with self.assertRaises(koji.GenericError) as ex:
+            self.exports.editPermission(self.perm_name, description=description)
+        self.assertEqual(f"Invalid type for value '{description}': {type(description)}",
+                         str(ex.exception))
+        self.update_processor.assert_not_called()
+        self.context.session.assertPerm.assert_called_with('admin')
diff --git a/tests/test_hub/test_edit_tag.py b/tests/test_hub/test_edit_tag.py
index bc7aa1e..6112047 100644
--- a/tests/test_hub/test_edit_tag.py
+++ b/tests/test_hub/test_edit_tag.py
@@ -236,7 +236,7 @@ WHERE id = %(tagID)i""", {'name': 'newtag', 'tagID': 333})
         with self.assertRaises(koji.GenericError):
             kojihub._edit_tag('tag', **kwargs)
 
-    def test_edit_wrong_format_tag(self):
+    def test_edit_wrong_tag(self):
         tag_name_new = 'new-test-tag+'
         tag_name = 'tag'
         self.get_tag.return_value = {'id': 333,
@@ -268,3 +268,36 @@ WHERE id = %(tagID)i""", {'name': 'newtag', 'tagID': 333})
         self.verify_name_internal.side_effect = koji.GenericError
         with self.assertRaises(koji.GenericError):
             kojihub._edit_tag('tag', **kwargs)
+
+    def test_edit_tag_remove_extra_wrong_format(self):
+        kwargs = {
+            'perm': None,
+            'name': 'tag_name_new',
+            'arches': 'arch1 arch2',
+            'locked': True,
+            'maven_support': False,
+            'maven_include_all': False,
+            'extra': {},
+            'remove_extra': 'remove-extra'
+        }
+        with self.assertRaises(koji.ParameterError) as ex:
+            kojihub._edit_tag('tag', **kwargs)
+        self.assertEqual(f"Invalid type for value '{kwargs['remove_extra']}': "
+                         f"{type(kwargs['remove_extra'])}", str(ex.exception))
+
+    def test_edit_tag_block_extra_wrong_format(self):
+        kwargs = {
+            'perm': None,
+            'name': 'tag_name_new',
+            'arches': 'arch1 arch2',
+            'locked': True,
+            'maven_support': False,
+            'maven_include_all': False,
+            'extra': {},
+            'remove_extra': [],
+            'block_extra': 'block-extra'
+        }
+        with self.assertRaises(koji.ParameterError) as ex:
+            kojihub._edit_tag('tag', **kwargs)
+        self.assertEqual(f"Invalid type for value '{kwargs['block_extra']}': "
+                         f"{type(kwargs['block_extra'])}", str(ex.exception))
diff --git a/tests/test_hub/test_edit_tag_external_repo.py b/tests/test_hub/test_edit_tag_external_repo.py
index c96a173..22ff6eb 100644
--- a/tests/test_hub/test_edit_tag_external_repo.py
+++ b/tests/test_hub/test_edit_tag_external_repo.py
@@ -42,8 +42,7 @@ class TestEditTagExternalRepo(unittest.TestCase):
         self.get_tag_external_repos.return_value = []
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.edit_tag_external_repo('tag', 'ext_repo', priority=6, merge_mode='bare')
-        self.assertEqual(cm.exception.args[0],
-                         'external repo ext_repo not associated with tag tag')
+        self.assertEqual('external repo ext_repo not associated with tag tag', str(cm.exception))
         self.get_tag.assert_called_once_with('tag', strict=True)
         self.get_external_repo.assert_called_once_with('ext_repo', strict=True)
         self.get_tag_external_repos.assert_called_once_with(tag_info=1, repo_info=11)
diff --git a/tests/test_hub/test_enable_channel.py b/tests/test_hub/test_enable_channel.py
index b8a3bf6..ac529c7 100644
--- a/tests/test_hub/test_enable_channel.py
+++ b/tests/test_hub/test_enable_channel.py
@@ -29,7 +29,7 @@ class TestEnableChannel(unittest.TestCase):
         self.get_channel.return_value = None
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.enableChannel(self.channelname)
-        self.assertEqual("No such channel: %s" % self.channelname, str(cm.exception))
+        self.assertEqual(f"No such channel: {self.channelname}", str(cm.exception))
 
     def test_valid(self):
         self.get_channel.return_value = {'comment': None, 'description': None,
@@ -42,3 +42,9 @@ class TestEnableChannel(unittest.TestCase):
         self.assertEqual(update.values, {'comment': None, 'description': None, 'enabled': False,
                                          'id': 1, 'name': 'test-channel'})
         self.assertEqual(update.clauses, ['id = %(id)i'])
+
+    def test_wrong_type_channel(self):
+        comment = ['test-comment']
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.enableChannel(self.channelname, comment=comment)
+        self.assertEqual(f"Invalid type for value '{comment}': {type(comment)}", str(cm.exception))
diff --git a/tests/test_hub/test_getRPM.py b/tests/test_hub/test_getRPM.py
index 7d25c55..0f12ce6 100644
--- a/tests/test_hub/test_getRPM.py
+++ b/tests/test_hub/test_getRPM.py
@@ -14,7 +14,7 @@ class TestGetRPM(unittest.TestCase):
         rpminfo = ['test-user']
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.get_rpm(rpminfo)
-        self.assertEqual("Invalid type for rpminfo: %s" % type(rpminfo), str(cm.exception))
+        self.assertEqual(f"Invalid type for rpminfo: {type(rpminfo)}", str(cm.exception))
 
 
 class TestGetRPMHeaders(unittest.TestCase):
@@ -41,7 +41,7 @@ class TestGetRPMHeaders(unittest.TestCase):
         filepath = '../test/path'
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getRPMHeaders(taskID=99, filepath=filepath)
-        self.assertEqual("Invalid filepath: %s" % filepath, str(cm.exception))
+        self.assertEqual(f"Invalid filepath: {filepath}", str(cm.exception))
         self.get_rpm.assert_not_called()
         self.get_build.assert_not_called()
         self.get_header_fields.assert_not_called()
diff --git a/tests/test_hub/test_get_archive.py b/tests/test_hub/test_get_archive.py
new file mode 100644
index 0000000..05b13bd
--- /dev/null
+++ b/tests/test_hub/test_get_archive.py
@@ -0,0 +1,28 @@
+import unittest
+import mock
+
+import koji
+import kojihub
+
+
+class TestGetArchive(unittest.TestCase):
+
+    def setUp(self):
+        self.maxDiff = None
+        self.list_archives = mock.patch('kojihub.list_archives').start()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_get_archive_non_exist_archive_with_strict(self):
+        archive_id = 1
+        self.list_archives.return_value = []
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.get_archive(archive_id, strict=True)
+        self.assertEqual(f"No such archive: {archive_id}", str(cm.exception))
+
+    def test_get_archive_non_exist_archive_without_strict(self):
+        archive_id = 1
+        self.list_archives.return_value = []
+        rv = kojihub.get_archive(archive_id)
+        self.assertEqual(rv, None)
diff --git a/tests/test_hub/test_get_archive_type.py b/tests/test_hub/test_get_archive_type.py
new file mode 100644
index 0000000..bf27b4a
--- /dev/null
+++ b/tests/test_hub/test_get_archive_type.py
@@ -0,0 +1,20 @@
+import unittest
+
+import koji
+import kojihub
+
+
+class TestGetArchiveType(unittest.TestCase):
+
+    def test_get_archive_wrong_type_filename(self):
+        filename = ['test-filename']
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.get_archive_type(filename=filename)
+        self.assertEqual(f"Invalid type for value '{filename}': {type(filename)}",
+                         str(cm.exception))
+
+    def test_get_archive_without_opt(self):
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.get_archive_type()
+        self.assertEqual("one of filename, type_name, or type_id must be specified",
+                         str(cm.exception))
diff --git a/tests/test_hub/test_get_build_notification.py b/tests/test_hub/test_get_build_notification.py
new file mode 100644
index 0000000..518e33a
--- /dev/null
+++ b/tests/test_hub/test_get_build_notification.py
@@ -0,0 +1,22 @@
+import mock
+import unittest
+import koji
+import kojihub
+
+
+class TestGetBuildNotification(unittest.TestCase):
+
+    def setUp(self):
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor').start()
+        self.query = self.QueryProcessor.return_value
+        self.exports = kojihub.RootExports()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_empty_result_with_strict(self):
+        notif_id = 1
+        self.query.executeOne.return_value = None
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getBuildNotification(notif_id, strict=True)
+        self.assertEqual(f"No notification with ID {notif_id} found", str(cm.exception))
diff --git a/tests/test_hub/test_get_build_notification_block.py b/tests/test_hub/test_get_build_notification_block.py
new file mode 100644
index 0000000..1a17072
--- /dev/null
+++ b/tests/test_hub/test_get_build_notification_block.py
@@ -0,0 +1,22 @@
+import mock
+import unittest
+import koji
+import kojihub
+
+
+class TestGetBuildNotificationBlock(unittest.TestCase):
+
+    def setUp(self):
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor').start()
+        self.query = self.QueryProcessor.return_value
+        self.exports = kojihub.RootExports()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_empty_result_with_strict(self):
+        notif_id = 1
+        self.query.executeOne.return_value = None
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getBuildNotificationBlock(notif_id, strict=True)
+        self.assertEqual(f"No notification block with ID {notif_id} found", str(cm.exception))
diff --git a/tests/test_hub/test_get_build_notification_blocks.py b/tests/test_hub/test_get_build_notification_blocks.py
new file mode 100644
index 0000000..720db8e
--- /dev/null
+++ b/tests/test_hub/test_get_build_notification_blocks.py
@@ -0,0 +1,29 @@
+import mock
+import unittest
+import koji
+import kojihub
+
+
+class TestGetBuildNotificationBlocks(unittest.TestCase):
+    def setUp(self):
+        self.exports = kojihub.RootExports()
+        self.get_user = mock.patch('kojihub.get_user').start()
+        self.get_build_notification_blocks = mock.patch(
+            'kojihub.get_build_notification_blocks').start()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_loggedin_user(self):
+        self.get_user.return_value = {'id': 1}
+        self.exports.getBuildNotificationBlocks(None)
+        self.get_user.assert_called_once_with(None, strict=True)
+        self.get_build_notification_blocks.assert_called_once_with(1)
+
+    def test_user_not_found(self):
+        self.get_user.side_effect = koji.GenericError('error msg')
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getBuildNotificationBlocks(1)
+        self.get_user.assert_called_once_with(1, strict=True)
+        self.get_build_notification_blocks.assert_not_called()
+        self.assertEqual(cm.exception.args[0], 'error msg')
diff --git a/tests/test_hub/test_get_build_notifications.py b/tests/test_hub/test_get_build_notifications.py
index b4fb176..aba3e97 100644
--- a/tests/test_hub/test_get_build_notifications.py
+++ b/tests/test_hub/test_get_build_notifications.py
@@ -5,18 +5,21 @@ import kojihub
 
 
 class TestGetBuildNotifications(unittest.TestCase):
-    @mock.patch('kojihub.get_user', return_value={'id': 1})
-    @mock.patch('kojihub.get_build_notifications')
-    def test_loggedin_user(self, get_build_notifications, get_user):
+    def setUp(self):
+        self.exports = kojihub.RootExports()
+        self.get_user = mock.patch('kojihub.get_user').start()
+        self.get_build_notifications = mock.patch('kojihub.get_build_notifications').start()
+
+    def test_loggedin_user(self):
+        self.get_user.return_value = {'id': 1}
         kojihub.RootExports().getBuildNotifications(None)
-        get_user.assert_called_once_with(None, strict=True)
-        get_build_notifications.assert_called_once_with(1)
+        self.get_user.assert_called_once_with(None, strict=True)
+        self.get_build_notifications.assert_called_once_with(1)
 
-    @mock.patch('kojihub.get_user', side_effect=koji.GenericError('error msg'))
-    @mock.patch('kojihub.get_build_notifications')
-    def test_user_not_found(self, get_build_notifications, get_user):
+    def test_user_not_found(self):
+        self.get_user.side_effect = koji.GenericError('error msg')
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.RootExports().getBuildNotifications(1)
-        get_user.assert_called_once_with(1, strict=True)
-        get_build_notifications.assert_not_called()
+        self.get_user.assert_called_once_with(1, strict=True)
+        self.get_build_notifications.assert_not_called()
         self.assertEqual(cm.exception.args[0], 'error msg')
diff --git a/tests/test_hub/test_get_changelog_entries.py b/tests/test_hub/test_get_changelog_entries.py
index d75960f..220cf14 100644
--- a/tests/test_hub/test_get_changelog_entries.py
+++ b/tests/test_hub/test_get_changelog_entries.py
@@ -24,13 +24,13 @@ class TestGetChangelogEntries(unittest.TestCase):
         self.get_build.return_value = None
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getChangelogEntries(buildID=build_id, strict=True)
-        self.assertEqual("No such build: %s" % build_id, str(cm.exception))
+        self.assertEqual(f"No such build: {build_id}", str(cm.exception))
 
     def test_taskid_invalid_path(self):
         filepath = '../test/path'
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getChangelogEntries(taskID=99, filepath=filepath)
-        self.assertEqual("Invalid filepath: %s" % filepath, str(cm.exception))
+        self.assertEqual(f"Invalid filepath: {filepath}", str(cm.exception))
 
     def test_taskid_without_filepath(self):
         with self.assertRaises(koji.GenericError) as cm:
@@ -43,7 +43,7 @@ class TestGetChangelogEntries(unittest.TestCase):
         self.os_path_exists.return_value = True
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getChangelogEntries(taskID=99, before=before, filepath=filepath)
-        self.assertEqual("Invalid type for before: %s" % type(before), str(cm.exception))
+        self.assertEqual(f"Invalid type for before: {type(before)}", str(cm.exception))
 
     def test_after_invalid_type(self):
         after = {'after': '1133456'}
@@ -51,7 +51,7 @@ class TestGetChangelogEntries(unittest.TestCase):
         self.os_path_exists.return_value = True
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getChangelogEntries(taskID=99, after=after, filepath=filepath)
-        self.assertEqual("Invalid type for after: %s" % type(after), str(cm.exception))
+        self.assertEqual(f"Invalid type for after: {type(after)}", str(cm.exception))
 
     def test_srpm_path_not_exist(self):
         filepath = 'test/path'
@@ -62,4 +62,4 @@ class TestGetChangelogEntries(unittest.TestCase):
         self.os_path_exists.return_value = False
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getChangelogEntries(taskID=task_id, filepath=filepath, strict=True)
-        self.assertEqual("SRPM %s doesn't exist" % srpm_path, str(cm.exception))
+        self.assertEqual(f"SRPM {srpm_path} doesn't exist", str(cm.exception))
diff --git a/tests/test_hub/test_get_external_repo.py b/tests/test_hub/test_get_external_repo.py
index 1880edf..cb687e4 100644
--- a/tests/test_hub/test_get_external_repo.py
+++ b/tests/test_hub/test_get_external_repo.py
@@ -17,7 +17,7 @@ class TestGetExternalRepo(unittest.TestCase):
         self.get_external_repos.return_value = []
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.getExternalRepo(repo, strict=True)
-        self.assertEqual("No such repo: %s" % repo, str(cm.exception))
+        self.assertEqual(f"No such repo: {repo}", str(cm.exception))
 
     def test_non_exist_repo_without_strict(self):
         repo = 'test-repo'
diff --git a/tests/test_hub/test_get_external_repos.py b/tests/test_hub/test_get_external_repos.py
index 25f09fa..6bbd245 100644
--- a/tests/test_hub/test_get_external_repos.py
+++ b/tests/test_hub/test_get_external_repos.py
@@ -1,4 +1,3 @@
-import koji
 import kojihub
 from .utils import DBQueryTestCase
 
@@ -97,9 +96,3 @@ class TestGetExternalRepos(DBQueryTestCase):
         self.assertEqual(rv, [{'id': 1,
                                'name': 'ext_repo_1',
                                'url': 'http://example.com/repo/'}])
-
-    def test_get_external_repos_wrong_type(self):
-        info = {'info_key': 'info_value'}
-        with self.assertRaises(koji.GenericError) as cm:
-            kojihub.get_external_repos(info=info)
-        self.assertEqual("Invalid name or id value: %s" % info, str(cm.exception))
diff --git a/tests/test_hub/test_get_next_release.py b/tests/test_hub/test_get_next_release.py
index 561a3f1..27a720c 100644
--- a/tests/test_hub/test_get_next_release.py
+++ b/tests/test_hub/test_get_next_release.py
@@ -7,7 +7,9 @@ import kojihub
 class TestGetNextRelease(unittest.TestCase):
 
     def setUp(self):
+        self.maxDiff = None
         self.QueryProcessor = mock.patch('kojihub.QueryProcessor').start()
+        self.get_build = mock.patch('kojihub.get_build').start()
         self._dml = mock.patch('kojihub._dml').start()
         self.query = self.QueryProcessor.return_value
         self.binfo = {'name': 'name', 'version': 'version'}
@@ -25,7 +27,7 @@ class TestGetNextRelease(unittest.TestCase):
         for n in [1, 2, 3, 5, 8, 13, 21, 34, 55]:
             self.query.executeOne.return_value = {'release': str(n)}
             result = kojihub.get_next_release(self.binfo)
-            self.assertEqual(result, str(n+1))
+            self.assertEqual(result, str(n + 1))
 
     def test_get_next_release_complex(self):
         data = [
@@ -59,10 +61,10 @@ class TestGetNextRelease(unittest.TestCase):
             # bad_incr_value
             "foo",
             None,
-            1.1,
-            {1:1},
+            {1: 1},
             [1],
         ]
         for val in data:
-            with self.assertRaises(koji.ParameterError):
+            with self.assertRaises(koji.ParameterError) as ex:
                 kojihub.get_next_release(self.binfo, incr=val)
+            self.assertEqual('incr parameter must be an integer', str(ex.exception))
diff --git a/tests/test_hub/test_get_notification_recipients.py b/tests/test_hub/test_get_notification_recipients.py
new file mode 100644
index 0000000..d6e9e86
--- /dev/null
+++ b/tests/test_hub/test_get_notification_recipients.py
@@ -0,0 +1,258 @@
+import mock
+import unittest
+
+import koji
+import kojihub
+
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
+
+class TestGetNotificationRecipients(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self.readPackageList = mock.patch('kojihub.readPackageList').start()
+        self.get_user = mock.patch('kojihub.get_user').start()
+
+        self.exports = kojihub.RootExports()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_get_notification_recipients_watchers(self):
+        # without build / tag_id
+        build = None
+        tag_id = None
+        state = koji.BUILD_STATES['CANCELED']
+
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(emails, [])
+
+        # only query to watchers
+        self.assertEqual(len(self.queries), 1)
+        q = self.queries[0]
+        self.assertEqual(q.columns, ['email', 'user_id'])
+        self.assertEqual(q.tables, ['build_notifications'])
+        self.assertEqual(q.clauses, ['package_id IS NULL',
+                                     'status = %(users_status)i',
+                                     'success_only = FALSE',
+                                     'tag_id IS NULL',
+                                     'usertype IN %(users_usertypes)s'])
+        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
+        self.assertEqual(q.values['state'], state)
+        self.assertEqual(q.values['build'], build)
+        self.assertEqual(q.values['tag_id'], tag_id)
+
+        '''
+        q = self.queries[1]
+        self.assertEqual(q.columns, ['user_id'])
+        self.assertEqual(q.tables, ['build_notifications_block'])
+        self.assertEqual(q.clauses, ['user_id IN %(user_ids)s'])
+        self.assertEqual(q.joins, [])
+        self.assertEqual(q.values['user_ids'], None)
+        '''
+        self.readPackageList.assert_not_called()
+
+    def test_get_notification_recipients_build_without_tag(self):
+        ### with build without tag
+        tag_id = None
+        state = koji.BUILD_STATES['CANCELED']
+        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
+        self.queries = []
+        self.set_queries([
+            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+            []
+        ])
+
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(emails, ['owner_name@test.domain.com'])
+
+        # there should be only query to watchers
+        self.assertEqual(len(self.queries), 2)
+        q = self.queries[0]
+        self.assertEqual(q.columns, ['email', 'user_id'])
+        self.assertEqual(q.tables, ['build_notifications'])
+        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
+                                     'status = %(users_status)i',
+                                     'success_only = FALSE',
+                                     'tag_id IS NULL',
+                                     'usertype IN %(users_usertypes)s'])
+        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
+        self.assertEqual(q.values['package_id'], build['package_id'])
+        self.assertEqual(q.values['state'], state)
+        self.assertEqual(q.values['build'], build)
+        self.assertEqual(q.values['tag_id'], tag_id)
+
+        q = self.queries[1]
+        self.assertEqual(q.columns, ['user_id'])
+        self.assertEqual(q.tables, ['build_notifications_block'])
+        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
+                                     'tag_id IS NULL',
+                                     'user_id IN %(user_ids)s',
+                                     ])
+        self.assertEqual(q.joins, None)
+        self.assertEqual(q.values['user_ids'], [5])
+
+        self.readPackageList.assert_not_called()
+
+    def test_get_notification_recipients_tag_without_build(self):
+        ### with tag without build makes no sense
+        build = None
+        tag_id = 123
+        state = koji.BUILD_STATES['CANCELED']
+        self.queries = []
+
+        with self.assertRaises(koji.GenericError):
+            kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(self.queries, [])
+        self.readPackageList.assert_not_called()
+
+    def set_queries(self, return_values):
+        self.query_returns = return_values
+        self.query_returns.reverse()
+
+        def getQuery(*args, **kwargs):
+            q = QP(*args, **kwargs)
+            q.execute = mock.MagicMock()
+            q.execute.return_value = self.query_returns.pop()
+            self.queries.append(q)
+            return q
+        self.QueryProcessor.side_effect = getQuery
+
+    def test_get_notification_recipients_tag_with_build(self):
+        ### with tag and build
+        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
+        tag_id = 123
+        state = koji.BUILD_STATES['CANCELED']
+        self.readPackageList.return_value = {12345: {'blocked': False, 'owner_id': 'owner_id'}}
+        self.get_user.return_value = {
+            'id': 342,
+            'name': 'pkg_owner_name',
+            'status': koji.USER_STATUS['NORMAL'],
+            'usertype': koji.USERTYPES['NORMAL']
+        }
+        self.set_queries([
+            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+            []
+        ])
+
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(sorted(emails),
+                         ['owner_name@test.domain.com', 'pkg_owner_name@test.domain.com'])
+
+        # there should be only query to watchers
+        self.assertEqual(len(self.queries), 2)
+        q = self.queries[0]
+        self.assertEqual(q.columns, ['email', 'user_id'])
+        self.assertEqual(q.tables, ['build_notifications'])
+        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
+                                     'status = %(users_status)i',
+                                     'success_only = FALSE',
+                                     'tag_id = %(tag_id)i OR tag_id IS NULL',
+                                     'usertype IN %(users_usertypes)s',
+                                     ])
+        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
+        self.assertEqual(q.values['package_id'], build['package_id'])
+        self.assertEqual(q.values['state'], state)
+        self.assertEqual(q.values['build'], build)
+        self.assertEqual(q.values['tag_id'], tag_id)
+
+        q = self.queries[1]
+        self.assertEqual(q.columns, ['user_id'])
+        self.assertEqual(q.tables, ['build_notifications_block'])
+        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
+                                     'tag_id = %(tag_id)i OR tag_id IS NULL',
+                                     'user_id IN %(user_ids)s',
+                                     ])
+        self.assertEqual(q.joins, None)
+        self.assertEqual(sorted(q.values['user_ids']), [5, 342])
+        self.readPackageList.assert_called_once_with(
+            pkgID=build['package_id'], tagID=tag_id, inherit=True)
+        self.get_user.asssert_called_once_with(342, strict=True)
+
+    def test_get_notification_recipients_blocked_pkg_owner(self):
+        # blocked package owner
+        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
+        tag_id = 123
+        state = koji.BUILD_STATES['CANCELED']
+        self.get_user.return_value = {
+            'id': 342,
+            'name': 'pkg_owner_name',
+            'status': koji.USER_STATUS['BLOCKED'],
+            'usertype': koji.USERTYPES['NORMAL']
+        }
+        self.set_queries([
+            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+            []
+        ])
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(emails, ['owner_name@test.domain.com'])
+
+    def test_get_notification_recipients_optout(self):
+        # blocked package owner
+        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
+        tag_id = 123
+        state = koji.BUILD_STATES['CANCELED']
+        self.get_user.return_value = {
+            'id': 342,
+            'name': 'pkg_owner_name',
+            'status': koji.USER_STATUS['NORMAL'],
+            'usertype': koji.USERTYPES['NORMAL']
+        }
+        self.set_queries([
+            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+            [{'user_id': 5}]
+        ])
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(emails, [])
+
+    def test_get_notification_recipients_machine(self):
+        # package owner is machine
+        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
+        tag_id = 123
+        state = koji.BUILD_STATES['CANCELED']
+        self.get_user.return_value = {
+            'id': 342,
+            'name': 'pkg_owner_name',
+            'status': koji.USER_STATUS['NORMAL'],
+            'usertype': koji.USERTYPES['HOST']
+        }
+        self.set_queries([
+            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
+            []
+        ])
+        emails = kojihub.get_notification_recipients(build, tag_id, state)
+        self.assertEqual(emails, ['owner_name@test.domain.com'])
diff --git a/tests/test_hub/test_grant_permissions.py b/tests/test_hub/test_grant_permissions.py
index 3745b4a..d8371ba 100644
--- a/tests/test_hub/test_grant_permissions.py
+++ b/tests/test_hub/test_grant_permissions.py
@@ -122,3 +122,13 @@ class TestGrantPermission(unittest.TestCase):
         self.assertEqual(ip.table, 'user_perms')
         self.assertEqual(ip.rawdata, {})
         self.context.session.assertPerm.assert_called_with('admin')
+
+    def test_grant_permission_description_wrong_type(self):
+        description = ['test-description']
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.grantPermission(self.user_name, self.perms_name,
+                                         description=description, create=True)
+        self.assertEqual(f"Invalid type for value '{description}': {type(description)}",
+                         str(ex.exception))
+        self.insert_processor.assert_not_called()
+        self.context.session.assertPerm.assert_called_with('admin')
diff --git a/tests/test_hub/test_import_archive.py b/tests/test_hub/test_import_archive.py
new file mode 100644
index 0000000..2a6719d
--- /dev/null
+++ b/tests/test_hub/test_import_archive.py
@@ -0,0 +1,40 @@
+import mock
+import unittest
+import koji
+import kojihub
+
+
+class TestImportArchive(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.filepath = 'path/to/file'
+        self.buildinfo = 'build-1-1.4'
+        self.type_archive = 'maven'
+        self.typeinfo = {'group_id': 1, 'artifact_id': 2, 'version': 1}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_maven_not_enabled(self):
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.importArchive(self.filepath, self.buildinfo, self.type_archive,
+                                       self.typeinfo)
+        self.assertEqual("Maven support not enabled", str(cm.exception))
+
+    def test_win_not_enabled(self):
+        type_archive = 'win'
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.importArchive(self.filepath, self.buildinfo, type_archive, self.typeinfo)
+        self.assertEqual("Windows support not enabled", str(cm.exception))
+
+    def test_unsupported_type(self):
+        type_archive = 'test-type'
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.importArchive(self.filepath, self.buildinfo, type_archive, self.typeinfo)
+        self.assertEqual(f"unsupported archive type: {type_archive}", str(cm.exception))
diff --git a/tests/test_hub/test_import_archive_internal.py b/tests/test_hub/test_import_archive_internal.py
new file mode 100644
index 0000000..70f64ba
--- /dev/null
+++ b/tests/test_hub/test_import_archive_internal.py
@@ -0,0 +1,23 @@
+import unittest
+import mock
+
+import koji
+import kojihub
+
+
+class TestImportArchiveInternal(unittest.TestCase):
+    def setUp(self):
+        self.os_path_exists = mock.patch('os.path.exists').start()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_import_archive_internal_non_exist_filepath(self):
+        self.os_path_exists.return_value = False
+        filepath = 'test/file/path/to/archive'
+        buildinfo = {'id': 1, 'name': 'test-build'}
+        type_archive = 'maven'
+        typeInfo = {'group_id': 1, 'artifact_id': 2, 'version': 3}
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_archive_internal(filepath, buildinfo, type_archive, typeInfo)
+        self.assertEqual(f"No such file: {filepath}", str(cm.exception))
diff --git a/tests/test_hub/test_import_build.py b/tests/test_hub/test_import_build.py
index 0f18a03..5de5a7a 100644
--- a/tests/test_hub/test_import_build.py
+++ b/tests/test_hub/test_import_build.py
@@ -1,203 +1,13 @@
 import copy
 import mock
+import unittest
 import shutil
 import tempfile
-import unittest
 
 import koji
 import kojihub
 
 
-class TestImportRPM(unittest.TestCase):
-    def setUp(self):
-        self.tempdir = tempfile.mkdtemp()
-        self.filename = self.tempdir + "/name-version-release.arch.rpm"
-        # Touch a file
-        with open(self.filename, 'w'):
-            pass
-        self.src_filename = self.tempdir + "/name-version-release.src.rpm"
-        # Touch a file
-        with open(self.src_filename, 'w'):
-            pass
-        self.context = mock.patch('kojihub.context').start()
-        self.cursor = mock.MagicMock()
-
-        self.rpm_header_retval = {
-            'filename': 'name-version-release.arch.rpm',
-            'sourcepackage': 2,
-            1000: 'name',
-            1001: 'version',
-            1002: 'release',
-            1003: 'epoch',
-            1006: 'buildtime',
-            1022: 'arch',
-            1044: 'name-version-release.arch',
-            1106: 'sourcepackage',
-            261: 'payload hash',
-        }
-
-    def tearDown(self):
-        shutil.rmtree(self.tempdir)
-
-    def test_nonexistant_rpm(self):
-        with self.assertRaises(koji.GenericError):
-            kojihub.import_rpm("this does not exist")
-
-    @mock.patch('kojihub.get_build')
-    @mock.patch('koji.get_rpm_header')
-    def test_import_rpm_failed_build(self, get_rpm_header, get_build):
-        get_rpm_header.return_value = self.rpm_header_retval
-        get_build.return_value = {
-            'state': koji.BUILD_STATES['FAILED'],
-            'name': 'name',
-            'version': 'version',
-            'release': 'release',
-        }
-        with self.assertRaises(koji.GenericError):
-            kojihub.import_rpm(self.filename)
-
-    @mock.patch('kojihub.new_typed_build')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub._singleValue')
-    @mock.patch('kojihub.get_build')
-    @mock.patch('koji.get_rpm_header')
-    def test_import_rpm_completed_build(self, get_rpm_header, get_build,
-                                        _singleValue, _dml,
-                                        new_typed_build):
-        get_rpm_header.return_value = self.rpm_header_retval
-        get_build.return_value = {
-            'state': koji.BUILD_STATES['COMPLETE'],
-            'name': 'name',
-            'version': 'version',
-            'release': 'release',
-            'id': 12345,
-        }
-        _singleValue.return_value = 9876
-        kojihub.import_rpm(self.filename)
-        fields = [
-            'arch',
-            'build_id',
-            'buildroot_id',
-            'buildtime',
-            'epoch',
-            'external_repo_id',
-            'id',
-            'name',
-            'payloadhash',
-            'release',
-            'size',
-            'version',
-        ]
-        statement = 'INSERT INTO rpminfo (%s) VALUES (%s)' % (
-            ", ".join(fields),
-            ", ".join(['%%(%s)s' % field for field in fields])
-        )
-        values = {
-            'build_id': 12345,
-            'name': 'name',
-            'arch': 'arch',
-            'buildtime': 'buildtime',
-            'payloadhash': '7061796c6f61642068617368',
-            'epoch': 'epoch',
-            'version': 'version',
-            'buildroot_id': None,
-            'release': 'release',
-            'external_repo_id': 0,
-            'id': 9876,
-            'size': 0,
-        }
-        _dml.assert_called_once_with(statement, values)
-
-    @mock.patch('kojihub.new_typed_build')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub._singleValue')
-    @mock.patch('kojihub.get_build')
-    @mock.patch('koji.get_rpm_header')
-    def test_import_rpm_completed_source_build(self, get_rpm_header, get_build,
-                                               _singleValue, _dml,
-                                               new_typed_build):
-        retval = copy.copy(self.rpm_header_retval)
-        retval.update({
-            'filename': 'name-version-release.arch.rpm',
-            1044: 'name-version-release.src',
-            1022: 'src',
-            1106: 1,
-        })
-        get_rpm_header.return_value = retval
-        get_build.return_value = {
-            'state': koji.BUILD_STATES['COMPLETE'],
-            'name': 'name',
-            'version': 'version',
-            'release': 'release',
-            'id': 12345,
-        }
-        _singleValue.return_value = 9876
-        kojihub.import_rpm(self.src_filename)
-        fields = [
-            'arch',
-            'build_id',
-            'buildroot_id',
-            'buildtime',
-            'epoch',
-            'external_repo_id',
-            'id',
-            'name',
-            'payloadhash',
-            'release',
-            'size',
-            'version',
-        ]
-        statement = 'INSERT INTO rpminfo (%s) VALUES (%s)' % (
-            ", ".join(fields),
-            ", ".join(['%%(%s)s' % field for field in fields])
-        )
-        values = {
-            'build_id': 12345,
-            'name': 'name',
-            'arch': 'src',
-            'buildtime': 'buildtime',
-            'payloadhash': '7061796c6f61642068617368',
-            'epoch': 'epoch',
-            'version': 'version',
-            'buildroot_id': None,
-            'release': 'release',
-            'external_repo_id': 0,
-            'id': 9876,
-            'size': 0,
-        }
-        _dml.assert_called_once_with(statement, values)
-
-    @mock.patch('os.path.exists')
-    def test_non_exist_file(self, os_path_exist):
-        exports = kojihub.RootExports()
-        basename = 'rpm-1-34'
-        uploadpath = koji.pathinfo.work()
-        filepath = '%s/%s/%s' % (uploadpath, self.filename, basename)
-        os_path_exist.return_value = False
-        with self.assertRaises(koji.GenericError) as cm:
-            exports.importRPM(self.filename, basename)
-        self.assertEqual("No such file: %s" % filepath, str(cm.exception))
-
-    @mock.patch('koji.get_rpm_header')
-    @mock.patch('os.path.exists')
-    @mock.patch('os.path.basename')
-    def test_non_exist_file(self, os_path_basename, os_path_exist, get_rpm_header):
-        self.cursor.fetchone.return_value = None
-        self.context.cnx.cursor.return_value = self.cursor
-        retval = copy.copy(self.rpm_header_retval)
-        retval.update({
-            'filename': 'name-version-release.arch.rpm',
-            'sourcepackage': 2
-        })
-        get_rpm_header.return_value = retval
-        os_path_exist.return_value = True
-        os_path_basename.return_value = 'name-version-release.arch.rpm'
-        kojihub.get_build.return_value = None
-        with self.assertRaises(koji.GenericError) as cm:
-            kojihub.import_rpm(self.src_filename)
-        self.assertEqual("No such build", str(cm.exception))
-
-
 class TestImportBuild(unittest.TestCase):
     def setUp(self):
         self.tempdir = tempfile.mkdtemp()
@@ -210,6 +20,22 @@ class TestImportBuild(unittest.TestCase):
         with open(self.src_filename, 'w'):
             pass
 
+        self.check_volume_policy = mock.patch('kojihub.check_volume_policy').start()
+        self.new_typed_build = mock.patch('kojihub.new_typed_build').start()
+        self._dml = mock.patch('kojihub._dml').start()
+        self._singleValue = mock.patch('kojihub._singleValue').start()
+        self.get_build = mock.patch('kojihub.get_build').start()
+        self.add_rpm_sig = mock.patch('kojihub.add_rpm_sig').start()
+        self.rip_rpm_sighdr = mock.patch('koji.rip_rpm_sighdr').start()
+        self.import_rpm_file = mock.patch('kojihub.import_rpm_file').start()
+        self.import_rpm = mock.patch('kojihub.import_rpm').start()
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor').start()
+        self.context = mock.patch('kojihub.context').start()
+        self.new_package = mock.patch('kojihub.new_package').start()
+        self.get_rpm_header = mock.patch('koji.get_rpm_header').start()
+        self.pathinfo_work = mock.patch('koji.pathinfo.work').start()
+        self.os_path_exists = mock.patch('os.path.exists').start()
+
         self.rpm_header_retval = {
             'filename': 'name-version-release.arch.rpm',
             1000: 'name',
@@ -225,39 +51,21 @@ class TestImportBuild(unittest.TestCase):
 
     def tearDown(self):
         shutil.rmtree(self.tempdir)
+        mock.patch.stopall()
 
-    @mock.patch('kojihub.check_volume_policy')
-    @mock.patch('kojihub.new_typed_build')
-    @mock.patch('kojihub._dml')
-    @mock.patch('kojihub._singleValue')
-    @mock.patch('kojihub.get_build')
-    @mock.patch('kojihub.add_rpm_sig')
-    @mock.patch('koji.rip_rpm_sighdr')
-    @mock.patch('kojihub.import_rpm_file')
-    @mock.patch('kojihub.import_rpm')
-    @mock.patch('kojihub.QueryProcessor')
-    @mock.patch('kojihub.context')
-    @mock.patch('kojihub.new_package')
-    @mock.patch('koji.get_rpm_header')
-    @mock.patch('koji.pathinfo.work')
-    def test_import_build_completed_build(self, work, get_rpm_header,
-                                          new_package, context, query,
-                                          import_rpm, import_rpm_file,
-                                          rip_rpm_sighdr, add_rpm_sig,
-                                          get_build, _singleValue, _dml,
-                                          new_typed_build, check_volume_policy):
+    def test_import_build_completed_build(self):
 
-        rip_rpm_sighdr.return_value = (0, 0)
+        self.rip_rpm_sighdr.return_value = (0, 0)
 
         processor = mock.MagicMock()
         processor.executeOne.return_value = None
-        query.return_value = processor
+        self.QueryProcessor.return_value = processor
 
-        context.session.user_id = 99
+        self.context.session.user_id = 99
 
-        work.return_value = '/'
+        self.pathinfo_work.return_value = '/'
 
-        check_volume_policy.return_value = {'id':0, 'name': 'DEFAULT'}
+        self.check_volume_policy.return_value = {'id': 0, 'name': 'DEFAULT'}
 
         retval = copy.copy(self.rpm_header_retval)
         retval.update({
@@ -266,7 +74,7 @@ class TestImportBuild(unittest.TestCase):
             1022: 'src',
             1106: 1,
         })
-        get_rpm_header.return_value = retval
+        self.get_rpm_header.return_value = retval
         binfo = {
             'state': koji.BUILD_STATES['COMPLETE'],
             'name': 'name',
@@ -277,7 +85,7 @@ class TestImportBuild(unittest.TestCase):
         # get_build called once to check for existing,
         # if it doesn't exist, called another time after creating
         # then 3rd later to get the build info
-        get_build.side_effect = [None, binfo, binfo]
+        self.get_build.side_effect = [None, binfo, binfo]
 
         kojihub.import_build(self.src_filename, [self.filename])
 
@@ -315,13 +123,29 @@ class TestImportBuild(unittest.TestCase):
             'pkg_id': mock.ANY,
             'id': mock.ANY,
         }
-        _dml.assert_called_once_with(statement, values)
+        self._dml.assert_called_once_with(statement, values)
 
-    @mock.patch('os.path.exists')
-    def test_import_build_non_exist_file(self, os_path_exists):
+    def test_import_build_non_exist_file(self):
         uploadpath = koji.pathinfo.work()
-        os_path_exists.return_value = False
+        self.os_path_exists.return_value = False
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.import_build(self.src_filename, [self.filename])
-        self.assertEqual("No such file: %s/%s" % (uploadpath, self.src_filename),
+        self.assertEqual(f"No such file: {uploadpath}/{self.src_filename}", str(cm.exception))
+
+    def test_import_build_wrong_type_brmap(self):
+        brmap = 'test-brmap'
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_build(self.src_filename, [self.filename], brmap=brmap)
+        self.assertEqual(f"Invalid type for value '{brmap}': {type(brmap)}", str(cm.exception))
+
+    def test_import_build_wrong_type_srpm(self):
+        srpm = ['test-srpm']
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_build(srpm, [self.filename])
+        self.assertEqual(f"Invalid type for value '{srpm}': {type(srpm)}", str(cm.exception))
+
+    def test_import_build_wrong_type_rpms(self):
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_build(self.src_filename, self.filename)
+        self.assertEqual(f"Invalid type for value '{self.filename}': {type(self.filename)}",
                          str(cm.exception))
diff --git a/tests/test_hub/test_import_rpm.py b/tests/test_hub/test_import_rpm.py
new file mode 100644
index 0000000..4e13dbf
--- /dev/null
+++ b/tests/test_hub/test_import_rpm.py
@@ -0,0 +1,187 @@
+import mock
+import unittest
+import koji
+import kojihub
+import shutil
+import tempfile
+import copy
+
+
+class TestImportRPM(unittest.TestCase):
+
+    def setUp(self):
+        self.exports = kojihub.RootExports()
+        self.tempdir = tempfile.mkdtemp()
+        self.filename = self.tempdir + "/name-version-release.arch.rpm"
+        # Touch a file
+        with open(self.filename, 'w'):
+            pass
+        self.src_filename = self.tempdir + "/name-version-release.src.rpm"
+        # Touch a file
+        with open(self.src_filename, 'w'):
+            pass
+        self.context = mock.patch('kojihub.context').start()
+        self.context.session.assertPerm = mock.MagicMock()
+        self.cursor = mock.MagicMock()
+
+        self.rpm_header_retval = {
+            'filename': 'name-version-release.arch.rpm',
+            'sourcepackage': 2,
+            1000: 'name',
+            1001: 'version',
+            1002: 'release',
+            1003: 'epoch',
+            1006: 'buildtime',
+            1022: 'arch',
+            1044: 'name-version-release.arch',
+            1106: 'sourcepackage',
+            261: 'payload hash',
+        }
+        self.get_build = mock.patch('kojihub.get_build').start()
+        self.get_rpm_header = mock.patch('koji.get_rpm_header').start()
+        self.new_typed_build = mock.patch('kojihub.new_typed_build').start()
+        self._dml = mock.patch('kojihub._dml').start()
+        self._singleValue = mock.patch('kojihub._singleValue').start()
+        self.os_path_exists = mock.patch('os.path.exists').start()
+        self.os_path_basename = mock.patch('os.path.basename').start()
+
+    def tearDown(self):
+        shutil.rmtree(self.tempdir)
+        mock.patch.stopall()
+
+    def test_nonexistant_rpm(self):
+        with self.assertRaises(koji.GenericError):
+            kojihub.import_rpm("this does not exist")
+
+    def test_import_rpm_failed_build(self):
+        self.get_rpm_header.return_value = self.rpm_header_retval
+        self.get_build.return_value = {
+            'state': koji.BUILD_STATES['FAILED'],
+            'name': 'name',
+            'version': 'version',
+            'release': 'release',
+        }
+        with self.assertRaises(koji.GenericError):
+            kojihub.import_rpm(self.filename)
+
+    def test_import_rpm_completed_build(self):
+        self.os_path_basename.return_value = 'name-version-release.arch.rpm'
+        self.get_rpm_header.return_value = self.rpm_header_retval
+        self.get_build.return_value = {
+            'state': koji.BUILD_STATES['COMPLETE'],
+            'name': 'name',
+            'version': 'version',
+            'release': 'release',
+            'id': 12345,
+        }
+        self._singleValue.return_value = 9876
+        kojihub.import_rpm(self.filename)
+        fields = [
+            'arch',
+            'build_id',
+            'buildroot_id',
+            'buildtime',
+            'epoch',
+            'external_repo_id',
+            'id',
+            'name',
+            'payloadhash',
+            'release',
+            'size',
+            'version',
+        ]
+        statement = 'INSERT INTO rpminfo (%s) VALUES (%s)' % (
+            ", ".join(fields),
+            ", ".join(['%%(%s)s' % field for field in fields])
+        )
+        values = {
+            'build_id': 12345,
+            'name': 'name',
+            'arch': 'arch',
+            'buildtime': 'buildtime',
+            'payloadhash': '7061796c6f61642068617368',
+            'epoch': 'epoch',
+            'version': 'version',
+            'buildroot_id': None,
+            'release': 'release',
+            'external_repo_id': 0,
+            'id': 9876,
+            'size': 0,
+        }
+        self._dml.assert_called_once_with(statement, values)
+
+    def test_import_rpm_completed_source_build(self):
+        self.os_path_basename.return_value = 'name-version-release.src.rpm'
+        retval = copy.copy(self.rpm_header_retval)
+        retval.update({
+            'filename': 'name-version-release.arch.rpm',
+            1044: 'name-version-release.src',
+            1022: 'src',
+            1106: 1,
+        })
+        self.get_rpm_header.return_value = retval
+        self.get_build.return_value = {
+            'state': koji.BUILD_STATES['COMPLETE'],
+            'name': 'name',
+            'version': 'version',
+            'release': 'release',
+            'id': 12345,
+        }
+        self._singleValue.return_value = 9876
+        kojihub.import_rpm(self.src_filename)
+        fields = [
+            'arch',
+            'build_id',
+            'buildroot_id',
+            'buildtime',
+            'epoch',
+            'external_repo_id',
+            'id',
+            'name',
+            'payloadhash',
+            'release',
+            'size',
+            'version',
+        ]
+        statement = 'INSERT INTO rpminfo (%s) VALUES (%s)' % (
+            ", ".join(fields),
+            ", ".join(['%%(%s)s' % field for field in fields])
+        )
+        values = {
+            'build_id': 12345,
+            'name': 'name',
+            'arch': 'src',
+            'buildtime': 'buildtime',
+            'payloadhash': '7061796c6f61642068617368',
+            'epoch': 'epoch',
+            'version': 'version',
+            'buildroot_id': None,
+            'release': 'release',
+            'external_repo_id': 0,
+            'id': 9876,
+            'size': 0,
+        }
+        self._dml.assert_called_once_with(statement, values)
+
+    def test_non_exist_file(self):
+        basename = 'rpm-1-34'
+        self.os_path_exists.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_rpm(self.filename, basename)
+        self.assertEqual(f"No such file: {self.filename}", str(cm.exception))
+
+    def test_non_exist_build(self):
+        self.cursor.fetchone.return_value = None
+        self.context.cnx.cursor.return_value = self.cursor
+        retval = copy.copy(self.rpm_header_retval)
+        retval.update({
+            'filename': 'name-version-release.arch.rpm',
+            'sourcepackage': 2
+        })
+        self.get_rpm_header.return_value = retval
+        self.os_path_exists.return_value = True
+        self.os_path_basename.return_value = 'name-version-release.arch.rpm'
+        kojihub.get_build.return_value = None
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.import_rpm(self.src_filename)
+        self.assertEqual("No such build", str(cm.exception))
diff --git a/tests/test_hub/test_list_archives.py b/tests/test_hub/test_list_archives.py
index 5a921b2..854504b 100644
--- a/tests/test_hub/test_list_archives.py
+++ b/tests/test_hub/test_list_archives.py
@@ -1,265 +1,224 @@
 import mock
-from .utils import DBQueryTestCase
+import unittest
 
 import koji
 import kojihub
 
+QP = kojihub.QueryProcessor
 
-class TestListArchives(DBQueryTestCase):
-    maxDiff = None
+
+class TestListArchives(unittest.TestCase):
+    def setUp(self):
+        self.maxDiff = None
+        self.get_build = mock.patch('kojihub.get_build').start()
+        self.get_host = mock.patch('kojihub.get_host').start()
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.get_query).start()
+        self.queries = []
+        self.exports = kojihub.RootExports()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def get_query(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
 
     def test_list_archives_simple(self):
-        rv = kojihub.list_archives()
+        kojihub.list_archives()
 
         self.assertEqual(len(self.queries), 1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=[],
-                                  values={})
-        self.assertEqual(rv, [])
-
-    def test_list_archives_strict(self):
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, [])
+        self.assertEqual(query.joins, ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                                       'btype ON archiveinfo.btype_id = btype.id'])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_archives_strict(self, QueryProcessor):
+        query = QueryProcessor.return_value
+        query.execute.return_value = None
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.list_archives(strict=True)
         self.assertEqual(cm.exception.args[0], 'No archives found.')
 
     def test_list_archives_buildid(self):
         kojihub.list_archives(buildID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['build_id = %(build_id)i'],
-                                  values={'build_id': 1})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['build_id = %(build_id)i'])
+        self.assertEqual(query.joins, ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                                       'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'build_id': 1})
 
     def test_list_archives_buildrootid(self):
         kojihub.list_archives(buildrootID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['buildroot_id = %(buildroot_id)i'],
-                                  values={'buildroot_id': 1})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['buildroot_id = %(buildroot_id)i'])
+        self.assertEqual(query.joins, ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                                       'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'buildroot_id': 1})
 
     def test_list_archives_componentbuildrootid(self):
         kojihub.list_archives(componentBuildrootID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'buildroot_archives on archiveinfo.id = buildroot_archives.archive_id'],
-                                  clauses=['buildroot_archives.buildroot_id = %(component_buildroot_id)i'],
-                                  values={'component_buildroot_id': 1},
-                                  colsByAlias={'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'component_buildroot_id': 'buildroot_archives.buildroot_id',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'project': 'buildroot_archives.project_dep',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses,
+                         ['buildroot_archives.buildroot_id = %(component_buildroot_id)i'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'buildroot_archives on archiveinfo.id = buildroot_archives.archive_id'])
+        self.assertEqual(query.values, {'component_buildroot_id': 1})
 
     def test_list_archives_imageid(self):
         kojihub.list_archives(imageID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'archive_components ON archiveinfo.id = archive_components.component_id'],
-                                  clauses=['archive_components.archive_id = %(imageID)i'],
-                                  values={'imageID': 1})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['archive_components.archive_id = %(imageID)i'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'archive_components ON archiveinfo.id = '
+                          'archive_components.component_id'])
+        self.assertEqual(query.values, {'imageID': 1})
 
     def test_list_archives_hostid(self):
         kojihub.list_archives(hostID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'standard_buildroot on archiveinfo.buildroot_id = standard_buildroot.buildroot_id'],
-                                  clauses=['standard_buildroot.host_id = %(host_id)i'],
-                                  values={'host_id': 1},
-                                  colsByAlias={'host_id': 'standard_buildroot.host_id',
-                                               'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['standard_buildroot.host_id = %(host_id)i'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'standard_buildroot on archiveinfo.buildroot_id = '
+                          'standard_buildroot.buildroot_id'])
+        self.assertEqual(query.values, {'host_id': 1})
 
     def test_list_archives_filename(self):
         kojihub.list_archives(filename='somefile.txt')
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['filename = %(filename)s'],
-                                  values={'filename': 'somefile.txt'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['filename = %(filename)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'filename': 'somefile.txt'})
 
     def test_list_archives_size(self):
         kojihub.list_archives(size=1231831)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['size = %(size)i'],
-                                  values={'size': 1231831})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['size = %(size)i'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'size': 1231831})
 
     def test_list_archives_checksum(self):
         kojihub.list_archives(checksum='7873f0a6dbf3abc07724e000ac9b3941')
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['checksum = %(checksum)s'],
-                                  values={'checksum': '7873f0a6dbf3abc07724e000ac9b3941'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['checksum = %(checksum)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'checksum': '7873f0a6dbf3abc07724e000ac9b3941'})
 
     def test_list_archives_checksum_type(self):
         kojihub.list_archives(checksum_type=koji.CHECKSUM_TYPES['sha256'])
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['checksum_type = %(checksum_type)s'],
-                                  values={'checksum_type': koji.CHECKSUM_TYPES['sha256']})
-
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['checksum_type = %(checksum_type)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'checksum_type': koji.CHECKSUM_TYPES['sha256']})
 
     def test_list_archives_archiveid(self):
         kojihub.list_archives(archiveID=1)
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['archiveinfo.id = %(archive_id)s'],
-                                  values={'archive_id': 1})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['archiveinfo.id = %(archive_id)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'archive_id': 1})
 
     def test_list_archives_type_maven(self):
         kojihub.list_archives(type='maven', typeInfo={'group_id': 'gid',
                                                       'artifact_id': 'aid',
                                                       'version': '1.0.1'})
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'maven_archives ON archiveinfo.id = maven_archives.archive_id'],
-                                  clauses=['maven_archives.artifact_id = %(artifact_id)s',
-                                           'maven_archives.group_id = %(group_id)s',
-                                           'maven_archives.version = %(version)s'],
-                                  values={'group_id': 'gid',
-                                          'artifact_id': 'aid',
-                                          'version': '1.0.1'},
-                                  colsByAlias={'group_id': 'maven_archives.group_id',
-                                               'artifact_id': 'maven_archives.artifact_id',
-                                               'version': 'maven_archives.version',
-                                               'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['maven_archives.artifact_id = %(artifact_id)s',
+                                         'maven_archives.group_id = %(group_id)s',
+                                         'maven_archives.version = %(version)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'maven_archives ON archiveinfo.id = maven_archives.archive_id'])
+        self.assertEqual(query.values, {'group_id': 'gid',
+                                        'artifact_id': 'aid',
+                                        'version': '1.0.1'})
 
     def test_list_archives_type_win(self):
         kojihub.list_archives(type='win', typeInfo={'relpath': 'somerelpath',
                                                     'platforms': 'all',
                                                     'flags': ['A', 'B']})
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=sorted([
-                                         'archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'win_archives ON archiveinfo.id = win_archives.archive_id']),
-                                  clauses=sorted([
-                                           'win_archives.relpath = %(relpath)s',
-                                           r"platforms ~ %(platforms_pattern_0)s",
-                                           r"flags ~ %(flags_pattern_0)s",
-                                           r"flags ~ %(flags_pattern_1)s"]),
-                                  values={'relpath': 'somerelpath',
-                                          'flags_pattern_0': '\\mA\\M',
-                                          'flags_pattern_1': '\\mB\\M',
-                                          'platforms_pattern_0': '\\mall\\M',
-                                          },
-                                  colsByAlias={'relpath': 'win_archives.relpath',
-                                               'platforms': 'win_archives.platforms',
-                                               'flags': 'win_archives.flags',
-                                               'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, sorted(['win_archives.relpath = %(relpath)s',
+                                                r"platforms ~ %(platforms_pattern_0)s",
+                                                r"flags ~ %(flags_pattern_0)s",
+                                                r"flags ~ %(flags_pattern_1)s"]))
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'win_archives ON archiveinfo.id = win_archives.archive_id'])
+        self.assertEqual(query.values, {'relpath': 'somerelpath',
+                                        'flags_pattern_0': '\\mA\\M',
+                                        'flags_pattern_1': '\\mB\\M',
+                                        'platforms_pattern_0': '\\mall\\M'})
 
     def test_list_archives_type_image(self):
         kojihub.list_archives(type='image', typeInfo={'arch': 'i386'})
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id',
-                                         'image_archives ON archiveinfo.id = image_archives.archive_id'],
-                                  clauses=['image_archives.arch = %(arch)s'],
-                                  values={'arch': 'i386'},
-                                  colsByAlias={'arch': 'image_archives.arch',
-                                               'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['image_archives.arch = %(arch)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id',
+                          'image_archives ON archiveinfo.id = image_archives.archive_id'])
+        self.assertEqual(query.values, {'arch': 'i386'})
 
     @mock.patch('kojihub.lookup_name', return_value={'id': 111, 'name': 'other'})
     def test_list_archives_type_others(self, lookup_name):
         kojihub.list_archives(type='other')
-        self.assertLastQueryEqual(tables=['archiveinfo'],
-                                  joins=['archivetypes on archiveinfo.type_id = archivetypes.id',
-                                         'btype ON archiveinfo.btype_id = btype.id'],
-                                  clauses=['archiveinfo.btype_id = %(btype_id)s'],
-                                  values={'btype_id': 111},
-                                  colsByAlias={'build_id': 'archiveinfo.build_id',
-                                               'type_name': 'archivetypes.name',
-                                               'type_id': 'archiveinfo.type_id',
-                                               'checksum': 'archiveinfo.checksum',
-                                               'extra': 'archiveinfo.extra',
-                                               'filename': 'archiveinfo.filename',
-                                               'type_description': 'archivetypes.description',
-                                               'metadata_only': 'archiveinfo.metadata_only',
-                                               'type_extensions': 'archivetypes.extensions',
-                                               'btype': 'btype.name',
-                                               'checksum_type': 'archiveinfo.checksum_type',
-                                               'btype_id': 'archiveinfo.btype_id',
-                                               'buildroot_id': 'archiveinfo.buildroot_id',
-                                               'id': 'archiveinfo.id',
-                                               'size': 'archiveinfo.size'})
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['archiveinfo'])
+        self.assertEqual(query.clauses, ['archiveinfo.btype_id = %(btype_id)s'])
+        self.assertEqual(query.joins,
+                         ['archivetypes on archiveinfo.type_id = archivetypes.id',
+                          'btype ON archiveinfo.btype_id = btype.id'])
+        self.assertEqual(query.values, {'btype_id': 111})
 
     @mock.patch('kojihub.lookup_name', return_value=None)
     def test_list_archives_type_not_found(self, lookup_name):
diff --git a/tests/test_hub/test_list_btypes.py b/tests/test_hub/test_list_btypes.py
index 82907ac..78343e0 100644
--- a/tests/test_hub/test_list_btypes.py
+++ b/tests/test_hub/test_list_btypes.py
@@ -8,64 +8,58 @@ QP = kojihub.QueryProcessor
 
 class TestListBTypes(unittest.TestCase):
 
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_btypes(self, QueryProcessor):
+    def setUp(self):
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.get_query).start()
+        self.queries = []
+        self.exports = kojihub.RootExports()
 
-        # default query
-        query = QueryProcessor.return_value
-        query.execute.return_value = "return value"
-        ret = kojihub.list_btypes()
-        QueryProcessor.assert_called_once()
-        query.execute.assert_called_once()
-        self.assertEqual(ret, "return value")
+    def tearDown(self):
+        mock.patch.stopall()
 
-        args, kwargs = QueryProcessor.call_args
-        self.assertEqual(args, ())
-        qp = QP(**kwargs)
-        self.assertEqual(qp.tables, ['btype'])
-        self.assertEqual(qp.columns, ['id', 'name'])
-        self.assertEqual(qp.clauses, [])
-        self.assertEqual(qp.joins, None)
-
-        QueryProcessor.reset_mock()
+    def get_query(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
 
-        # query by name
-        query = QueryProcessor.return_value
-        query.execute.return_value = "return value"
-        ret = kojihub.list_btypes({'name': 'rpm'})
-        QueryProcessor.assert_called_once()
-        query.execute.assert_called_once()
-        self.assertEqual(ret, "return value")
+    def test_list_btypes_default(self):
+        kojihub.list_btypes()
+        self.QueryProcessor.assert_called_once()
 
-        args, kwargs = QueryProcessor.call_args
+        args, kwargs = self.QueryProcessor.call_args
         self.assertEqual(args, ())
-        qp = QP(**kwargs)
-        self.assertEqual(qp.tables, ['btype'])
-        self.assertEqual(qp.columns, ['id', 'name'])
-        self.assertEqual(qp.clauses, ['btype.name = %(name)s'])
-        self.assertEqual(qp.values, {'name': 'rpm'})
-        self.assertEqual(qp.joins, None)
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['btype'])
+        self.assertEqual(query.columns, ['id', 'name'])
+        self.assertEqual(query.clauses, [])
+        self.assertEqual(query.joins, None)
 
-        QueryProcessor.reset_mock()
+    def test_list_btypes_by_name(self):
+        kojihub.list_btypes({'name': 'rpm'})
+        self.QueryProcessor.assert_called_once()
 
-        # query by id, with opts
-        query = QueryProcessor.return_value
-        query.execute.return_value = "return value"
-        ret = kojihub.list_btypes({'id': 1}, {'order': 'id'})
-        QueryProcessor.assert_called_once()
-        query.execute.assert_called_once()
-        self.assertEqual(ret, "return value")
-
-        args, kwargs = QueryProcessor.call_args
+        args, kwargs = self.QueryProcessor.call_args
         self.assertEqual(args, ())
-        qp = QP(**kwargs)
-        self.assertEqual(qp.tables, ['btype'])
-        self.assertEqual(qp.columns, ['id', 'name'])
-        self.assertEqual(qp.clauses, ['btype.id = %(id)s'])
-        self.assertEqual(qp.values, {'id': 1})
-        self.assertEqual(qp.opts, {'order': 'id'})
-        self.assertEqual(qp.joins, None)
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['btype'])
+        self.assertEqual(query.columns, ['id', 'name'])
+        self.assertEqual(query.clauses, ['btype.name = %(name)s'])
+        self.assertEqual(query.values, {'name': 'rpm'})
+        self.assertEqual(query.joins, None)
 
-        QueryProcessor.reset_mock()
+    def test_list_btypes_by_it_with_opts(self):
+        kojihub.list_btypes({'id': 1}, {'order': 'id'})
+        self.QueryProcessor.assert_called_once()
 
-        # query by name
+        args, kwargs = self.QueryProcessor.call_args
+        self.assertEqual(args, ())
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['btype'])
+        self.assertEqual(query.columns, ['id', 'name'])
+        self.assertEqual(query.clauses, ['btype.id = %(id)s'])
+        self.assertEqual(query.values, {'id': 1})
+        self.assertEqual(query.opts, {'order': 'id'})
+        self.assertEqual(query.joins, None)
diff --git a/tests/test_hub/test_list_builds.py b/tests/test_hub/test_list_builds.py
index 361fd29..5e172f4 100644
--- a/tests/test_hub/test_list_builds.py
+++ b/tests/test_hub/test_list_builds.py
@@ -25,6 +25,8 @@ class TestListBuilds(unittest.TestCase):
         self.queries = []
 
         self.context = mock.patch('kojihub.context').start()
+        self.get_package_id = mock.patch('kojihub.get_package_id').start()
+        self.get_user = mock.patch('kojihub.get_user').start()
         self.cursor = mock.MagicMock()
         self.build_list = [{'build_id': 9,
                             'epoch': 0,
@@ -41,18 +43,16 @@ class TestListBuilds(unittest.TestCase):
                             'volume_id': 0,
                             'volume_name': 'DEFAULT'}]
 
-    @mock.patch('kojihub.get_package_id')
-    def test_wrong_package(self, get_package_id):
+    def test_wrong_package(self):
         package = 'test-package'
-        get_package_id.return_value = None
+        self.get_package_id.return_value = None
         rv = self.exports.listBuilds(packageID=package)
         self.assertEqual(rv, [])
 
-    @mock.patch('kojihub.get_package_id')
-    def test_package_string(self, get_package_id):
+    def test_package_string(self):
         package = 'test-package'
         package_id = 1
-        get_package_id.return_value = package_id
+        self.get_package_id.return_value = package_id
         self.query_executeOne.return_value = None
         self.exports.listBuilds(packageID=package)
         self.assertEqual(len(self.queries), 1)
@@ -76,9 +76,8 @@ class TestListBuilds(unittest.TestCase):
                                     'LEFT JOIN volume ON build.volume_id = volume.id',
                                     'LEFT JOIN users ON build.owner = users.id'])
 
-    @mock.patch('kojihub.get_user')
-    def test_wrong_user(self, get_user):
+    def test_wrong_user(self):
         user = 'test-user'
-        get_user.return_value = None
+        self.get_user.return_value = None
         rv = self.exports.listBuilds(userID=user)
         self.assertEqual(rv, [])
diff --git a/tests/test_hub/test_list_packages_simple.py b/tests/test_hub/test_list_packages_simple.py
new file mode 100644
index 0000000..47cf772
--- /dev/null
+++ b/tests/test_hub/test_list_packages_simple.py
@@ -0,0 +1,40 @@
+import unittest
+import kojihub
+import mock
+
+QP = kojihub.QueryProcessor
+
+
+class TestListPackagesSimple(unittest.TestCase):
+
+    def setUp(self):
+        self.maxDiff = None
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.get_query).start()
+        self.queries = []
+        self.exports = kojihub.RootExports()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def get_query(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def test_prefix_not_none(self):
+        self.exports.listPackagesSimple('test-prefix')
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['package'])
+        self.assertEqual(query.clauses, ["package.name ILIKE %(prefix)s || '%%'"])
+        self.assertEqual(query.joins, None)
+
+    def test_prefix_is_none(self):
+        self.exports.listPackagesSimple()
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['package'])
+        self.assertEqual(query.clauses, None)
+        self.assertEqual(query.joins, None)
diff --git a/tests/test_hub/test_list_rpms.py b/tests/test_hub/test_list_rpms.py
index 88449dd..e0d0669 100644
--- a/tests/test_hub/test_list_rpms.py
+++ b/tests/test_hub/test_list_rpms.py
@@ -1,14 +1,87 @@
 import unittest
+import mock
 
 import koji
 import kojihub
 
+QP = kojihub.QueryProcessor
+
 
 class TestListRpms(unittest.TestCase):
+    def setUp(self):
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.get_query).start()
+        self.queries = []
+        self.get_build = mock.patch('kojihub.get_build').start()
+        self.get_host = mock.patch('kojihub.get_host').start()
+        self._dml = mock.patch('kojihub._dml').start()
+        self.list_rpms = {'arch': 'x86_64',
+                          'build_id': 1,
+                          'buildroot_id': 2,
+                          'buildtime': 1596090711,
+                          'epoch': 2,
+                          'external_repo_id': 1,
+                          'external_repo_name': 'fedora-34-released',
+                          'extra': None,
+                          'id': 277,
+                          'metadata_only': False,
+                          'name': 'shadow-utils',
+                          'nvr': 'shadow-utils-4.8.1-4.fc33',
+                          'payloadhash': 'c5bfe5267dc6e0ca127092a82b4f260b',
+                          'release': '4.fc33',
+                          'size': 3891272,
+                          'version': '4.8.1'}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def get_query(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
 
     def test_wrong_type_arches(self):
         arches = {'test-arch': 'val'}
         with self.assertRaises(koji.GenericError) as cm:
             kojihub.list_rpms(arches=arches)
-        self.assertEqual('Invalid type for "arches" parameter: %s' % type(arches),
-                         str(cm.exception))
+        self.assertEqual(f'Invalid type for "arches" parameter: {type(arches)}', str(cm.exception))
+
+    def test_int_values(self):
+        build_id = 1
+        buildroot_id = 1
+        host_id = 1
+        arches = 'x86_64'
+        kojihub.list_rpms(arches=arches, buildID=build_id, buildrootID=buildroot_id,
+                          hostID=host_id)
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['rpminfo'])
+        self.assertEqual(query.joins,
+                         ['LEFT JOIN external_repo ON rpminfo.external_repo_id = external_repo.id',
+                          'standard_buildroot ON rpminfo.buildroot_id = '
+                          'standard_buildroot.buildroot_id'])
+        self.assertEqual(query.clauses, [
+            'rpminfo.arch = %(arches)s',
+            'rpminfo.build_id = %(buildID)i',
+            'rpminfo.buildroot_id = %(buildrootID)i',
+            'standard_buildroot.host_id = %(hostID)i',
+        ])
+
+    def test_compoenent_buldroot_image_list_arch_values(self):
+        comp_buildroot_id = 1
+        image_id = 1
+        arches = ['x86_64']
+        kojihub.list_rpms(componentBuildrootID=comp_buildroot_id, imageID=image_id, arches=arches)
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        self.assertEqual(query.tables, ['rpminfo'])
+        self.assertEqual(query.joins,
+                         ['LEFT JOIN external_repo ON rpminfo.external_repo_id = external_repo.id',
+                          'buildroot_listing ON rpminfo.id = buildroot_listing.rpm_id',
+                          'archive_rpm_components ON rpminfo.id = archive_rpm_components.rpm_id'])
+        self.assertEqual(query.clauses, [
+            'archive_rpm_components.archive_id = %(imageID)i',
+            'buildroot_listing.buildroot_id = %(componentBuildrootID)i',
+            'rpminfo.arch IN %(arches)s',
+        ])
diff --git a/tests/test_hub/test_list_tasks.py b/tests/test_hub/test_list_tasks.py
new file mode 100644
index 0000000..95cf850
--- /dev/null
+++ b/tests/test_hub/test_list_tasks.py
@@ -0,0 +1,78 @@
+import mock
+import unittest
+
+import kojihub
+
+
+class TestListing(unittest.TestCase):
+    def setUp(self):
+        self.hub = kojihub.RootExports()
+        self.standard_processor_kwargs = dict(
+            tables=mock.ANY,
+            columns=mock.ANY,
+            values=mock.ANY,
+            joins=mock.ANY,
+            clauses=mock.ANY,
+            opts=mock.ANY,
+            aliases=mock.ANY,
+        )
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_basic_invocation(self, processor):
+        generator = self.hub.listTasks()
+        list(generator)  # Exhaust the generator
+        processor.assert_called_once_with(**self.standard_processor_kwargs)
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_owner_as_int(self, processor):
+        generator = self.hub.listTasks(opts={'owner': 1})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['owner = %(owner)i']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_not_owner_as_int(self, processor):
+        generator = self.hub.listTasks(opts={'not_owner': 1})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['owner != %(not_owner)i']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_arch(self, processor):
+        generator = self.hub.listTasks(opts={'arch': ['x86_64']})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['arch IN %(arch)s']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_not_arch(self, processor):
+        generator = self.hub.listTasks(opts={'not_arch': ['x86_64']})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['arch NOT IN %(not_arch)s']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_owner_as_list(self, processor):
+        generator = self.hub.listTasks(opts={'owner': [1, 2]})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['owner IN %(owner)s']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
+
+    @mock.patch('kojihub.QueryProcessor')
+    def test_list_tasks_by_not_owner_as_list(self, processor):
+        generator = self.hub.listTasks(opts={'not_owner': [1, 2]})
+        results = list(generator)  # Exhaust the generator
+        arguments = self.standard_processor_kwargs.copy()
+        arguments['clauses'] = ['owner NOT IN %(not_owner)s']
+        processor.assert_called_once_with(**arguments)
+        self.assertEqual(results, [])
diff --git a/tests/test_hub/test_listing.py b/tests/test_hub/test_listing.py
deleted file mode 100644
index 95cf850..0000000
--- a/tests/test_hub/test_listing.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import mock
-import unittest
-
-import kojihub
-
-
-class TestListing(unittest.TestCase):
-    def setUp(self):
-        self.hub = kojihub.RootExports()
-        self.standard_processor_kwargs = dict(
-            tables=mock.ANY,
-            columns=mock.ANY,
-            values=mock.ANY,
-            joins=mock.ANY,
-            clauses=mock.ANY,
-            opts=mock.ANY,
-            aliases=mock.ANY,
-        )
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_basic_invocation(self, processor):
-        generator = self.hub.listTasks()
-        list(generator)  # Exhaust the generator
-        processor.assert_called_once_with(**self.standard_processor_kwargs)
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_owner_as_int(self, processor):
-        generator = self.hub.listTasks(opts={'owner': 1})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['owner = %(owner)i']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_not_owner_as_int(self, processor):
-        generator = self.hub.listTasks(opts={'not_owner': 1})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['owner != %(not_owner)i']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_arch(self, processor):
-        generator = self.hub.listTasks(opts={'arch': ['x86_64']})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['arch IN %(arch)s']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_not_arch(self, processor):
-        generator = self.hub.listTasks(opts={'not_arch': ['x86_64']})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['arch NOT IN %(not_arch)s']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_owner_as_list(self, processor):
-        generator = self.hub.listTasks(opts={'owner': [1, 2]})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['owner IN %(owner)s']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
-
-    @mock.patch('kojihub.QueryProcessor')
-    def test_list_tasks_by_not_owner_as_list(self, processor):
-        generator = self.hub.listTasks(opts={'not_owner': [1, 2]})
-        results = list(generator)  # Exhaust the generator
-        arguments = self.standard_processor_kwargs.copy()
-        arguments['clauses'] = ['owner NOT IN %(not_owner)s']
-        processor.assert_called_once_with(**arguments)
-        self.assertEqual(results, [])
diff --git a/tests/test_hub/test_lookup_name.py b/tests/test_hub/test_lookup_name.py
index 42346fa..39c9ca6 100644
--- a/tests/test_hub/test_lookup_name.py
+++ b/tests/test_hub/test_lookup_name.py
@@ -44,12 +44,11 @@ class TestLookupName(unittest.TestCase):
             {'id': 'not a valid int'},
             ['something'],
             set(),
-            ]
+        ]
         for value in bad_values:
             with self.assertRaises(koji.GenericError) as cm:
                 kojihub.lookup_name('mytable', value)
-            self.assertEqual('Invalid name or id value: %s' % value,
-                             str(cm.exception))
+            self.assertEqual(f'Invalid name or id value: {value}', str(cm.exception))
         self.assertEqual(len(self.queries), 0)
         self.assertEqual(len(self.inserts), 0)
 
@@ -78,7 +77,7 @@ class TestLookupName(unittest.TestCase):
         self.assertEqual(len(self.inserts), 0)
 
     def test_query_by_dict(self):
-        kojihub.lookup_name('some_table', {'id':12345, 'name': 'whatever'})
+        kojihub.lookup_name('some_table', {'id': 12345, 'name': 'whatever'})
         self.assertEqual(len(self.queries), 1)
         query = self.queries[0]
         clauses = ['(some_table.id = %(some_table_id)s)']
@@ -110,7 +109,7 @@ class TestLookupName(unittest.TestCase):
 
     def test_lookup_name_strict(self):
         self.query_executeOne.return_value = None
-        with self.assertRaises(koji.GenericError) as cm:
+        with self.assertRaises(koji.GenericError):
             kojihub.lookup_name('package', 'python', strict=True)
         self.assertEqual(len(self.queries), 1)
         self.assertEqual(len(self.inserts), 0)
@@ -134,7 +133,7 @@ class TestLookupName(unittest.TestCase):
         bad_values = [
             {'id': 100},
             100
-            ]
+        ]
         for value in bad_values:
             with self.assertRaises(koji.GenericError) as cm:
                 kojihub.lookup_name('package', value, create=True)
diff --git a/tests/test_hub/test_maven_build.py b/tests/test_hub/test_maven_build.py
new file mode 100644
index 0000000..90a4d4c
--- /dev/null
+++ b/tests/test_hub/test_maven_build.py
@@ -0,0 +1,49 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestMaven(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.url = 'https://test-url.com'
+        self.target = 'test-target'
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_maven_not_supported(self):
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.mavenBuild(self.url, self.target)
+        self.assertEqual("Maven support not enabled", str(cm.exception))
+
+    def test_url_not_str(self):
+        url = ['test-url']
+        self.context.opts.get.return_value = True
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.mavenBuild(url, self.target)
+        self.assertEqual(f"Invalid type for value '{url}': {type(url)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.opts.get.return_value = True
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.mavenBuild(self.url, self.target, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_channel_not_str(self):
+        priority = 10
+        self.context.opts.get.return_value = True
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 2, 'name': 'maven'}
+        self.make_task.return_value = 123
+        self.exports.mavenBuild(self.url, self.target, priority=priority, channel=2)
diff --git a/tests/test_hub/test_new_build.py b/tests/test_hub/test_new_build.py
index fef494b..284f973 100644
--- a/tests/test_hub/test_new_build.py
+++ b/tests/test_hub/test_new_build.py
@@ -6,6 +6,7 @@ import kojihub
 
 IP = kojihub.InsertProcessor
 
+
 class TestNewBuild(unittest.TestCase):
     def setUp(self):
         self.get_rpm = mock.patch('kojihub.get_rpm').start()
@@ -13,7 +14,7 @@ class TestNewBuild(unittest.TestCase):
         self.nextval = mock.patch('kojihub.nextval').start()
         self.Savepoint = mock.patch('kojihub.Savepoint').start()
         self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
-                side_effect=self.getInsert).start()
+                                          side_effect=self.getInsert).start()
         self.inserts = []
         self.insert_execute = mock.MagicMock()
         self.lookup_package = mock.patch('kojihub.lookup_package').start()
@@ -35,7 +36,7 @@ class TestNewBuild(unittest.TestCase):
 
     def test_valid(self):
         self.get_build.return_value = None
-        self._singleValue.return_value = 65 # free build id
+        self._singleValue.return_value = 65  # free build id
         self.new_package.return_value = 54
         self.get_user.return_value = {'id': 123}
         data = {
@@ -99,10 +100,11 @@ class TestNewBuild(unittest.TestCase):
             'extra': {'extra_key': 'extra_value'},
         }
 
-        with self.assertRaises(koji.GenericError):
+        with self.assertRaises(koji.GenericError) as cm:
             kojihub.new_build(data)
 
         self.assertEqual(len(self.inserts), 0)
+        self.assertEqual("No name or package id provided for build", str(cm.exception))
 
     def test_wrong_owner(self):
         self.get_user.side_effect = koji.GenericError
@@ -121,7 +123,6 @@ class TestNewBuild(unittest.TestCase):
         self.assertEqual(len(self.inserts), 0)
 
     def test_missing_vre(self):
-        self.get_user.side_effect = koji.GenericError
         data = {
             'name': 'test_name',
             'version': 'test_version',
diff --git a/tests/test_hub/test_notifications.py b/tests/test_hub/test_notifications.py
deleted file mode 100644
index a95116a..0000000
--- a/tests/test_hub/test_notifications.py
+++ /dev/null
@@ -1,821 +0,0 @@
-import mock
-import unittest
-
-import koji
-import kojihub
-
-QP = kojihub.QueryProcessor
-IP = kojihub.InsertProcessor
-UP = kojihub.UpdateProcessor
-
-class TestNotifications(unittest.TestCase):
-    def getInsert(self, *args, **kwargs):
-        insert = IP(*args, **kwargs)
-        insert.execute = mock.MagicMock()
-        self.inserts.append(insert)
-        return insert
-
-    def getQuery(self, *args, **kwargs):
-        query = QP(*args, **kwargs)
-        query.execute = mock.MagicMock()
-        self.queries.append(query)
-        return query
-
-    def getUpdate(self, *args, **kwargs):
-        update = UP(*args, **kwargs)
-        update.execute = mock.MagicMock()
-        self.updates.append(update)
-        return update
-
-    def setUp(self):
-        self.context = mock.patch('kojihub.context').start()
-        self.context.opts = {
-            'EmailDomain': 'test.domain.com',
-            'NotifyOnSuccess': True,
-        }
-
-        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
-                side_effect=self.getQuery).start()
-        self.queries = []
-        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
-                side_effect=self.getInsert).start()
-        self.inserts = []
-        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
-                side_effect=self.getUpdate).start()
-        self.updates = []
-
-        self.exports = kojihub.RootExports()
-        self.exports.getLoggedInUser = mock.MagicMock()
-        self.exports.getUser = mock.MagicMock()
-        self.exports.hasPerm = mock.MagicMock()
-        self.exports.getBuildNotification = mock.MagicMock()
-        self.exports.getBuildNotificationBlock = mock.MagicMock()
-
-    def tearDown(self):
-        mock.patch.stopall()
-
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_watchers(self, readPackageList, get_user):
-        # without build / tag_id
-        build = None
-        tag_id = None
-        state = koji.BUILD_STATES['CANCELED']
-
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(emails, [])
-
-        # only query to watchers
-        self.assertEqual(len(self.queries), 1)
-        q = self.queries[0]
-        self.assertEqual(q.columns, ['email', 'user_id'])
-        self.assertEqual(q.tables, ['build_notifications'])
-        self.assertEqual(q.clauses, ['package_id IS NULL',
-                                     'status = %(users_status)i',
-                                     'success_only = FALSE',
-                                     'tag_id IS NULL',
-                                     'usertype IN %(users_usertypes)s'])
-        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
-        self.assertEqual(q.values['state'], state)
-        self.assertEqual(q.values['build'], build)
-        self.assertEqual(q.values['tag_id'], tag_id)
-
-        '''
-        q = self.queries[1]
-        self.assertEqual(q.columns, ['user_id'])
-        self.assertEqual(q.tables, ['build_notifications_block'])
-        self.assertEqual(q.clauses, ['user_id IN %(user_ids)s'])
-        self.assertEqual(q.joins, [])
-        self.assertEqual(q.values['user_ids'], None)
-        '''
-        readPackageList.assert_not_called()
-
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_build_without_tag(self, readPackageList, get_user):
-        ### with build without tag
-        tag_id = None
-        state = koji.BUILD_STATES['CANCELED']
-        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
-        self.queries = []
-        self.set_queries([
-            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-            []
-        ])
-
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(emails, ['owner_name@test.domain.com'])
-
-        # there should be only query to watchers
-        self.assertEqual(len(self.queries), 2)
-        q = self.queries[0]
-        self.assertEqual(q.columns, ['email', 'user_id'])
-        self.assertEqual(q.tables, ['build_notifications'])
-        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
-                                     'status = %(users_status)i',
-                                     'success_only = FALSE',
-                                     'tag_id IS NULL',
-                                     'usertype IN %(users_usertypes)s'])
-        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
-        self.assertEqual(q.values['package_id'], build['package_id'])
-        self.assertEqual(q.values['state'], state)
-        self.assertEqual(q.values['build'], build)
-        self.assertEqual(q.values['tag_id'], tag_id)
-
-        q = self.queries[1]
-        self.assertEqual(q.columns, ['user_id'])
-        self.assertEqual(q.tables, ['build_notifications_block'])
-        self.assertEqual(q.clauses, [
-                                     'package_id = %(package_id)i OR package_id IS NULL',
-                                     'tag_id IS NULL',
-                                     'user_id IN %(user_ids)s',
-                                    ])
-        self.assertEqual(q.joins, None)
-        self.assertEqual(q.values['user_ids'], [5])
-
-        readPackageList.assert_not_called()
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_tag_without_build(self, readPackageList, get_user):
-        ### with tag without build makes no sense
-        build = None
-        tag_id = 123
-        state = koji.BUILD_STATES['CANCELED']
-        self.queries = []
-
-        with self.assertRaises(koji.GenericError):
-            kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(self.queries, [])
-        readPackageList.assert_not_called()
-
-    def set_queries(self, return_values):
-        self.query_returns = return_values
-        self.query_returns.reverse()
-        def getQuery(*args, **kwargs):
-            q = QP(*args, **kwargs)
-            q.execute = mock.MagicMock()
-            q.execute.return_value = self.query_returns.pop()
-            self.queries.append(q)
-            return q
-        self.QueryProcessor.side_effect = getQuery
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_tag_with_build(self, readPackageList, get_user):
-        ### with tag and build
-        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
-        tag_id = 123
-        state = koji.BUILD_STATES['CANCELED']
-        readPackageList.return_value = {12345: {'blocked': False, 'owner_id': 'owner_id'}}
-        get_user.return_value = {
-            'id': 342,
-            'name': 'pkg_owner_name',
-            'status': koji.USER_STATUS['NORMAL'],
-            'usertype': koji.USERTYPES['NORMAL']
-        }
-        self.set_queries([
-            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-            []
-        ])
-
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(sorted(emails), ['owner_name@test.domain.com', 'pkg_owner_name@test.domain.com'])
-
-
-        # there should be only query to watchers
-        self.assertEqual(len(self.queries), 2)
-        q = self.queries[0]
-        self.assertEqual(q.columns, ['email', 'user_id'])
-        self.assertEqual(q.tables, ['build_notifications'])
-        self.assertEqual(q.clauses, ['package_id = %(package_id)i OR package_id IS NULL',
-                                     'status = %(users_status)i',
-                                     'success_only = FALSE',
-                                     'tag_id = %(tag_id)i OR tag_id IS NULL',
-                                     'usertype IN %(users_usertypes)s',
-                                     ])
-        self.assertEqual(q.joins, ['JOIN users ON build_notifications.user_id = users.id'])
-        self.assertEqual(q.values['package_id'], build['package_id'])
-        self.assertEqual(q.values['state'], state)
-        self.assertEqual(q.values['build'], build)
-        self.assertEqual(q.values['tag_id'], tag_id)
-
-        q = self.queries[1]
-        self.assertEqual(q.columns, ['user_id'])
-        self.assertEqual(q.tables, ['build_notifications_block'])
-        self.assertEqual(q.clauses, [
-                                     'package_id = %(package_id)i OR package_id IS NULL',
-                                     'tag_id = %(tag_id)i OR tag_id IS NULL',
-                                     'user_id IN %(user_ids)s',
-                                    ])
-        self.assertEqual(q.joins, None)
-        self.assertEqual(sorted(q.values['user_ids']), [5, 342])
-        readPackageList.assert_called_once_with(pkgID=build['package_id'], tagID=tag_id, inherit=True)
-        get_user.asssert_called_once_with(342, strict=True)
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_blocked_pkg_owner(self, readPackageList, get_user):
-        # blocked package owner
-        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
-        tag_id = 123
-        state = koji.BUILD_STATES['CANCELED']
-        get_user.return_value = {
-            'id': 342,
-            'name': 'pkg_owner_name',
-            'status': koji.USER_STATUS['BLOCKED'],
-            'usertype': koji.USERTYPES['NORMAL']
-        }
-        self.set_queries([
-            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-            []
-        ])
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(emails, ['owner_name@test.domain.com'])
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_optout(self, readPackageList, get_user):
-        # blocked package owner
-        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
-        tag_id = 123
-        state = koji.BUILD_STATES['CANCELED']
-        get_user.return_value = {
-            'id': 342,
-            'name': 'pkg_owner_name',
-            'status': koji.USER_STATUS['NORMAL'],
-            'usertype': koji.USERTYPES['NORMAL']
-        }
-        self.set_queries([
-            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-            [{'user_id': 5}]
-        ])
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(emails, [])
-
-
-    @mock.patch('kojihub.get_user')
-    @mock.patch('kojihub.readPackageList')
-    def test_get_notification_recipients_machine(self, readPackageList, get_user):
-        # package owner is machine
-        build = {'package_id': 12345, 'owner_name': 'owner_name', 'owner_id': 5}
-        tag_id = 123
-        state = koji.BUILD_STATES['CANCELED']
-        get_user.return_value = {
-            'id': 342,
-            'name': 'pkg_owner_name',
-            'status': koji.USER_STATUS['NORMAL'],
-            'usertype': koji.USERTYPES['HOST']
-        }
-        self.set_queries([
-            [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-            []
-        ])
-        emails = kojihub.get_notification_recipients(build, tag_id, state)
-        self.assertEqual(emails, ['owner_name@test.domain.com'])
-
-    #####################
-    # Create notification
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'username'}
-        self.exports.hasPerm.return_value = True
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notifications.return_value = []
-
-        r = self.exports.createNotification(user_id, package_id, tag_id, success_only)
-        self.assertEqual(r, None)
-
-        self.exports.getLoggedInUser.assert_called_once()
-        self.exports.getUser.asssert_called_once_with(user_id)
-        self.exports.hasPerm.asssert_called_once_with('admin')
-        get_package_id.assert_called_once_with(package_id, strict=True)
-        get_tag_id.assert_called_once_with(tag_id, strict=True)
-        get_build_notifications.assert_called_once_with(2)
-        self.assertEqual(len(self.inserts), 1)
-        insert = self.inserts[0]
-        self.assertEqual(insert.table, 'build_notifications')
-        self.assertEqual(insert.data, {
-            'package_id': package_id,
-            'user_id': 2,
-            'tag_id': tag_id,
-            'success_only': success_only,
-            'email': 'username@test.domain.com',
-        })
-        self.assertEqual(insert.rawdata, {})
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_unauthentized(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_invalid_user(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.getUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_no_perm(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'b'}
-        self.exports.hasPerm.return_value = False
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_invalid_pkg(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.side_effect = ValueError
-
-        with self.assertRaises(ValueError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_invalid_tag(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.return_value = package_id
-        get_tag_id.side_effect = ValueError
-
-        with self.assertRaises(ValueError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotification_exists(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notifications.return_value = [{
-            'package_id': package_id,
-            'tag_id': tag_id,
-            'success_only': success_only,
-        }]
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotification(user_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    #####################
-    # Delete notification
-    @mock.patch('kojihub._dml')
-    def test_deleteNotification(self, _dml):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-
-        self.exports.deleteNotification(n_id)
-
-        self.exports.getBuildNotification.assert_called_once_with(n_id, strict=True)
-        self.exports.getLoggedInUser.assert_called_once_with()
-        _dml.assert_called_once()
-
-    def test_deleteNotification_missing(self):
-        n_id = 543
-        self.exports.getBuildNotification.side_effect = koji.GenericError
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotification(n_id)
-
-        self.exports.getBuildNotification.assert_called_once_with(n_id, strict=True)
-
-    def test_deleteNotification_not_logged(self):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-        self.exports.getLoggedInUser.return_value = None
-        #self.set_queries = ([
-        #    [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-        #])
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotification(n_id)
-
-        self.exports.getBuildNotification.assert_called_once_with(n_id, strict=True)
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-        self.assertEqual(len(self.queries), 0)
-
-    @mock.patch('kojihub._dml')
-    def test_deleteNotification_no_perm(self, _dml):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.hasPerm.return_value = False
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotification(n_id)
-
-        self.exports.getBuildNotification.assert_called_once_with(n_id, strict=True)
-        _dml.assert_not_called()
-
-
-    #####################
-    # Update notification
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.hasPerm.return_value = True
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notifications.return_value = [{
-            'tag_id': tag_id,
-            'user_id': user_id,
-            'package_id': package_id,
-            'success_only': not success_only,
-        }]
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-
-        r = self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-        self.assertEqual(r, None)
-
-        self.exports.getLoggedInUser.assert_called_once()
-        self.exports.hasPerm.asssert_called_once_with('admin')
-        get_package_id.assert_called_once_with(package_id, strict=True)
-        get_tag_id.assert_called_once_with(tag_id, strict=True)
-        get_build_notifications.assert_called_once_with(user_id)
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 1)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification_not_logged(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification_missing(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.getBuildNotification.side_effect = koji.GenericError
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification_no_perm(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 132}
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-        self.exports.hasPerm.return_value = False
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification_exists(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.hasPerm.return_value = True
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notifications.return_value = [{
-            'tag_id': tag_id,
-            'user_id': user_id,
-            'package_id': package_id,
-            'success_only': success_only,
-        }]
-        self.exports.getBuildNotification.return_value = {'user_id': user_id}
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-
-        self.exports.getLoggedInUser.assert_called_once()
-        self.exports.hasPerm.asssert_called_once_with('admin')
-        get_package_id.assert_called_once_with(package_id, strict=True)
-        get_tag_id.assert_called_once_with(tag_id, strict=True)
-        get_build_notifications.assert_called_once_with(user_id)
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-
-    @mock.patch('kojihub.get_build_notifications')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_updateNotification_not_logged(self, get_package_id, get_tag_id,
-            get_build_notifications):
-        n_id = 5432
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        success_only = True
-        self.exports.getLoggedInUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.updateNotification(n_id, package_id, tag_id, success_only)
-
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-
-    ###########################
-    # Create notification block
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'username'}
-        self.exports.hasPerm.return_value = True
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notification_blocks.return_value = []
-
-        r = self.exports.createNotificationBlock(user_id, package_id, tag_id)
-        self.assertEqual(r, None)
-
-        self.exports.getLoggedInUser.assert_called_once()
-        self.exports.getUser.asssert_called_once_with(user_id)
-        self.exports.hasPerm.asssert_called_once_with('admin')
-        get_package_id.assert_called_once_with(package_id, strict=True)
-        get_tag_id.assert_called_once_with(tag_id, strict=True)
-        get_build_notification_blocks.assert_called_once_with(2)
-        self.assertEqual(len(self.inserts), 1)
-        insert = self.inserts[0]
-        self.assertEqual(insert.table, 'build_notifications_block')
-        self.assertEqual(insert.data, {
-            'package_id': package_id,
-            'user_id': 2,
-            'tag_id': tag_id,
-        })
-        self.assertEqual(insert.rawdata, {})
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_unauthentized(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 1
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_invalid_user(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.getUser.return_value = None
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_no_perm(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 1, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'b'}
-        self.exports.hasPerm.return_value = False
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_invalid_pkg(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.side_effect = ValueError
-
-        with self.assertRaises(ValueError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_invalid_tag(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.return_value = package_id
-        get_tag_id.side_effect = ValueError
-
-        with self.assertRaises(ValueError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    @mock.patch('kojihub.get_build_notification_blocks')
-    @mock.patch('kojihub.get_tag_id')
-    @mock.patch('kojihub.get_package_id')
-    def test_createNotificationBlock_exists(self, get_package_id, get_tag_id,
-            get_build_notification_blocks):
-        user_id = 2
-        package_id = 234
-        tag_id = 345
-        self.exports.getLoggedInUser.return_value = {'id': 2, 'name': 'a'}
-        self.exports.getUser.return_value = {'id': 2, 'name': 'a'}
-        get_package_id.return_value = package_id
-        get_tag_id.return_value = tag_id
-        get_build_notification_blocks.return_value = [{
-            'package_id': package_id,
-            'tag_id': tag_id,
-        }]
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.createNotificationBlock(user_id, package_id, tag_id)
-
-        self.assertEqual(len(self.inserts), 0)
-
-    ###########################
-    # Delete notification block
-    @mock.patch('kojihub._dml')
-    def test_deleteNotificationBlock(self, _dml):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotificationBlock.return_value = {'user_id': user_id}
-
-        self.exports.deleteNotificationBlock(n_id)
-
-        self.exports.getBuildNotificationBlock.assert_called_once_with(n_id, strict=True)
-        self.exports.getLoggedInUser.assert_called_once_with()
-        _dml.assert_called_once()
-
-    def test_deleteNotificationBlock_missing(self):
-        n_id = 543
-        self.exports.getBuildNotificationBlock.side_effect = koji.GenericError
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotificationBlock(n_id)
-
-        self.exports.getBuildNotificationBlock.assert_called_once_with(n_id, strict=True)
-
-    def test_deleteNotificationBlock_not_logged(self):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotificationBlock.return_value = {'user_id': user_id}
-        self.exports.getLoggedInUser.return_value = None
-        #self.set_queries = ([
-        #    [{'user_id': 5, 'email': 'owner_name@%s' % self.context.opts['EmailDomain']}],
-        #])
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotificationBlock(n_id)
-
-        self.exports.getBuildNotificationBlock.assert_called_once_with(n_id, strict=True)
-        self.assertEqual(len(self.inserts), 0)
-        self.assertEqual(len(self.updates), 0)
-        self.assertEqual(len(self.queries), 0)
-
-    @mock.patch('kojihub._dml')
-    def test_deleteNotificationBlock_no_perm2(self, _dml):
-        user_id = 752
-        n_id = 543
-        self.exports.getBuildNotificationBlock.return_value = {'user_id': user_id}
-        self.exports.getLoggedInUser.return_value = {'id': 1}
-        self.exports.hasPerm.return_value = False
-
-        with self.assertRaises(koji.GenericError):
-            self.exports.deleteNotificationBlock(n_id)
-
-        self.exports.getBuildNotificationBlock.assert_called_once_with(n_id, strict=True)
-        _dml.assert_not_called()
diff --git a/tests/test_hub/test_repo_delete.py b/tests/test_hub/test_repo_delete.py
new file mode 100644
index 0000000..fe87fbb
--- /dev/null
+++ b/tests/test_hub/test_repo_delete.py
@@ -0,0 +1,13 @@
+import unittest
+
+import koji
+import kojihub
+
+
+class TestRepoDelete(unittest.TestCase):
+
+    def test_repo_delete_wrong_type_typeID(self):
+        repo_id = 'test-repo-id'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.repo_delete(repo_id)
+        self.assertEqual(f"Invalid type for value '{repo_id}': {type(repo_id)}", str(cm.exception))
diff --git a/tests/test_hub/test_repo_init.py b/tests/test_hub/test_repo_init.py
new file mode 100644
index 0000000..b3c30c8
--- /dev/null
+++ b/tests/test_hub/test_repo_init.py
@@ -0,0 +1,13 @@
+import unittest
+
+import koji
+import kojihub
+
+
+class TestRepoInit(unittest.TestCase):
+
+    def test_repo_init_wrong_type_typeID(self):
+        task_id = 'test-task_id'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.repo_init('test-tag', task_id)
+        self.assertEqual(f"Invalid type for value '{task_id}': {type(task_id)}", str(cm.exception))
diff --git a/tests/test_hub/test_repo_set_state.py b/tests/test_hub/test_repo_set_state.py
new file mode 100644
index 0000000..b6a5007
--- /dev/null
+++ b/tests/test_hub/test_repo_set_state.py
@@ -0,0 +1,13 @@
+import unittest
+
+import koji
+import kojihub
+
+
+class TestRepoSetState(unittest.TestCase):
+
+    def test_set_state_wrong_type_typeID(self):
+        repo_id = 'test-repo-id'
+        with self.assertRaises(koji.ParameterError) as cm:
+            kojihub.repo_set_state(repo_id, 'failed')
+        self.assertEqual(f"Invalid type for value '{repo_id}': {type(repo_id)}", str(cm.exception))
diff --git a/tests/test_hub/test_repos.py b/tests/test_hub/test_repos.py
index 48c7cd7..19d14ca 100644
--- a/tests/test_hub/test_repos.py
+++ b/tests/test_hub/test_repos.py
@@ -26,6 +26,7 @@ class TestRepoFunctions(unittest.TestCase):
         self.updates = []
         self._dml = mock.patch('kojihub._dml').start()
         self.exports = kojihub.RootExports()
+        self.get_tag = mock.patch('kojihub.get_tag').start()
 
     def tearDown(self):
         mock.patch.stopall()
@@ -83,7 +84,8 @@ class TestRepoFunctions(unittest.TestCase):
                     'state': 0,
                     'task_id': 15,
                     'create_event': 32,
-                    'creation_time': datetime.datetime(2021, 3, 30, 12, 34, 5, 204023, tzinfo=datetime.timezone.utc),
+                    'creation_time': datetime.datetime(2021, 3, 30, 12, 34, 5, 204023,
+                                                       tzinfo=datetime.timezone.utc),
                     'create_ts': 1617107645.204023,
                     'tag_id': 3,
                     'tag_name': 'test-tag',
@@ -92,8 +94,8 @@ class TestRepoFunctions(unittest.TestCase):
         rv = kojihub.repo_info(3)
         self.assertEqual(rv, repo_row)
 
-    def test_get_repo(self):
-        rv = self.exports.getRepo(2)
+    def test_get_repo_default(self):
+        self.exports.getRepo(2)
         self.assertEqual(len(self.queries), 1)
         query = self.queries[0]
         # make sure the following does not error
@@ -105,3 +107,17 @@ class TestRepoFunctions(unittest.TestCase):
         self.assertEqual(query.joins, ['events ON repo.create_event = events.id'])
         self.assertEqual(query.clauses, ['repo.dist is false', 'repo.state = %(state)s',
                                          'repo.tag_id = %(id)i'])
+
+    def test_get_repo_with_dist_and_event(self):
+        self.exports.getRepo(2, event=111, dist=True)
+        self.assertEqual(len(self.queries), 1)
+        query = self.queries[0]
+        # make sure the following does not error
+        str(query)
+        self.assertEqual(query.tables, ['repo'])
+        columns = ['repo.id', 'repo.state', 'repo.task_id', 'repo.create_event',
+                   'EXTRACT(EPOCH FROM events.time)', 'repo.dist', 'events.time']
+        self.assertEqual(set(query.columns), set(columns))
+        self.assertEqual(query.joins, ['events ON repo.create_event = events.id'])
+        self.assertEqual(query.clauses, ['create_event <= %(event)i', 'repo.dist is true',
+                                         'repo.tag_id = %(id)i'])
diff --git a/tests/test_hub/test_restart_hosts.py b/tests/test_hub/test_restart_hosts.py
index 5da1c32..4bd6fc3 100644
--- a/tests/test_hub/test_restart_hosts.py
+++ b/tests/test_hub/test_restart_hosts.py
@@ -2,6 +2,7 @@ import unittest
 
 import mock
 
+import koji
 import kojihub
 
 
@@ -13,12 +14,18 @@ class TestRestartHosts(unittest.TestCase):
         self.context.session.assertPerm = mock.MagicMock()
         self.make_task = mock.patch('kojihub.make_task').start()
 
-    def options_is_none(self):
+    def test_options_is_none(self):
         self.make_task.return_value = 13
         rv = self.exports.restartHosts()
         self.assertEqual(rv, 13)
 
-    def options_is_not_none(self):
+    def test_options_is_not_none(self):
         self.make_task.return_value = 13
         rv = self.exports.restartHosts(options={'opt': 'open'})
         self.assertEqual(rv, 13)
+
+    def test_options_wrong_type(self):
+        options = 'test-options'
+        with self.assertRaises(koji.ParameterError) as ex:
+            self.exports.restartHosts(options=options)
+        self.assertEqual(f"Invalid type of options: {type(options)}", str(ex.exception))
diff --git a/tests/test_hub/test_search.py b/tests/test_hub/test_search.py
index 72fb162..1f8ecff 100644
--- a/tests/test_hub/test_search.py
+++ b/tests/test_hub/test_search.py
@@ -18,4 +18,4 @@ class TestSearch(unittest.TestCase):
         type = 'test-type'
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.search('item', type, 'glob')
-        self.assertEqual("No such search type: %s" % type, str(cm.exception))
+        self.assertEqual(f"No such search type: {type}", str(cm.exception))
diff --git a/tests/test_hub/test_tag_operations.py b/tests/test_hub/test_tag_operations.py
index d15de00..381b78c 100644
--- a/tests/test_hub/test_tag_operations.py
+++ b/tests/test_hub/test_tag_operations.py
@@ -252,16 +252,22 @@ class TestGetTag(unittest.TestCase):
         self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
                                          side_effect=self.getQuery).start()
         self.queries = []
+        self.tagname = 'test-tag'
 
     def test_get_tag_invalid_taginfo(self):
         taginfo = {'test-tag': 'value'}
         with self.assertRaises(koji.GenericError) as ex:
             kojihub.get_tag(taginfo, strict=True)
-        self.assertEqual("Invalid name or id value: %s" % taginfo, str(ex.exception))
+        self.assertEqual(f"Invalid name or id value: {taginfo}", str(ex.exception))
 
     def test_get_tag_non_exist_tag(self):
-        taginfo = 'test-tag'
         self.query_executeOne.return_value = None
         with self.assertRaises(koji.GenericError) as ex:
-            kojihub.get_tag(taginfo, strict=True)
-        self.assertEqual("No such tagInfo: '%s'" % taginfo, str(ex.exception))
+            kojihub.get_tag(self.tagname, strict=True)
+        self.assertEqual(f"No such tagInfo: '{self.tagname}'", str(ex.exception))
+
+    def test_get_tag_wrong_event(self):
+        event = 'unsupported-event'
+        with self.assertRaises(koji.GenericError) as ex:
+            kojihub.get_tag(self.tagname, event=event)
+        self.assertEqual(f"Invalid event: '{event}'", str(ex.exception))
diff --git a/tests/test_hub/test_update_notification.py b/tests/test_hub/test_update_notification.py
new file mode 100644
index 0000000..f08d8c5
--- /dev/null
+++ b/tests/test_hub/test_update_notification.py
@@ -0,0 +1,148 @@
+import mock
+import unittest
+
+import koji
+import kojihub
+
+QP = kojihub.QueryProcessor
+IP = kojihub.InsertProcessor
+UP = kojihub.UpdateProcessor
+
+
+class TestUpdateNotifications(unittest.TestCase):
+    def getInsert(self, *args, **kwargs):
+        insert = IP(*args, **kwargs)
+        insert.execute = mock.MagicMock()
+        self.inserts.append(insert)
+        return insert
+
+    def getQuery(self, *args, **kwargs):
+        query = QP(*args, **kwargs)
+        query.execute = mock.MagicMock()
+        self.queries.append(query)
+        return query
+
+    def getUpdate(self, *args, **kwargs):
+        update = UP(*args, **kwargs)
+        update.execute = mock.MagicMock()
+        self.updates.append(update)
+        return update
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.context.opts = {
+            'EmailDomain': 'test.domain.com',
+            'NotifyOnSuccess': True,
+        }
+
+        self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
+                                         side_effect=self.getQuery).start()
+        self.queries = []
+        self.InsertProcessor = mock.patch('kojihub.InsertProcessor',
+                                          side_effect=self.getInsert).start()
+        self.inserts = []
+        self.UpdateProcessor = mock.patch('kojihub.UpdateProcessor',
+                                          side_effect=self.getUpdate).start()
+        self.updates = []
+        self.get_build_notifications = mock.patch('kojihub.get_build_notifications').start()
+        self.get_tag_id = mock.patch('kojihub.get_tag_id').start()
+        self.get_package_id = mock.patch('kojihub.get_package_id').start()
+
+        self.exports = kojihub.RootExports()
+        self.exports.getLoggedInUser = mock.MagicMock()
+        self.exports.hasPerm = mock.MagicMock()
+        self.exports.getBuildNotification = mock.MagicMock()
+        self.user_id = 1
+        self.n_id = 5432
+        self.package_id = 234
+        self.tag_id = 345
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_updateNotification(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.hasPerm.return_value = True
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notifications.return_value = [{
+            'tag_id': self.tag_id,
+            'user_id': self.user_id,
+            'package_id': self.package_id,
+            'success_only': not success_only,
+        }]
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+
+        r = self.exports.updateNotification(self.n_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual(r, None)
+
+        self.exports.getLoggedInUser.assert_called_once()
+        self.exports.hasPerm.asssert_called_once_with('admin')
+        self.get_package_id.assert_called_once_with(self.package_id, strict=True)
+        self.get_tag_id.assert_called_once_with(self.tag_id, strict=True)
+        self.get_build_notifications.assert_called_once_with(self.user_id)
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 1)
+
+    def test_updateNotification_not_logged(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = None
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.updateNotification(self.n_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual('Not logged-in', str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
+
+    def test_updateNotification_missing(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.getBuildNotification.side_effect = koji.GenericError
+
+        with self.assertRaises(koji.GenericError):
+            self.exports.updateNotification(self.n_id, self.package_id, self.tag_id, success_only)
+
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
+
+    def test_updateNotification_no_perm(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 132}
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+        self.exports.hasPerm.return_value = False
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.updateNotification(self.n_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual(f'user 132 cannot update notifications for user {self.user_id}',
+                         str(cm.exception))
+
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
+
+    def test_updateNotification_exists(self):
+        success_only = True
+        self.exports.getLoggedInUser.return_value = {'id': 1}
+        self.exports.hasPerm.return_value = True
+        self.get_package_id.return_value = self.package_id
+        self.get_tag_id.return_value = self.tag_id
+        self.get_build_notifications.return_value = [{
+            'tag_id': self.tag_id,
+            'user_id': self.user_id,
+            'package_id': self.package_id,
+            'success_only': success_only,
+        }]
+        self.exports.getBuildNotification.return_value = {'user_id': self.user_id}
+
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.updateNotification(self.n_id, self.package_id, self.tag_id, success_only)
+        self.assertEqual('notification already exists', str(cm.exception))
+
+        self.exports.getLoggedInUser.assert_called_once()
+        self.exports.hasPerm.asssert_called_once_with('admin')
+        self.get_package_id.assert_called_once_with(self.package_id, strict=True)
+        self.get_tag_id.assert_called_once_with(self.tag_id, strict=True)
+        self.get_build_notifications.assert_called_once_with(self.user_id)
+        self.assertEqual(len(self.inserts), 0)
+        self.assertEqual(len(self.updates), 0)
diff --git a/tests/test_hub/test_win_build.py b/tests/test_hub/test_win_build.py
new file mode 100644
index 0000000..a2adcd7
--- /dev/null
+++ b/tests/test_hub/test_win_build.py
@@ -0,0 +1,68 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestWinBuild(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.assert_policy = mock.patch('kojihub.assert_policy').start()
+        self.get_build_target = mock.patch('kojihub.get_build_target').start()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.vm = 'test-vm'
+        self.url = 'https://test-url.com'
+        self.target = 'test-target'
+        self.targetinfo = {'build_tag': 444,
+                           'build_tag_name': 'test-tag',
+                           'dest_tag': 445,
+                           'dest_tag_name': 'dest-test-tag',
+                           'id': 1,
+                           'name': self.target}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_win_not_supported(self):
+        self.context.opts.get.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.winBuild(self.vm, self.url, self.target)
+        self.assertEqual("Windows support not enabled", str(cm.exception))
+
+    def test_vm_wrong_type(self):
+        vm = ['test-vm']
+        self.context.opts.get.return_value = True
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.winBuild(vm, self.url, self.target)
+        self.assertEqual(f"Invalid type for value '{vm}': {type(vm)}", str(cm.exception))
+
+    def test_url_wrong_type(self):
+        url = ['test-url']
+        self.context.opts.get.return_value = True
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.winBuild(self.vm, url, self.target)
+        self.assertEqual(f"Invalid type for value '{url}': {type(url)}", str(cm.exception))
+
+    def test_priority_without_admin(self):
+        priority = -10
+        self.context.opts.get.return_value = True
+        self.get_build_target.return_value = self.targetinfo
+        self.assert_policy.return_value = True
+        self.context.session.hasPerm.return_value = False
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.winBuild(self.vm, self.url, self.target, priority=priority)
+        self.assertEqual("only admins may create high-priority tasks", str(cm.exception))
+
+    def test_channel_not_str(self):
+        self.context.opts.get.return_value = True
+        self.get_build_target.return_value = self.targetinfo
+        self.assert_policy.return_value = True
+        self.make_task.return_value = 123
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 1, 'name': 'vm'}
+        self.exports.winBuild(self.vm, self.url, self.target, channel=1, priority=10)
diff --git a/tests/test_hub/test_wrapper_rpm.py b/tests/test_hub/test_wrapper_rpm.py
new file mode 100644
index 0000000..0698285
--- /dev/null
+++ b/tests/test_hub/test_wrapper_rpm.py
@@ -0,0 +1,56 @@
+import unittest
+import koji
+import kojihub
+import mock
+
+
+class TestWrapperRPM(unittest.TestCase):
+
+    def setUp(self):
+        self.context = mock.patch('kojihub.context').start()
+        self.exports = kojihub.RootExports()
+        self.context.session.assertLogin = mock.MagicMock()
+        self.context.session.hasPerm = mock.MagicMock()
+        self.get_channel = mock.patch('kojihub.get_channel').start()
+        self.exports.getBuild = mock.MagicMock()
+        self.make_task = mock.patch('kojihub.make_task').start()
+        self.list_rpms = mock.patch('kojihub.list_rpms').start()
+        self.exports.getTag = mock.MagicMock()
+        self.exports.getBuildTarget = mock.MagicMock()
+        self.exports.getRepo = mock.MagicMock()
+        self.build = 'testbuild-1-1.4'
+        self.target = 'test-target'
+        self.url = 'https://test-url.com'
+        self.buildinfo = {'name': 'testbuild', 'version': '1', 'release': '1.4',
+                          'nvr': self.build, 'id': 123}
+        self.targetinfo = {'build_tag': 444,
+                           'build_tag_name': 'test-tag',
+                           'dest_tag': 445,
+                           'dest_tag_name': 'dest-test-tag',
+                           'id': 1,
+                           'name': self.target}
+        self.taginfo = {'id': 159, 'name': 'test-tag'}
+        self.repoinfo = {'id': 753}
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_url_wrong_type(self):
+        url = ['test-url']
+        self.context.opts.get.return_value = True
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.wrapperRPM(self.build, url, self.target)
+        self.assertEqual(f"Invalid type for value '{url}': {type(url)}", str(cm.exception))
+
+    def test_channel_not_str(self):
+        priority = 10
+        self.context.opts.get.return_value = True
+        self.exports.getBuild.return_value = self.buildinfo
+        self.list_rpms.return_value = []
+        self.exports.getBuildTarget.return_value = self.targetinfo
+        self.exports.getRepo.return_value = self.taginfo
+        self.exports.getRepo.return_value = self.repoinfo
+        self.make_task.return_value = 123
+        self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
+                                         'id': 2, 'name': 'maven'}
+        self.exports.wrapperRPM(self.build, self.url, self.target, priority=priority, channel=2)
diff --git a/tests/test_hub/test_write_signed_rpm.py b/tests/test_hub/test_write_signed_rpm.py
new file mode 100644
index 0000000..818ea22
--- /dev/null
+++ b/tests/test_hub/test_write_signed_rpm.py
@@ -0,0 +1,23 @@
+import unittest
+import mock
+
+import koji
+import kojihub
+
+
+class TestWriteSignedRPM(unittest.TestCase):
+    def setUp(self):
+        self.get_rpm = mock.patch('kojihub.get_rpm').start()
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_write_signed_rpm_not_internal_rpm(self):
+        sigkey = 'test-sigkey'
+        rpm_id = 1
+        rpminfo = {'external_repo_id': 1, 'external_repo_name': 'test-external-repo'}
+        self.get_rpm.return_value = rpminfo
+        with self.assertRaises(koji.GenericError) as cm:
+            kojihub.write_signed_rpm(rpm_id, sigkey)
+        self.assertEqual(f"Not an internal rpm: {rpm_id} (from {rpminfo['external_repo_name']})",
+                         str(cm.exception))
diff --git a/tests/test_plugins/test_runroot_hub.py b/tests/test_plugins/test_runroot_hub.py
index f78b2e0..14d7874 100644
--- a/tests/test_plugins/test_runroot_hub.py
+++ b/tests/test_plugins/test_runroot_hub.py
@@ -48,8 +48,8 @@ class TestRunrootHub(unittest.TestCase):
         context.handlers = mock.MagicMock()
         context.handlers.call = mock.MagicMock()
         context.handlers.call.side_effect = [
-            {'id': 2, 'name': 'runroot'}, # getChannel
-            [ # listHosts
+            {'id': 2, 'name': 'runroot'},  # getChannel
+            [  # listHosts
                 {
                     'arches': 'i386 x86_64',
                     'capacity': 20.0,
@@ -105,8 +105,8 @@ class TestRunrootHub(unittest.TestCase):
         context.handlers = mock.MagicMock()
         context.handlers.call = mock.MagicMock()
         context.handlers.call.side_effect = [
-            {'id': 2, 'name': 'runroot'}, # getChannel
-            [ # listHosts
+            {'id': 2, 'name': 'runroot'},  # getChannel
+            [  # listHosts
                 {
                     'arches': 'i386 x86_64',
                     'capacity': 20.0,
@@ -147,3 +147,31 @@ class TestRunrootHub(unittest.TestCase):
             mock.call('listHosts', channelID=2, enabled=True),
         ])
         make_task.assert_not_called()
+
+    @mock.patch('kojihub.get_channel')
+    @mock.patch('kojihub.get_tag')
+    @mock.patch('kojihub.make_task')
+    @mock.patch('runroot_hub.context')
+    def test_non_exist_channel(self, context, make_task, get_tag, get_channel):
+        context.session.assertPerm = mock.MagicMock()
+        get_channel.side_effect = koji.GenericError
+        with self.assertRaises(koji.GenericError):
+            runroot_hub.runroot(tagInfo='some_tag', arch='x86_64', command='ls',
+                                channel='non-exist-channel')
+        make_task.assert_not_called()
+        get_tag.assert_not_called()
+
+    @mock.patch('kojihub.get_channel')
+    @mock.patch('kojihub.get_tag')
+    @mock.patch('kojihub.make_task')
+    @mock.patch('runroot_hub.context')
+    def test_commang_wrong_format(self, context, make_task, get_tag, get_channel):
+        context.session.assertPerm = mock.MagicMock()
+        command = ['ls']
+        with self.assertRaises(koji.GenericError) as ex:
+            runroot_hub.runroot(tagInfo='some_tag', arch='x86_64', command=command,
+                                channel='non-exist-channel')
+        self.assertEqual(f"Invalid type for value '{command}': {type(command)}", str(ex.exception))
+        make_task.assert_not_called()
+        get_tag.assert_not_called()
+        get_channel.assert_not_called()
diff --git a/util/koji-gc b/util/koji-gc
index 79d589f..6013c27 100755
--- a/util/koji-gc
+++ b/util/koji-gc
@@ -168,6 +168,8 @@ def get_options():
     # figure out actions
     actions = ('prune', 'trash', 'delete', 'salvage')
     if options.action:
+        if not isinstance(options.action, str):
+            raise koji.ParameterError('Invalid type of action: %s' % type(options.action))
         options.action = options.action.lower().replace(',', ' ').split()
         for x in options.action:
             if x not in actions:
@@ -177,6 +179,9 @@ def get_options():
 
     # split patterns for unprotected keys
     if options.unprotected_keys:
+        if not isinstance(options.unprotected_keys, str):
+            raise koji.ParameterError('Invalid type of unprotected_keys: %s'
+                                      % type(options.unprotected_keys))
         options.unprotected_key_patterns = options.unprotected_keys.replace(',', ' ').split()
     else:
         options.unprotected_key_patterns = []
@@ -250,10 +255,15 @@ def check_tag(name):
     Returns True if we should process the tag, False otherwise
     """
     if options.ignore_tags:
+        if not isinstance(options.ignore_tags, list):
+            raise koji.ParameterError('Invalid type of ignore_tags: %s'
+                                      % type(options.ignore_tags))
         for pattern in options.ignore_tags:
             if fnmatch.fnmatch(name, pattern):
                 return False
     if options.tag_filter:
+        if not isinstance(options.tag_filter, list):
+            raise koji.ParameterError('Invalid type of tag_filter: %s' % type(options.tag_filter))
         for pattern in options.tag_filter:
             if fnmatch.fnmatch(name, pattern):
                 return True
@@ -270,6 +280,8 @@ def check_package(name):
     Returns True if we should process the package, False otherwise
     """
     if options.pkg_filter:
+        if not isinstance(options.pkg_filter, list):
+            raise koji.ParameterError('Invalid type of pkg_filter: %s' % type(options.pkg_filter))
         for pattern in options.pkg_filter:
             if fnmatch.fnmatch(name, pattern):
                 return True
@@ -417,13 +429,21 @@ Build: %%(name)s-%%(version)s-%%(release)s
         msg['Subject'] = "1 build marked for deletion"
     else:
         msg['Subject'] = "%i builds marked for deletion" % len(builds)
+    if not isinstance(options.from_addr, str):
+        raise koji.ParameterError('Invalid type of from_addr: %s' % type(options.from_addr))
     msg['From'] = options.from_addr
+    if not isinstance(options.email_domain, str):
+        raise koji.ParameterError('Invalid type of email_domain: %s' % type(options.email_domain))
     msg['To'] = "%s@%s" % (owner_name, options.email_domain)  # XXX!
     emails = [msg['To']]
     if options.cc_addr:
+        if not isinstance(options.cc_addr, str):
+            raise koji.ParameterError('Invalid type of cc_addr: %s' % type(options.cc_addr))
         msg['Cc'] = ','.join(options.cc_addr)
         emails += options.cc_addr
     if options.bcc_addr:
+        if not isinstance(options.bcc_addr, str):
+            raise koji.ParameterError('Invalid type of bcc_addr: %s' % type(options.bcc_addr))
         emails += options.bcc_addr
     msg['X-Koji-Builder'] = owner_name
     if options.test:
@@ -894,6 +914,9 @@ def handle_prune():
         bypass = False
         if taginfo['locked']:
             if options.bypass_locks:
+                if not isinstance(options.bypass_locks, list):
+                    raise koji.ParameterError('Invalid type of bypass_locks: %s'
+                                              % type(options.bypass_locks))
                 for pattern in options.bypass_locks:
                     if fnmatch.fnmatch(tagname, pattern):
                         bypass = True
diff --git a/util/koji-shadow b/util/koji-shadow
index 0f25329..211d7ad 100755
--- a/util/koji-shadow
+++ b/util/koji-shadow
@@ -389,6 +389,8 @@ class TrackedBuild(object):
             log("Downloading %s" % url)
             # XXX - this is not really the right place for this
             resp = request_with_retry().get(url, stream=True)
+            if not isinstance(options.workpath, str):
+                raise koji.ParameterError('Invalid type of workpath: %s' % type(options.workpath))
             fn = "%s/%s.src.rpm" % (options.workpath, self.nvr)
             koji.ensuredir(os.path.dirname(fn))
             try:
diff --git a/util/koji-sidetag-cleanup b/util/koji-sidetag-cleanup
index a881a21..1bdc6d3 100644
--- a/util/koji-sidetag-cleanup
+++ b/util/koji-sidetag-cleanup
@@ -141,6 +141,11 @@ def activate_session(session):
     elif options.keytab and options.principal:
         try:
             if options.keytab and options.principal:
+                if not isinstance(options.keytab, str):
+                    raise koji.ParameterError('Invalid type of keytab: %s' % type(options.keytab))
+                if not isinstance(options.principal, str):
+                    raise koji.ParameterError('Invalid type of principal: %s'
+                                              % type(options.principal))
                 session.gssapi_login(
                     principal=options.principal,
                     keytab=options.keytab,

From 04da1d2db309343d0d2d64aec39390ec2e9f2139 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 02 2022 04:55:46 +0000
Subject: [PATCH 2/3] additional fixes


---

diff --git a/hub/kojihub.py b/hub/kojihub.py
index 1da5f84..54c1fea 100644
--- a/hub/kojihub.py
+++ b/hub/kojihub.py
@@ -91,28 +91,32 @@ def xform_user_krb(entry):
     return entry
 
 
-def convert_value(value, cast=None, message=None, exc_type=None, none_allowed=False):
-    if exc_type is None:
-        exc_type = koji.ParameterError
+def convert_value(value, cast=None, message=None,
+                  exc_type=koji.ParameterError, none_allowed=False, check_only=False):
+    """Cast to another type with tailored exception
+
+    :param any value: tested object
+    :param type cast: To which type value should be cast
+    :param type exc_type: Raise this exception
+    :param bool none_allowed: Is None valid value?
+    :param check_only: Don't convert but raise an exception if type(value) != cast
+
+    :returns any value: returns converted value
+    """
     if value is None:
         if not none_allowed:
             raise(exc_type(message or "Invalid type"))
         else:
             return value
-    try:
-        return cast(value)
-    except (ValueError, TypeError):
-        raise(exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
-
-
-def check_value_type(value, cast=None, message=None, exc_type=None, none_allowed=False):
-    if none_allowed:
-        if value is None:
-            return value
-    if exc_type is None:
-        exc_type = koji.ParameterError
-    if not isinstance(value, cast):
-        raise (exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
+    if check_only:
+        if not isinstance(value, cast):
+            raise(exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
+    else:
+        try:
+            value = cast(value)
+        except (ValueError, TypeError):
+            raise(exc_type(message or f"Invalid type for value '{value}': {type(value)}"))
+    return value
 
 
 class Task(object):
@@ -564,11 +568,11 @@ def make_task(method, arglist, **opts):
         priority: the priority of the task
         assign: a host_id to assign the task to
     """
-    check_value_type(method, cast=str)
+    convert_value(method, cast=str, check_only=True)
     if 'parent' in opts:
         opts['parent'] = convert_value(opts['parent'], cast=int)
     if 'label' in opts:
-        check_value_type(opts['label'], cast=str)
+        convert_value(opts['label'], cast=str, check_only=True)
     if 'owner' in opts:
         if not isinstance(opts['owner'], int):
             opts['owner'] = get_user(opts['owner'], strict=True)['id']
@@ -1429,8 +1433,6 @@ def readTaggedBuilds(tag, event=None, inherit=False, latest=False, package=None,
         'build.state = %(st_complete)i'
     ]
     if package:
-        if not isinstance(package, str):
-            package = lookup_package(package, strict=True)['name']
         clauses.append('package.name = %(package)s')
     if owner:
         clauses.append('users.name = %(owner)s')
@@ -1678,10 +1680,9 @@ def check_tag_access(tag_id, user_id=None):
     """
     if user_id is None:
         user_id = context.session.user_id
-        if not user_id:
-            raise koji.GenericError("a user_id is required")
-    else:
-        user_id = convert_value(user_id, cast=int)
+    if user_id is None:
+        raise koji.GenericError("a user_id is required")
+    user_id = convert_value(user_id, cast=int)
     perms = koji.auth.get_user_perms(user_id)
     override = False
     if 'admin' in perms:
@@ -2424,10 +2425,10 @@ def edit_channel(channelInfo, **kw):
         dup_check = get_channel(kw['name'], strict=False)
         if dup_check:
             raise koji.GenericError("channel %(name)s already exists (id=%(id)i)" % dup_check)
-    if kw.get('description'):
-        check_value_type(kw['description'], cast=str)
-    if kw.get('comment'):
-        check_value_type(kw['comment'], cast=str)
+    if 'description' in kw:
+        convert_value(kw['description'], cast=str, check_only=True)
+    if 'comment' in kw:
+        convert_value(kw['comment'], cast=str, check_only=True)
 
     update = UpdateProcessor('channels',
                              values={'channelID': channel['id']},
@@ -2479,7 +2480,8 @@ def add_channel(channel_name, description=None):
     :param str description: description of channel
     """
     context.session.assertPerm('admin')
-    check_value_type(description, cast=str, none_allowed=True)
+    convert_value(description, cast=str, none_allowed=True,
+                  message="Channel name must be a string")
     verify_name_internal(channel_name)
     dup_check = get_channel(channel_name, strict=False)
     if dup_check:
@@ -2494,7 +2496,7 @@ def add_channel(channel_name, description=None):
 
 def set_channel_enabled(channelname, enabled=True, comment=None):
     context.session.assertPerm('host')
-    check_value_type(comment, cast=str, none_allowed=True)
+    convert_value(comment, cast=str, none_allowed=True, check_only=True)
     channel = get_channel(channelname)
     if not channel:
         raise koji.GenericError('No such channel: %s' % channelname)
@@ -2870,10 +2872,10 @@ def _write_maven_repo_metadata(destdir, artifacts):
 def dist_repo_init(tag, keys, task_opts):
     """Create a new repo entry in the INIT state, return full repo data"""
     state = koji.REPO_INIT
-    check_value_type(keys, cast=list)
+    convert_value(keys, cast=list, check_only=True)
     tinfo = get_tag(tag, strict=True)
     tag_id = tinfo['id']
-    check_value_type(task_opts, cast=dict)
+    convert_value(task_opts, cast=dict, check_only=True)
     event = task_opts.get('event')
     event = convert_value(event, cast=int, none_allowed=True)
     volume = task_opts.get('volume')
@@ -2903,7 +2905,7 @@ def dist_repo_init(tag, keys, task_opts):
         os.symlink(relpath, basedir)
     # handle comps
     if task_opts.get('comps'):
-        check_value_type(task_opts['comps'], cast=str)
+        convert_value(task_opts['comps'], cast=str, check_only=True)
         groupsdir = joinpath(repodir, 'groups')
         koji.ensuredir(groupsdir)
         shutil.copyfile(joinpath(koji.pathinfo.work(),
@@ -2931,7 +2933,7 @@ def repo_set_state(repo_id, state, check=True):
     repo_id = convert_value(repo_id, cast=int)
     if check:
         # The repo states are sequential, going backwards makes no sense
-        q = """SELECT state FROM repo WHERE id = %(repo_id)s FOR UPDATE"""
+        q = """SELECT state FROM repo WHERE id = %(repo_id)i FOR UPDATE"""
         oldstate = _singleValue(q, locals())
         if oldstate > state:
             raise koji.GenericError("Invalid repo state transition %s->%s"
@@ -2988,7 +2990,7 @@ def repo_delete(repo_id):
     If the number of references is nonzero, no change is made"""
     repo_id = convert_value(repo_id, cast=int)
     # get a row lock on the repo
-    q = """SELECT state FROM repo WHERE id = %(repo_id)s FOR UPDATE"""
+    q = """SELECT state FROM repo WHERE id = %(repo_id)i FOR UPDATE"""
     _singleValue(q, locals())
     references = repo_references(repo_id)
     if not references:
@@ -3609,9 +3611,9 @@ def _edit_tag(tagInfo, **kwargs):
             and dslice(kwargs, ['maven_support', 'maven_include_all'], strict=False):
         raise koji.GenericError("Maven support not enabled")
     if kwargs.get('remove_extra'):
-        check_value_type(kwargs['remove_extra'], cast=list, none_allowed=True)
+        convert_value(kwargs['remove_extra'], cast=list, none_allowed=True, check_only=True)
     if kwargs.get('block_extra'):
-        check_value_type(kwargs['block_extra'], cast=list, none_allowed=True)
+        convert_value(kwargs['block_extra'], cast=list, none_allowed=True, check_only=True)
 
     tag = get_tag(tagInfo, strict=True)
     if 'perm' in kwargs and 'perm_id' not in kwargs:
@@ -5580,7 +5582,7 @@ def edit_host(hostInfo, **kw):
 
     for change in changes:
         if change in ['description', 'comment', 'arches']:
-            check_value_type(kw[change], cast=str)
+            convert_value(kw[change], cast=str, check_only=True)
 
     update = UpdateProcessor('host_config', values=host, clauses=['host_id = %(id)i'])
     update.make_revoke()
@@ -6233,9 +6235,9 @@ def import_build(srpm, rpms, brmap=None, task_id=None, build_id=None, logs=None)
     if brmap is None:
         brmap = {}
     else:
-        check_value_type(brmap, cast=dict)
-    check_value_type(srpm, cast=str)
-    check_value_type(rpms, cast=list)
+        convert_value(brmap, cast=dict, check_only=True)
+    convert_value(srpm, cast=str, check_only=True)
+    convert_value(rpms, cast=list, check_only=True)
     koji.plugin.run_callbacks('preImport', type='build', srpm=srpm, rpms=rpms, brmap=brmap,
                               task_id=task_id, build_id=build_id, build=None, logs=logs)
     uploadpath = koji.pathinfo.work()
@@ -6612,7 +6614,7 @@ class CG_Importer(object):
         if metadata is None:
             # default to looking for uploaded file
             metadata = 'metadata.json'
-        check_value_type(metadata, cast=str)
+        convert_value(metadata, cast=str, check_only=True)
         if metadata.endswith('.json'):
             # handle uploaded metadata
             workdir = koji.pathinfo.work()
@@ -7387,7 +7389,7 @@ def get_archive_type(filename=None, type_name=None, type_id=None, strict=False):
     elif type_name:
         return _get_archive_type_by_name(type_name, strict)
     elif filename:
-        check_value_type(filename, cast=str)
+        convert_value(filename, cast=str, check_only=True)
     else:
         raise koji.GenericError('one of filename, type_name, or type_id must be specified')
 
@@ -7427,8 +7429,8 @@ def add_archive_type(name, description, extensions):
     """
     context.session.assertPerm('admin')
     verify_name_internal(name)
-    check_value_type(description, cast=str)
-    check_value_type(extensions, cast=str)
+    convert_value(description, cast=str, check_only=True)
+    convert_value(extensions, cast=str, check_only=True)
     data = {'name': name,
             'description': description,
             'extensions': extensions,
@@ -10387,7 +10389,7 @@ def policy_data_from_task_args(method, arglist):
             # newRepo has a 'src' parameter that means something else
             break
         if k in params:
-            check_value_type(params[k], cast=str)
+            convert_value(params[k], cast=str, check_only=True)
             policy_data['source'] = params.get(k)
             break
     # parameters that indicate build target
@@ -10569,7 +10571,7 @@ class RootExports(object):
         if not opts:
             opts = {}
         taskOpts = {}
-        check_value_type(src, cast=str)
+        convert_value(src, cast=str, check_only=True)
         if priority:
             if priority < 0:
                 if not context.session.hasPerm('admin'):
@@ -10593,7 +10595,7 @@ class RootExports(object):
         Returns a list of all the dependent task ids
         """
         context.session.assertLogin()
-        check_value_type(srcs, cast=list)
+        convert_value(srcs, cast=list, check_only=True)
         if not opts:
             opts = {}
         taskOpts = {}
@@ -10622,7 +10624,7 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableMaven'):
             raise koji.GenericError("Maven support not enabled")
-        check_value_type(url, cast=str)
+        convert_value(url, cast=str, check_only=True)
         if not opts:
             opts = {}
         taskOpts = {}
@@ -10659,7 +10661,7 @@ class RootExports(object):
         if not opts:
             opts = {}
 
-        check_value_type(url, cast=str)
+        convert_value(url, cast=str, check_only=True)
 
         build = self.getBuild(build, strict=True)
         if list_rpms(build['id']) and not (opts.get('scratch') or opts.get('create_build')):
@@ -10675,8 +10677,8 @@ class RootExports(object):
         taskOpts = {}
         if priority:
             taskOpts['priority'] = koji.PRIO_DEFAULT + priority
-        if channel:
-            taskOpts['channel'] = channel
+        convert_value(channel, cast=str, check_only=True)
+        taskOpts['channel'] = channel
 
         return make_task('wrapperRPM', [url, build_target, build, None, opts], **taskOpts)
 
@@ -10695,7 +10697,7 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableMaven'):
             raise koji.GenericError("Maven support not enabled")
-        check_value_type(builds, cast=list)
+        convert_value(builds, cast=list, check_only=True)
         taskOpts = {}
         if priority:
             if priority < 0:
@@ -10725,8 +10727,8 @@ class RootExports(object):
         context.session.assertLogin()
         if not context.opts.get('EnableWin'):
             raise koji.GenericError("Windows support not enabled")
-        check_value_type(vm, cast=str)
-        check_value_type(url, cast=str)
+        convert_value(vm, cast=str, check_only=True)
+        convert_value(url, cast=str, check_only=True)
         targ_info = get_build_target(target, strict=True)
         policy_data = {'vm_name': vm,
                        'tag': targ_info['dest_tag']}
@@ -10754,7 +10756,7 @@ class RootExports(object):
         if img_type not in ('livecd', 'appliance', 'livemedia'):
             raise koji.GenericError('Unrecognized image type: %s' % img_type)
         for i in [name, ksfile, version]:
-            check_value_type(i, cast=str)
+            convert_value(i, cast=str, check_only=True)
 
         context.session.assertPerm(img_type)
 
@@ -10803,7 +10805,7 @@ class RootExports(object):
         Create an image using a kickstart file and group package list.
         """
         for i in [name, inst_tree, version]:
-            check_value_type(i, cast=str)
+            convert_value(i, cast=str, check_only=True)
         context.session.assertPerm('image')
         taskOpts = {'channel': 'image'}
         if priority:
@@ -12577,7 +12579,7 @@ class RootExports(object):
             verify_name_internal(permission)
         if description is not None and not create:
             raise koji.GenericError('Description should be specified only with create.')
-        check_value_type(description, cast=str, none_allowed=True)
+        convert_value(description, cast=str, none_allowed=True, check_only=True)
         user_id = get_user(userinfo, strict=True)['id']
         perm = lookup_perm(permission, strict=(not create), create=create)
         perm_id = perm['id']
@@ -12611,7 +12613,7 @@ class RootExports(object):
     def editPermission(self, permission, description):
         """Edit a permission description"""
         context.session.assertPerm('admin')
-        check_value_type(description, cast=str)
+        convert_value(description, cast=str, check_only=True)
         perm = lookup_perm(permission, strict=True)
         perm_id = perm['id']
         update = UpdateProcessor('permissions', clauses=['id=%(perm_id)i'],
@@ -12636,8 +12638,7 @@ class RootExports(object):
             raise koji.GenericError('user already exists: %s' % username)
         if krb_principal and get_user_by_krb_principal(krb_principal):
             raise koji.GenericError(
-                'user with this Kerberos principal already exists: %s' % krb_principal)
-        status = convert_value(status, cast=int, none_allowed=True)
+                f'user with this Kerberos principal already exists: {krb_principal}')
         return context.session.createUser(username, status=status, krb_principal=krb_principal)
 
     def addUserKrbPrincipal(self, user, krb_principal):
@@ -12647,7 +12648,7 @@ class RootExports(object):
         verify_name_user(krb=krb_principal)
         if get_user_by_krb_principal(krb_principal):
             raise koji.GenericError(
-                'user with this Kerberos principal already exists: %s' % krb_principal)
+                f'user with this Kerberos principal already exists: {krb_principal}')
         return context.session.setKrbPrincipal(userinfo['name'], krb_principal)
 
     def removeUserKrbPrincipal(self, user, krb_principal):
@@ -13118,7 +13119,7 @@ class RootExports(object):
         # builder user can already exist, if host tried to log in before adding into db
         userinfo = {'name': hostname}
         if krb_principal:
-            verify_name_user(krb=krb_principal)
+            convert_value(krb_principal, cast=str, check_only=True)
             userinfo['krb_principal'] = krb_principal
         user = get_user(userInfo=userinfo)
         if user:
@@ -14100,7 +14101,7 @@ class Host(object):
 
     def taskSetWait(self, parent, tasks):
         """Mark task waiting and subtasks awaited"""
-        check_value_type(tasks, cast=list, none_allowed=True)
+        convert_value(tasks, cast=list, none_allowed=True, check_only=True)
         # mark parent as waiting
         update = UpdateProcessor('task', clauses=['id=%(parent)s'], values=locals())
         update.set(waiting=True)
@@ -14218,11 +14219,11 @@ class Host(object):
 
     def updateHost(self, task_load, ready):
         host_data = get_host(self.id)
-        task_load = convert_value(task_load, cast=float)
+        task_load = float(task_load)
         if task_load != host_data['task_load'] or ready != host_data['ready']:
             c = context.cnx.cursor()
             id = self.id
-            q = """UPDATE host SET task_load=%(task_load)s,ready=%(ready)s WHERE id=%(id)s"""
+            q = "UPDATE host SET task_load=%(task_load)f,ready=%(ready)s WHERE id=%(id)i"
             c.execute(q, locals())
             context.commit_pending = True
 
@@ -14501,7 +14502,7 @@ class HostExports(object):
 
     def moveImageBuildToScratch(self, task_id, results):
         """move a completed image scratch build into place"""
-        check_value_type(results, cast=dict)
+        convert_value(results, cast=dict, check_only=True)
         host = Host()
         host.verify()
         task = Task(task_id)
@@ -14947,8 +14948,8 @@ class HostExports(object):
         pkg_id = build['package_id']
         tag_id = get_tag(tag, strict=True)['id']
         user_id = task.getOwner()
-        if not isinstance(fromtag, str):
-            tag = get_tag(tag, strict=True)['name']
+        if fromtag and not isinstance(fromtag, str):
+            fromtag = get_tag(fromtag, strict=True)['name']
         policy_data = {'tag': tag, 'build': build, 'fromtag': fromtag}
         policy_data['user_id'] = user_id
         if fromtag is None:
@@ -15244,7 +15245,7 @@ class HostExports(object):
         host = Host()
         host.verify()
         rinfo = repo_info(repo_id, strict=True)
-        check_value_type(data, cast=dict)
+        convert_value(data, cast=dict, check_only=True)
         koji.plugin.run_callbacks('preRepoDone', repo=rinfo, data=data, expire=expire)
         if rinfo['state'] != koji.REPO_INIT:
             raise koji.GenericError("Repo %(id)s not in INIT state (got %(state)s)" % rinfo)
diff --git a/plugins/hub/kiwi.py b/plugins/hub/kiwi.py
index 57c0c96..0814120 100644
--- a/plugins/hub/kiwi.py
+++ b/plugins/hub/kiwi.py
@@ -20,9 +20,9 @@ def kiwiBuild(target, arches, desc_url, desc_path, optional_arches=None, profile
     context.session.assertPerm('image')
     for i in [desc_url, desc_path, profile, release]:
         if i is not None:
-            kojihub.check_value_type(i, cast=str)
+            kojihub.convert_value(i, cast=str, check_only=True)
     if repos:
-        kojihub.check_value_type(repos, cast=list)
+        kojihub.convert_value(repos, cast=list, check_only=True)
     kojihub.get_build_targets(target, strict=True)
     arches = koji.parse_arches(arches, strict=True, allow_none=False)
     optional_arches = koji.parse_arches(optional_arches, strict=True, allow_none=True)
diff --git a/plugins/hub/runroot_hub.py b/plugins/hub/runroot_hub.py
index c6f3639..271a9d3 100644
--- a/plugins/hub/runroot_hub.py
+++ b/plugins/hub/runroot_hub.py
@@ -31,7 +31,7 @@ def runroot(tagInfo, arch, command, channel=None, **opts):
     """ Create a runroot task """
     context.session.assertPerm('runroot')
     arch = koji.parse_arches(arch, strict=True, allow_none=False)
-    kojihub.check_value_type(command, cast=str)
+    kojihub.convert_value(command, cast=str, check_only=True)
     taskopts = {
         'priority': 15,
         'arch': arch,
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 2daf98a..4889a41 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -16,13 +16,13 @@ from kojihub import (  # noqa: E402
     _delete_tag,
     _edit_tag,
     assert_policy,
+    convert_value,
     get_build_target,
-    readInheritanceData,
     get_tag,
     get_user,
     nextval,
     policy_get_user,
-    check_value_type
+    readInheritanceData,
 )
 
 CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
@@ -271,11 +271,11 @@ def editSideTag(sidetag, debuginfo=None, rpm_macros=None, remove_rpm_macros=None
     if debuginfo is not None:
         kwargs['extra']['with_debuginfo'] = bool(debuginfo)
     if rpm_macros is not None:
-        check_value_type(rpm_macros, cast=dict)
+        convert_value(rpm_macros, cast=dict, check_only=True)
         for macro, value in rpm_macros.items():
             kwargs['extra']['rpm.macro.%s' % macro] = value
     if remove_rpm_macros is not None:
-        check_value_type(remove_rpm_macros, cast=list)
+        convert_value(remove_rpm_macros, cast=list, check_only=True)
         kwargs['remove_extra'] = ['rpm.macro.%s' % m for m in remove_rpm_macros]
 
     _edit_tag(sidetag['id'], **kwargs)
diff --git a/tests/test_hub/test_add_host.py b/tests/test_hub/test_add_host.py
index 0592d1f..6c13312 100644
--- a/tests/test_hub/test_add_host.py
+++ b/tests/test_hub/test_add_host.py
@@ -165,4 +165,3 @@ class TestAddHost(unittest.TestCase):
         self.assertEqual(self._singleValue.call_count, 1)
         self._singleValue.assert_called_once_with("SELECT id FROM channels WHERE name = 'default'")
         self.verify_host_name.assert_called_once_with('hostname')
-        self.verify_name_user.assert_called_once_with(krb=krb_principal)
diff --git a/tests/test_hub/test_create_user.py b/tests/test_hub/test_create_user.py
index d991aa8..e2027fe 100644
--- a/tests/test_hub/test_create_user.py
+++ b/tests/test_hub/test_create_user.py
@@ -53,12 +53,3 @@ class TestCreateUser(unittest.TestCase):
         with self.assertRaises(koji.GenericError) as cm:
             self.exports.createUser(self.user_name, krb_principal=krb_principal)
         self.assertEqual(expected, str(cm.exception))
-
-    def test_create_user_wrong_type_status(self):
-        status = 'test-status'
-        self.verify_name_user.return_value = None
-        self.get_user.return_value = None
-        self.get_user_by_krb_principal.return_value = self.user_info_krb
-        with self.assertRaises(koji.ParameterError) as cm:
-            self.exports.createUser(self.user_name, status=status)
-        self.assertEqual(f"Invalid type for value '{status}': {type(status)}", str(cm.exception))
diff --git a/tests/test_hub/test_wrapper_rpm.py b/tests/test_hub/test_wrapper_rpm.py
index 0698285..26b0f3c 100644
--- a/tests/test_hub/test_wrapper_rpm.py
+++ b/tests/test_hub/test_wrapper_rpm.py
@@ -53,4 +53,6 @@ class TestWrapperRPM(unittest.TestCase):
         self.make_task.return_value = 123
         self.get_channel.return_value = {'comment': None, 'description': None, 'enabled': True,
                                          'id': 2, 'name': 'maven'}
-        self.exports.wrapperRPM(self.build, self.url, self.target, priority=priority, channel=2)
+        with self.assertRaises(koji.ParameterError):
+            self.exports.wrapperRPM(self.build, self.url, self.target,
+                                    priority=priority, channel=2)
diff --git a/tests/test_plugins/test_sidetag_hub.py b/tests/test_plugins/test_sidetag_hub.py
index d0792a5..6e70b75 100644
--- a/tests/test_plugins/test_sidetag_hub.py
+++ b/tests/test_plugins/test_sidetag_hub.py
@@ -57,14 +57,14 @@ class TestSideTagHub(unittest.TestCase):
         )
         nextval.assert_called_once_with('tag_id_seq')
         _create_tag.assert_called_once_with(
-                sidetag_name,
-                parent=basetag['id'],
-                arches=basetag['arches'],
-                extra={
-                    "sidetag": True,
-                    "sidetag_user": user["name"],
-                    "sidetag_user_id": user["id"],
-                })
+            sidetag_name,
+            parent=basetag['id'],
+            arches=basetag['arches'],
+            extra={
+                "sidetag": True,
+                "sidetag_user": user["name"],
+                "sidetag_user_id": user["id"],
+            })
         _create_build_target.assert_called_once_with(sidetag_name, 12346, 12346)
 
     @mock.patch('sidetag_hub.nextval')

From 60add4a88ae1acbbb18540f472af62f088d0fc07 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 02 2022 05:09:51 +0000
Subject: [PATCH 3/3] test


---

diff --git a/plugins/hub/kiwi.py b/plugins/hub/kiwi.py
index 0814120..16ae016 100644
--- a/plugins/hub/kiwi.py
+++ b/plugins/hub/kiwi.py
@@ -23,7 +23,7 @@ def kiwiBuild(target, arches, desc_url, desc_path, optional_arches=None, profile
             kojihub.convert_value(i, cast=str, check_only=True)
     if repos:
         kojihub.convert_value(repos, cast=list, check_only=True)
-    kojihub.get_build_targets(target, strict=True)
+    kojihub.get_build_target(target, strict=True)
     arches = koji.parse_arches(arches, strict=True, allow_none=False)
     optional_arches = koji.parse_arches(optional_arches, strict=True, allow_none=True)
     taskOpts = {
diff --git a/tests/test_hub/test_convert_value.py b/tests/test_hub/test_convert_value.py
new file mode 100644
index 0000000..088bd89
--- /dev/null
+++ b/tests/test_hub/test_convert_value.py
@@ -0,0 +1,42 @@
+import unittest
+
+import koji
+from kojihub import convert_value
+
+
+class TestConvertValue(unittest.TestCase):
+    def test_convert_ok(self):
+        cases = (
+            (str, 'text'),
+            (int, 1),
+            (bool, True)
+        )
+        for cast, input in cases:
+            output = convert_value(input, cast=cast)
+            self.assertEqual(input, output)
+
+    def test_convert_fail(self):
+        with self.assertRaises(koji.ParameterError):
+            convert_value('asd', cast=int)
+
+    def test_convert_exc(self):
+        with self.assertRaises(IOError):
+            convert_value('asd', cast=int, exc_type=IOError)
+
+    def test_none(self):
+        with self.assertRaises(koji.ParameterError):
+            convert_value(None, cast=int)
+        value = convert_value(None, cast=int, none_allowed=True)
+        self.assertIsNone(value)
+
+    def test_message(self):
+        msg = 'test_message'
+        with self.assertRaises(koji.ParameterError) as ex:
+            convert_value(None, cast=int, message=msg)
+        self.assertEqual(str(ex.exception), msg)
+
+    def test_only(self):
+        "fail on otherwise valid conversions"
+        with self.assertRaises(koji.ParameterError):
+            convert_value(None, cast=bool, check_only=True)
+
diff --git a/tests/test_hub/test_getRPM.py b/tests/test_hub/test_getRPM.py
index 0f12ce6..d0749b9 100644
--- a/tests/test_hub/test_getRPM.py
+++ b/tests/test_hub/test_getRPM.py
@@ -57,10 +57,14 @@ class TestGetRPMHeaders(unittest.TestCase):
         self.get_header_fields.assert_not_called()
 
     def test_insufficient_args(self):
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(strict=False)
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(filepath='something')
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(strict=False)
+        self.assertEqual("either rpmID or taskID and filepath must be specified",
+                         str(cm.exception))
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(filepath='something')
+        self.assertEqual("either rpmID or taskID and filepath must be specified",
+                         str(cm.exception))
         # we already test taskid without filepath above
 
     def test_unknown_rpm(self):
@@ -71,50 +75,55 @@ class TestGetRPMHeaders(unittest.TestCase):
 
         # again with strict mode
         self.get_rpm.side_effect = koji.GenericError('NO SUCH RPM')
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        self.assertEqual("NO SUCH RPM", str(cm.exception))
         self.get_rpm.assert_called_with('FOO-1-1.noarch', strict=True)
         self.get_build.assert_not_called()
         self.get_header_fields.assert_not_called()
 
     def test_external_rpm(self):
-        self.get_rpm.return_value = {'external_repo_id': 1, 'id': 'RPMID'}
+        rpm_info = {'external_repo_id': 1, 'id': 'RPMID'}
+        self.get_rpm.return_value = rpm_info
         result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=False)
         self.assertEqual(result, {})
 
         # again with strict mode
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        self.assertEqual(f"External rpm: {rpm_info['id']}", str(cm.exception))
         self.get_build.assert_not_called()
         self.get_header_fields.assert_not_called()
 
     def test_deleted_build(self):
         self.get_rpm.return_value = {'build_id': 'BUILDID', 'external_repo_id': 0}
-        self.get_build.return_value = {'nvr': 'NVR', 'state': koji.BUILD_STATES['DELETED']}
+        build_info = {'nvr': 'NVR', 'state': koji.BUILD_STATES['DELETED']}
+        self.get_build.return_value = build_info
         result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=False)
         self.assertEqual(result, {})
         self.get_build.assert_called_with('BUILDID', strict=True)
 
         # again with strict mode
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        self.assertEqual(f"Build {build_info['nvr']} is deleted", str(cm.exception))
         self.get_build.assert_called_with('BUILDID', strict=True)
         self.get_header_fields.assert_not_called()
 
     def test_missing_rpm(self):
         self.get_rpm.return_value = {
-                'build_id': 'BUILDID',
-                'external_repo_id': 0,
-                'name': 'pkg',
-                'version': '1',
-                'release': '2',
-                'arch': 'noarch'}
+            'build_id': 'BUILDID',
+            'external_repo_id': 0,
+            'name': 'pkg',
+            'version': '1',
+            'release': '2',
+            'arch': 'noarch'}
         self.get_build.return_value = {
-                'name': 'pkg',
-                'version': '1',
-                'release': '2',
-                'nvr': 'pkg-1-2',
-                'state': koji.BUILD_STATES['COMPLETE']}
+            'name': 'pkg',
+            'version': '1',
+            'release': '2',
+            'nvr': 'pkg-1-2',
+            'state': koji.BUILD_STATES['COMPLETE']}
         rpmpath = '%s/packages/pkg/1/2/noarch/pkg-1-2.noarch.rpm' % self.tempdir
 
         # rpm does not exist
@@ -124,24 +133,25 @@ class TestGetRPMHeaders(unittest.TestCase):
 
         # again with strict mode
         with self.assertRaises(koji.GenericError) as e:
-            result = self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+            self.exports.getRPMHeaders(rpmID='FOO-1-1.noarch', strict=True)
+        self.assertEqual(f"Missing rpm file: {rpmpath}", str(e.exception))
         self.get_build.assert_called_with('BUILDID', strict=True)
         self.get_header_fields.assert_not_called()
 
     def test_rpm_exists(self):
         self.get_rpm.return_value = {
-                'build_id': 'BUILDID',
-                'external_repo_id': 0,
-                'name': 'pkg',
-                'version': '1',
-                'release': '2',
-                'arch': 'noarch'}
+            'build_id': 'BUILDID',
+            'external_repo_id': 0,
+            'name': 'pkg',
+            'version': '1',
+            'release': '2',
+            'arch': 'noarch'}
         self.get_build.return_value = {
-                'name': 'pkg',
-                'version': '1',
-                'release': '2',
-                'nvr': 'pkg-1-2',
-                'state': koji.BUILD_STATES['COMPLETE']}
+            'name': 'pkg',
+            'version': '1',
+            'release': '2',
+            'nvr': 'pkg-1-2',
+            'state': koji.BUILD_STATES['COMPLETE']}
         rpmpath = '%s/packages/pkg/1/2/noarch/pkg-1-2.noarch.rpm' % self.tempdir
         koji.ensuredir(os.path.dirname(rpmpath))
         with open(rpmpath, 'w') as fo:
@@ -178,9 +188,9 @@ class TestGetRPMHeaders(unittest.TestCase):
         result = self.exports.getRPMHeaders(taskID=taskid, filepath=filepath, strict=False)
         self.assertEqual(result, {})
 
-        with self.assertRaises(koji.GenericError):
-            result = self.exports.getRPMHeaders(taskID=taskid, filepath=filepath, strict=True)
-
+        with self.assertRaises(koji.GenericError) as cm:
+            self.exports.getRPMHeaders(taskID=taskid, filepath=filepath, strict=True)
+        self.assertEqual(f"Missing rpm file: {rpmpath}", str(cm.exception))
         self.get_rpm.assert_not_called()
         self.get_build.assert_not_called()
         self.get_header_fields.assert_not_called()

