mozreview: respect the repository's configured ldap group for landing permissions (bug 1345927) r=mars draft
authorPiotr Zalewa <pzalewa@mozilla.com>
Wed, 15 Mar 2017 05:04:20 +0100
changeset 10541 087f21e58a1efa3cd41325baf9aa9cee61283e5f
parent 10485 06154c69e174a0d5d6140951fc5011ae9b49e79b
push id1589
push userbmo:pzalewa@mozilla.com
push dateFri, 24 Mar 2017 18:02:56 +0000
reviewersmars
bugs1345927
mozreview: respect the repository's configured ldap group for landing permissions (bug 1345927) r=mars MozReview forces user to have SCM Level 3 permission to be able to land any repository. Since we've got a `required_ldap_group` stored in repository `extra_data`, we should use it. Permissions to land is driven entirely by group name in Repository extra_data and LDAP response. The `webapi_scm_groups_required` decorator has an optional `force_group` argument. Repository `required_ldap_group` is read if called without arguments. If repository has no required group specified the default group "scm_level_3" is used. Autoland itself is not checking if user has enough privileges. All changes are applied in MozReview. Unit tests are written to all methods created or modified. Since LDAP and Autoland is tested separately no integration test is provided. MozReview-Commit-ID: DRe6Gm3nD7L
docs/mozreview/install.rst
pylib/mozreview/mozreview/autoland/resources.py
pylib/mozreview/mozreview/decorators.py
pylib/mozreview/mozreview/hooks.py
pylib/mozreview/mozreview/review_helpers.py
pylib/mozreview/mozreview/static/mozreview/js/autoland.js
pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
pylib/mozreview/mozreview/templates/mozreview/user-data.html
pylib/mozreview/mozreview/templatetags/mozreview.py
pylib/mozreview/mozreview/tests/helpers.py
pylib/mozreview/mozreview/tests/test-autoland-post-job.py
pylib/mozreview/mozreview/tests/test_commit_rewrite.py
--- a/docs/mozreview/install.rst
+++ b/docs/mozreview/install.rst
@@ -39,18 +39,18 @@ are documented at
 Benefits of Having an LDAP Account
 ----------------------------------
 
 Having an LDAP account associated with MozReview grants the following
 additional privileges:
 
 * Ability to trigger *Try* jobs from MozReview
 * Ability to land commits from the MozReview
-* Reviews from people with level 3 will enable *Ship Its* (r+) to be
-  carried forward.
+* Reviews from people with required SCM level (3 as default) will enable 
+  *Ship Its* (r+) to be carried forward.
 
 .. important::
 
    Non-casual contributors are strongly encouraged to obtain and configure
    an LDAP account. This includes reviewers.
 
 Updating SSH Config
 -------------------
--- a/pylib/mozreview/mozreview/autoland/resources.py
+++ b/pylib/mozreview/mozreview/autoland/resources.py
@@ -121,17 +121,17 @@ class BaseAutolandTriggerResource(WebAPI
 class AutolandTriggerResource(BaseAutolandTriggerResource):
     """Resource to kick off Autoland to inbound for a review request."""
 
     name = 'autoland_trigger'
 
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
                             NOT_LOGGED_IN)
-    @webapi_scm_groups_required('scm_level_3')
+    @webapi_scm_groups_required()
     @webapi_request_fields(
         required={
             'review_request_id': {
                 'type': int,
                 'description': 'The review request for which to trigger a Try '
                                'build',
             },
             'commit_descriptions': {
--- a/pylib/mozreview/mozreview/decorators.py
+++ b/pylib/mozreview/mozreview/decorators.py
@@ -2,16 +2,23 @@ from djblets.util.decorators import simp
 from djblets.webapi.decorators import (_find_httprequest,
                                        webapi_decorator,
                                        webapi_login_required,
                                        webapi_response_errors)
 from djblets.webapi.errors import PERMISSION_DENIED
 
 import logging
 
+from reviewboard.reviews.models import ReviewRequest
+
+from mozreview.review_helpers import (
+    DEFAULT_REQUIRED_LDAP_GROUP,
+    get_required_ldap_group,
+)
+
 
 logger = logging.getLogger(__name__)
 
 
 @simple_decorator
 def if_ext_enabled(fn):
     """Only execute the function if the extension is enabled.
 
@@ -32,48 +39,58 @@ def if_ext_enabled(fn):
         if not ext.get_settings('enabled', False):
             return
 
         return fn(*args, **kwargs)
 
     return _wrapped
 
 
-def webapi_scm_groups_required(*groups):
+def _get_required_ldap_group(request):
+    """Get ReviewRequest id and return the repository required LDAP group."""
+    rr_id = request.POST.get('review_request_id', None)
+    if rr_id and rr_id[0]:
+        rr = ReviewRequest.objects.get(pk=rr_id[0])
+        return get_required_ldap_group(rr.repository)
+    return DEFAULT_REQUIRED_LDAP_GROUP
+
+
+def webapi_scm_groups_required(force_group=None):
     """Checks that a user has required scm ldap groups."""
     @webapi_decorator
     def _dec(view_func):
 
         @webapi_login_required
         @webapi_response_errors(PERMISSION_DENIED)
         def _check_groups(*args, **kwargs):
             request = _find_httprequest(args)
+            group = force_group or _get_required_ldap_group(request)
+
             mrp = request.mozreview_profile
 
             if not mrp:
                 # This should never happen since webapi_login_required
                 # should mean they are authenticated and our middleware
                 # has added the profile to the request. Check it just
                 # to make sure nothing went wrong with the middleware.
-                logger.error('No MozReviewUserProfile for authenticated user: %s',
-                             request.user.id)
+                logger.error('No MozReviewUserProfile for authenticated '
+                             'user: %s', request.user.id)
                 return PERMISSION_DENIED
 
             if not mrp.ldap_username:
                 logger.info('No ldap_username for user: %s when '
                             'attempting to access protected resource',
                             request.user.id)
                 return PERMISSION_DENIED.with_message(
                     'You are not associated with an ldap account')
 
-            for group in groups:
-                if not mrp.has_scm_ldap_group(group):
-                    logger.info('Missing group membership for user: %s '
-                                'when attempting to access protected '
-                                'resource', request.user.id)
-                    return PERMISSION_DENIED.with_message(
-                        'You do not have the required ldap permissions')
+            if not mrp.has_scm_ldap_group(group):
+                logger.info('Missing group membership for user: %s '
+                            'when attempting to access protected '
+                            'resource', request.user.id)
+                return PERMISSION_DENIED.with_message(
+                    'You do not have the required ldap permissions')
 
             return view_func(*args, **kwargs)
 
         return _check_groups
 
     return _dec
--- a/pylib/mozreview/mozreview/hooks.py
+++ b/pylib/mozreview/mozreview/hooks.py
@@ -1,52 +1,51 @@
 from __future__ import unicode_literals
 
 import logging
 
-from django.template.loader import Context
-from django.utils.translation import ugettext as _
-
 from reviewboard.extensions.hooks import (
     ReviewRequestApprovalHook,
-    ReviewRequestFieldsHook,
     TemplateHook
 )
 
 from mozreview.extra_data import (
     COMMIT_ID_KEY,
     fetch_commit_data,
     gen_child_rrs,
-    get_parent_rr,
     is_parent,
     is_pushed,
 )
 from mozreview.models import (
     get_profile,
 )
 from mozreview.review_helpers import (
+    get_required_ldap_group,
     has_valid_shipit,
-    has_l3_shipit,
+    has_level_shipit,
 )
 from mozreview.template_helpers import get_commit_table_context
 
 
 logger = logging.getLogger(__name__)
 
+
 class CommitContextTemplateHook(TemplateHook):
     """Gathers all information required for commits table
 
     This hook allows us to generate a detailed, custom commits table.
     Information provided includes the parent and child review requests,
     as well as autoland information.
     """
 
     def get_extra_context(self, request, context):
         """Fetches relevant review request information, returns context"""
-        return get_commit_table_context(request, context['review_request_details'])
+        return get_commit_table_context(request,
+                                        context['review_request_details'])
+
 
 class MozReviewApprovalHook(ReviewRequestApprovalHook):
     """Calculates landing approval for review requests.
 
     This hook allows us to control the `approved` and `approval_failure`
     fields on review request model instances, and Web API results
     associated with them. By calculating landing approval and returning
     it here we have a nice way to distribute this decision throughout
@@ -120,20 +119,21 @@ class MozReviewApprovalHook(ReviewReques
 
         # TODO: Add a check that we have executed a try build of some kind.
 
         author_mrp = get_profile(review_request.submitter)
 
         # TODO: Make these "has_..." methods return the set of reviews
         # which match the criteria so we can indicate which reviews
         # actually gave the permission to land.
-        if author_mrp.has_scm_ldap_group('scm_level_3'):
-            # In the case of a level 3 user we really only care that they've
-            # received a single ship-it, which is still current, from any
-            # user. If they need to wait for reviews from other people
-            # before landing we trust them to wait.
+        required_group = get_required_ldap_group(review_request.repository)
+        if author_mrp.has_scm_ldap_group(required_group):
+            # In the case of a user with the right SCM LDAP group we really
+            # only care that they've received a single ship-it, which is
+            # still current, from any user. If they need to wait for reviews
+            # from other people before landing we trust them to wait.
             if not has_valid_shipit(review_request):
                 return False, 'A suitable reviewer has not given a "Ship It!"'
         else:
-            if not has_l3_shipit(review_request):
+            if not has_level_shipit(review_request, required_group):
                 return False, 'A suitable reviewer has not given a "Ship It!"'
 
         return True
--- a/pylib/mozreview/mozreview/review_helpers.py
+++ b/pylib/mozreview/mozreview/review_helpers.py
@@ -5,16 +5,24 @@
 from __future__ import absolute_import, unicode_literals
 
 from reviewboard.reviews.models import ReviewRequestDraft
 
 from mozreview.extra_data import REVIEW_FLAG_KEY
 from mozreview.models import get_profile
 
 
+DEFAULT_REQUIRED_LDAP_GROUP = 'scm_level_3'
+
+
+def get_required_ldap_group(repository):
+    return repository.extra_data.get('required_ldap_group',
+                                     DEFAULT_REQUIRED_LDAP_GROUP)
+
+
 def gen_latest_reviews(review_request):
     """Generate a series of relevant reviews.
 
     Generates the set of reviews for a review request where there is
     only a single review for each user and it is that users most
     recent review.
     """
     last_user = None
@@ -43,22 +51,23 @@ def has_valid_shipit(review_request):
         # TODO: Should we require that the ship-it comes from a review
         # which the review request submitter didn't create themselves?
         if review.ship_it:
             return True
 
     return False
 
 
-def has_l3_shipit(review_request):
-    """Return whether the review request has received a current L3 ship it.
+def has_level_shipit(review_request, group):
+    """Return whether the review request has received the right level ship it.
 
     A boolean will be returned indicating if the review request has received
-    a ship-it from an L3 user that is still valid. In order to be valid the
-    ship-it must have been provided after the latest diff has been uploaded.
+    a ship-it from a user with the required level group that is still valid.
+    In order to be valid the ship-it must have been provided after the
+    latest diff has been uploaded.
     """
     diffset_history = review_request.diffset_history
 
     if not diffset_history:
         # There aren't any published diffs so we should just consider
         # any ship-its meaningless.
         return False
 
@@ -66,17 +75,17 @@ def has_l3_shipit(review_request):
         # Although I'm not certain when this field will be empty
         # it has "blank=true, null=True" - we'll assume there is
         # no published diff.
         return False
 
     for review in gen_latest_reviews(review_request):
         if not review.ship_it:
             continue
-        if get_profile(review.user).has_scm_ldap_group('scm_level_3'):
+        if get_profile(review.user).has_scm_ldap_group(group):
             return True
 
     return False
 
 
 def get_reviewers_status(review_request, reviewers=None,
                          include_drive_by=False):
     """Returns the latest review status for each reviewer.
--- a/pylib/mozreview/mozreview/static/mozreview/js/autoland.js
+++ b/pylib/mozreview/mozreview/static/mozreview/js/autoland.js
@@ -32,18 +32,18 @@
       )
       .show();
   }
 
   if (!MozReview.autolandingToTryEnabled) {
     try_trigger.attr('title', 'Try builds cannot be triggered for this repository');
   } else if ($("#draft-banner").is(":visible")) {
     try_trigger.attr('title', 'Try builds cannot be triggered on draft review requests');
-  } else if (!MozReview.hasScmLevel1) {
-    try_trigger.attr('title', 'You do not have the required scm level to trigger a try build');
+  } else if (!MozReview.canTriggerTryBuild) {
+    try_trigger.attr('title', 'You do not have the required permision to trigger a try build');
   } else if (!MozReview.reviewRequestPending) {
     try_trigger.attr('title', 'You can not trigger a try build on a closed review request');
   } else {
     try_trigger.css('opacity', '1');
 
     $("#autoland-try-trigger").click(function() {
       var box = $("<div/>")
           .addClass("formdlg")
@@ -290,18 +290,19 @@
       autoland_trigger.click(autoland_confirm);
     });
   }
 
   if (!MozReview.autolandingEnabled) {
     autoland_trigger.attr('title', 'Landing is not supported for this repository');
   } else if ($("#draft-banner").is(":visible")) {
     autoland_trigger.attr('title', 'Draft review requests cannot be landed');
-  } else if (!MozReview.hasScmLevel3) {
-    autoland_trigger.attr('title', 'You must have scm_level_3 access to land');
+  } else if (!MozReview.canAutoland) {
+    autoland_trigger.attr('title',
+      'You must have ' + MozReview.requiredLevel + ' access to land');
   } else if (!MozReview.reviewRequestPending) {
     autoland_trigger.attr('title', 'You can not autoland from a closed review request');
   } else {
     MozReview.parentReviewRequest.ready({
       error: function() {
         autoland_trigger.attr('title', 'Error determining approval');
       },
       ready: function() {
--- a/pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
+++ b/pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
@@ -1,28 +1,28 @@
 var MozReview = {};
 
 $(document).ready(function() {
   // The back-end should have already supplied us with the parent review
   // request ID (whether or not we're already looking at it), and set it as
   // the parent-review-id attribute on the mozreview-data element. Let's get
   // that first - because if we can't get it, we're stuck.
   MozReview.parentID = $("#mozreview-data").data("parent-review-id");
+  MozReview.requiredLevel = $("#repository").data("required-ldap-group");
 
   if (!MozReview.parentID) {
     console.error("Could not find a valid id for the parent review " +
                   "request.");
     return;
   }
 
   // Load injected user data>
   var $userData = $("#user_data");
-  MozReview.scmLevel = $userData.data("scm-level");
-  MozReview.hasScmLevel1 = MozReview.scmLevel >= 1;
-  MozReview.hasScmLevel3 = MozReview.scmLevel == 3;
+  MozReview.canTriggerTryBuild = !!$userData.data('can-trigger-try-build');
+  MozReview.canAutoland = !!$userData.data('can-autoland');
   MozReview.isSubmitter = !!$userData.data("is-submitter");
 
   // Whether or not the repository has associated try and landing repositories
   // is in an invisible div.
   MozReview.autolandingToTryEnabled = $("#repository").data("autolanding-to-try-enabled");
   MozReview.autolandingEnabled = $("#repository").data("autolanding-enabled");
   MozReview.landingRepository = $("#repository").data("landing-repository");
 
--- a/pylib/mozreview/mozreview/templates/mozreview/user-data.html
+++ b/pylib/mozreview/mozreview/templates/mozreview/user-data.html
@@ -1,10 +1,11 @@
 {% load mozreview %}
 <div id="user_data"
-  data-scm-level="{{ request.mozreview_profile|scm_level }}"
   {% if request.user.is_authenticated and review_request %}
     data-last-reviewed-revision="{{ review_request|data_reviewed_revision:request.user }}"
     {% if review_request.submitter.id == request.user.id %}
       data-is-submitter="true"
     {% endif %}
+	data-can-trigger-try-build="{{ request.mozreview_profile|can_trigger_try_build }}"
+    data-can-autoland="{{ request.mozreview_profile|can_autoland:review_request.repository }}"
   {% endif %}
 ></div>
--- a/pylib/mozreview/mozreview/templatetags/mozreview.py
+++ b/pylib/mozreview/mozreview/templatetags/mozreview.py
@@ -1,32 +1,35 @@
 from __future__ import absolute_import
 
 from django import template
 from django.contrib.auth.models import User
 from django.utils.safestring import SafeString
 
+from reviewboard.reviews.models import (
+    ReviewRequestDraft,
+)
+
 from mozreview.diffs import (
     latest_revision_reviewed,
 )
 from mozreview.diffviewer import (
     get_diffstats,
 )
 from mozreview.extra_data import (
     COMMIT_ID_KEY,
     COMMIT_MSG_FILEDIFF_IDS_KEY,
     fetch_commit_data,
     is_parent,
     is_pushed,
     REVIEW_FLAG_KEY,
 )
-from mozreview.review_helpers import get_reviewers_status
-
-from reviewboard.reviews.models import (
-    ReviewRequestDraft,
+from mozreview.review_helpers import (
+    get_reviewers_status,
+    get_required_ldap_group,
 )
 
 
 register = template.Library()
 
 
 @register.filter()
 def isSquashed(review_request):
@@ -62,54 +65,50 @@ def reviewer_list(review_request):
     return ', '.join([user.username
                       for user in review_request.target_people.all()])
 
 
 @register.filter()
 def extra_data(review_request, key):
     return review_request.extra_data[key]
 
+
 @register.filter()
 def review_flag(review):
     flag = review.extra_data[REVIEW_FLAG_KEY]
     if flag == ' ':
         return 'Review flag cleared'
 
     return 'Review flag: %s' % flag
 
-@register.filter()
-def scm_level(mozreview_profile):
-    if mozreview_profile is None:
-        return ''
-    elif mozreview_profile.has_scm_ldap_group('scm_level_3'):
-        return '3'
-    elif mozreview_profile.has_scm_ldap_group('scm_level_2'):
-        return '2'
-    elif mozreview_profile.has_scm_ldap_group('scm_level_1'):
-        return '1'
-    else:
-        return ''
-
 
 @register.filter()
 def data_reviewed_revision(review_request, user):
     """Return the latest diff revision a user reviewed.
 
     `0`, a revision number which will never exist, is returned
     if the user has not performed a review.
     """
     return latest_revision_reviewed(review_request, user=user) or 0
 
 
 @register.filter()
 def required_ldap_group(repository):
-    try:
-        return repository.extra_data['required_ldap_group']
-    except (AttributeError, KeyError):
-        return ''
+    return get_required_ldap_group(repository)
+
+
+@register.filter()
+def can_trigger_try_build(mozreview_profile):
+    return 1 if mozreview_profile.has_scm_ldap_group('scm_level_1') else ''
+
+
+@register.filter()
+def can_autoland(mr_profile, repository):
+    required_ldap_group = get_required_ldap_group(repository)
+    return 1 if mr_profile.has_scm_ldap_group(required_ldap_group) else ''
 
 
 @register.filter()
 def autolanding_to_try_enabled(repository):
     try:
         return ('true' if repository.extra_data['autolanding_to_try_enabled']
                 else 'false')
     except (AttributeError, KeyError):
@@ -127,23 +126,25 @@ def autolanding_enabled(repository):
 
 @register.filter()
 def landing_repository(repository):
     try:
         return repository.extra_data['landing_repository_url']
     except (AttributeError, KeyError):
         return ''
 
+
 @register.filter()
 def trychooser_url(repository):
     try:
         return repository.extra_data['trychooser_url']
     except (AttributeError, KeyError):
         return ''
 
+
 @register.filter()
 def treeherder_repo(landing_url):
     mapping = {
         'try': 'try',
         'ssh://hg.mozilla.org/try': 'try',
         'ssh://hg.mozilla.org/projects/cedar': 'cedar',
         'ssh://hg.mozilla.org/integration/mozilla-inbound': 'mozilla-inbound',
     }
--- a/pylib/mozreview/mozreview/tests/helpers.py
+++ b/pylib/mozreview/mozreview/tests/helpers.py
@@ -1,33 +1,142 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import factory
+import json
+
 from django.contrib.auth.models import User, Permission
+from django.db.models import signals
+
+from reviewboard.diffviewer.models import DiffSetHistory
+from reviewboard.scmtools.models import Repository
+from reviewboard.reviews import models as rbmodels
+
+from mozreview import models as mrmodels
+from mozreview.extra_data import COMMITS_KEY
 
 
 class BaseFactory(factory.django.DjangoModelFactory):
     # Django models are compared for equivalence using the object.id
     # attribute, not by comparing the value of id(object).  If we don't
     # include an id/pk field, then it defaults to 'None' for these unsaved DB
     # objects.  That in-turn causes the Django model comparison to
     # incorrectly return 'True' when comparing unsaved DB objects with '=='.
     id = factory.Sequence(int)
 
 
+
 class UserFactory(BaseFactory):
     class Meta:
         model = User
         strategy = factory.BUILD_STRATEGY
 
     username = factory.Faker('word')
 
     class Params:
         permissions = []
 
     @factory.post_generation
     def permissions(user, create, extracted, **kwargs):
         perms_list = extracted
         for p in perms_list:
             perm = Permission.objects.get(codename=p)
-            user.user_permissions.add(perm)
\ No newline at end of file
+            user.user_permissions.add(perm)
+
+
+class MozReviewUserProfileFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = mrmodels.MozReviewUserProfile
+        strategy = factory.BUILD_STRATEGY
+
+    user = factory.SubFactory(UserFactory)
+    ldap_username = factory.Faker('email')
+
+
+class DiffSetHistoryFactory(BaseFactory):
+    class Meta:
+        model = DiffSetHistory
+        strategy = factory.BUILD_STRATEGY
+
+    last_diff_updated = True
+
+
+@factory.django.mute_signals(signals.post_init)
+class RepositoryFactory(BaseFactory):
+    class Meta:
+        model = Repository
+        strategy = factory.BUILD_STRATEGY
+
+    extra_data = {}
+
+
+# Reviewboard's post-review-creation signal hooks try to touch the database,
+# so we have to switch them off
+@factory.django.mute_signals(signals.post_init)
+class ReviewRequestFactory(BaseFactory):
+    # Review Requests are made up of a summary/parent/squashed review request
+    # and a bunch of child review requests.
+    # The "parent" request is squashed and aggregated.
+    # The "child" requests are for individual commits.  They have associated
+    # reviews and reviewers.
+    class Meta:
+        model = rbmodels.ReviewRequest
+        strategy = factory.BUILD_STRATEGY
+
+    id = factory.Sequence(int)
+    submitter = factory.SubFactory(UserFactory)
+    description = 'foo'
+    # This object is created automatically by the custom Manager for
+    # ReviewRequest.objects.create()
+    diffset_history = factory.SubFactory(DiffSetHistoryFactory)
+    repository = factory.SubFactory(RepositoryFactory)
+
+    @factory.post_generation
+    def approved(review_request, create, extracted, **kwargs):
+        # The `.approved` property is a complex lazy calculation on
+        # ReviewRequest objects.  We'll short-circuit it here.
+
+        if extracted is None:
+            # No bool provided by caller, set a default value
+            extracted = True
+
+        review_request._approved = extracted
+
+
+class ReviewFactory(BaseFactory):
+    class Meta:
+        model = rbmodels.Review
+        strategy = factory.BUILD_STRATEGY
+
+    public = True
+    user = factory.SubFactory(UserFactory)
+    review_request = factory.SubFactory(ReviewRequestFactory)
+    ship_it = False
+
+
+class CommitDataFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = mrmodels.CommitData
+        strategy = factory.BUILD_STRATEGY
+
+    extra_data = factory.LazyFunction(dict)
+
+    @factory.post_generation
+    def reviews(commit_data, create, extracted, **kwargs):
+        # extra_data is a complex JSON field, serialized and de-serialized at
+        # database read/write time.  We'll streamline setting the field's value
+        # here.
+        # To build the list the user has to pass a special form:
+        #   [parent-review [child-review-1, child-review-2, ...]]
+        if not extracted:
+            return
+        parent_review, child_reviews = extracted
+
+        # The COMMITS_KEY field is JSON-encoded as a list of lists, like so:
+        # [['commitid1', db-obj-id-for-commit-1],
+        #  ['commitid2', db-obj-id-for-commit-2], ...]
+        reviews = []
+        for i, child_request in enumerate(child_reviews):
+            commit_id = 'commitidforrequestnumber' + str(child_request.id)
+            reviews.append([commit_id, child_request.id])
+        commit_data.set_for(parent_review, COMMITS_KEY, json.dumps(reviews))
new file mode 100644
--- /dev/null
+++ b/pylib/mozreview/mozreview/tests/test-autoland-post-job.py
@@ -0,0 +1,198 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from mock import patch, Mock
+
+from django.http import HttpRequest
+
+from reviewboard.reviews import models as rbmodels
+
+from mozreview import models as mrmodels
+from mozreview.decorators import webapi_scm_groups_required
+from mozreview.hooks import MozReviewApprovalHook
+from mozreview.review_helpers import has_level_shipit
+from mozreview.tests.helpers import (
+    MozReviewUserProfileFactory,
+    RepositoryFactory,
+    ReviewRequestFactory,
+    ReviewFactory,
+    UserFactory,
+)
+
+
+# Calling `__init__` from MozReviewApprovalHook from factoryboy was not easy.
+# We're testing only its `is_approved_child` and it's not using `self`.
+# Overloading `__init__`.
+class FakeApprovalHook(MozReviewApprovalHook):
+    def __init__(self):
+        pass
+
+
+class TestApprovalHook:
+    def setup(self):
+        self.repository = RepositoryFactory()
+        self.review_request = ReviewRequestFactory(repository=self.repository,
+                                                   shipit_count=1)
+        self.user = UserFactory()
+        self.profile = MozReviewUserProfileFactory(user=self.user)
+        self.review = ReviewFactory(user=self.user,
+                                    review_request=self.review_request,
+                                    ship_it=True)
+        patcher_get_or_create = patch.object(
+            mrmodels.MozReviewUserProfile.objects, 'get_or_create')
+        self.mock_profile_get_or_create = patcher_get_or_create.start()
+        self.mock_profile_get_or_create.return_value = [self.profile]
+
+        patcher_gen_latest_reviews = patch(
+            'mozreview.review_helpers.gen_latest_reviews')
+        self.mock_gen_latest_reviews = patcher_gen_latest_reviews.start()
+        self.mock_gen_latest_reviews.return_value = [self.review]
+
+        patcher_has_scm_group = patch.object(
+            mrmodels.MozReviewUserProfile, 'has_scm_ldap_group')
+        self.mock_profile_has_scm_ldap_group = patcher_has_scm_group.start()
+
+        self.patchers = [
+            patcher_gen_latest_reviews,
+            patcher_get_or_create,
+            patcher_has_scm_group]
+
+    def teardown(self):
+        [patcher.stop() for patcher in self.patchers]
+
+    def test_reviewer_not_leveled_enough(self):
+        self.mock_profile_has_scm_ldap_group.return_value = False
+        assert not has_level_shipit(self.review_request, 'any_scm_level')
+
+    def test_reviewer_leveled_enough(self):
+        self.mock_profile_has_scm_ldap_group.return_value = True
+        assert has_level_shipit(self.review_request, 'any_scm_level')
+
+    @patch('mozreview.hooks.has_level_shipit')
+    def test_if_the_right_level_called(self, mock_has_level_shipit):
+        self.mock_profile_has_scm_ldap_group.return_value = False
+
+        self.repository.extra_data = {'required_ldap_group': 'some_scm_level'}
+
+        hook = FakeApprovalHook()
+        hook.is_approved_child(self.review_request)
+        mock_has_level_shipit.assert_called_once_with(self.review_request,
+                                                      'some_scm_level')
+
+    @patch('mozreview.hooks.has_level_shipit')
+    def test_is_approved_child_reviewer(self, mock_has_level_shipit):
+        # Creator of the ReviewRequest has not enough privileges
+        self.mock_profile_has_scm_ldap_group.return_value = False
+        # Reviewer has enough privileges
+        mock_has_level_shipit.return_value = True
+
+        hook = FakeApprovalHook()
+        result = hook.is_approved_child(self.review_request)
+        assert isinstance(result, bool)
+        assert result
+
+    def test_is_approved_child_requester(self):
+        # Creator of ReviewRequest has the right SCM level
+        self.mock_profile_has_scm_ldap_group.return_value = True
+
+        hook = FakeApprovalHook()
+        result = hook.is_approved_child(self.review_request)
+        assert isinstance(result, bool)
+        assert result
+
+    def test_is_approved_no_shipit(self):
+        self.review.ship_it = False
+        # Creator of ReviewRequest has the right SCM level
+        self.mock_profile_has_scm_ldap_group.return_value = True
+
+        hook = FakeApprovalHook()
+        result = hook.is_approved_child(self.review_request)
+        assert isinstance(result, tuple)
+        assert not result[0]
+
+    def test_is_approved_child_noone(self):
+        # this mock is called for both requester and reviewer
+        self.mock_profile_has_scm_ldap_group.return_value = False
+
+        hook = FakeApprovalHook()
+        result = hook.is_approved_child(self.review_request)
+        assert isinstance(result, tuple)
+        assert not result[0]
+
+
+# The `webapi_scm_groups_required` decorator is using HttpRequest get the
+# `mozreview_profile` and `user` (added in the middleware).
+# Adding POST - it is used to pass ReviewRequest's id
+class FakeHttpRequest(HttpRequest):
+    POST = {}  # {'review_request_id': ['3']}
+    mozreview_profile = None
+    user = None
+
+    def _set_review_request_id(self, value):
+        self.POST['review_request_id'] = [str(value)]
+
+
+class TestSCMRequiredDecorator:
+    def setup(self):
+        self.repository = RepositoryFactory()
+        self.review_request = ReviewRequestFactory(repository=self.repository,
+                                                   shipit_count=1)
+        self.user = UserFactory()
+        self.profile = MozReviewUserProfileFactory(user=self.user)
+        self.review = ReviewFactory(user=self.user,
+                                    review_request=self.review_request,
+                                    ship_it=True)
+        self.request = FakeHttpRequest()
+        self.request.mozreview_profile = self.profile
+        self.request.user = self.user
+
+        self.patcher = patch.object(
+            mrmodels.MozReviewUserProfile, 'has_scm_ldap_group')
+        self.mock_profile_has_scm_ldap_group = self.patcher.start()
+
+    def teardown(self):
+        self.patcher.stop()
+
+    def test_view_called(self):
+        self.mock_profile_has_scm_ldap_group.return_value = True
+        func = Mock()
+
+        @webapi_scm_groups_required()
+        def view_func(request):
+            func()
+
+        view_func(self.request)
+        # Check if decorator passed through without any issues
+        func.assert_any_call()
+        # Is the default group used to check for privileges
+        self.mock_profile_has_scm_ldap_group.assert_called_once_with(
+            'scm_level_3')
+
+    @patch.object(rbmodels.ReviewRequest.objects, 'get')
+    def test_right_level_detected(self, mock_review_request_get):
+        mock_review_request_get.return_value = self.review_request
+        self.repository.extra_data = {'required_ldap_group': 'some_scm_level'}
+        self.request._set_review_request_id(self.review_request.id)
+
+        @webapi_scm_groups_required()
+        def view_func(request):
+            pass
+
+        view_func(self.request)
+        self.mock_profile_has_scm_ldap_group.assert_called_once_with(
+            'some_scm_level')
+
+    @patch.object(rbmodels.ReviewRequest.objects, 'get')
+    def test_forced_level_detected(self, mock_review_request_get):
+        mock_review_request_get.return_value = self.review_request
+        self.repository.extra_data = {'required_ldap_group': 'some_scm_level'}
+        self.request._set_review_request_id(self.review_request.id)
+
+        @webapi_scm_groups_required('forced_scm_level')
+        def view_func(request):
+            pass
+
+        view_func(self.request)
+        self.mock_profile_has_scm_ldap_group.assert_called_once_with(
+            'forced_scm_level')
--- a/pylib/mozreview/mozreview/tests/test_commit_rewrite.py
+++ b/pylib/mozreview/mozreview/tests/test_commit_rewrite.py
@@ -1,109 +1,33 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-import json
 import unittest
 
-import factory
-from django.db.models import signals
 from django.test import RequestFactory as DjangoRequestFactory
 from djblets.webapi import errors as rberrors
 from mock import patch
-from reviewboard.diffviewer.models import DiffSetHistory
 from reviewboard.reviews import models as rbmodels
 
-from mozreview import errors as mrerrors, models as mrmodels
-from mozreview.extra_data import COMMITS_KEY
+from mozreview import errors as mrerrors
 from mozreview.resources.commit_rewrite import CommitRewriteResource
-from mozreview.tests.helpers import BaseFactory, UserFactory
-
-
-class DiffSetHistoryFactory(BaseFactory):
-    class Meta:
-        model = DiffSetHistory
-        strategy = factory.BUILD_STRATEGY
-
-
-# Reviewboard's post-review-creation signal hooks try to touch the database,
-# so we have to switch them off
-@factory.django.mute_signals(signals.post_init)
-class ReviewRequestFactory(BaseFactory):
-    # Review Requests are made up of a summary/parent/squashed review request
-    # and a bunch of child review requests.
-    # The "parent" request is squashed and aggregated.
-    # The "child" requests are for individual commits.  They have associated
-    # reviews and reviewers.
-    class Meta:
-        model = rbmodels.ReviewRequest
-        strategy = factory.BUILD_STRATEGY
-
-    id = factory.Sequence(int)
-    submitter = factory.SubFactory(UserFactory)
-    description = 'foo'
-    # This object is created automatically by the custom Manager for
-    # ReviewRequest.objects.create()
-    diffset_history = factory.SubFactory(DiffSetHistoryFactory)
-
-    @factory.post_generation
-    def approved(review_request, create, extracted, **kwargs):
-        # The `.approved` property is a complex lazy calculation on
-        # ReviewRequest objects.  We'll short-circuit it here.
-
-        if extracted is None:
-            # No bool provided by caller, set a default value
-            extracted = True
-
-        review_request._approved = extracted
-
-
-class ReviewFactory(BaseFactory):
-    class Meta:
-        model = rbmodels.Review
-        strategy = factory.BUILD_STRATEGY
-
-    public = True
-    user = factory.SubFactory(UserFactory)
-    review_request = factory.SubFactory(ReviewRequestFactory)
-
-
-class CommitDataFactory(factory.django.DjangoModelFactory):
-    class Meta:
-        model = mrmodels.CommitData
-        strategy = factory.BUILD_STRATEGY
-
-    extra_data = factory.LazyFunction(dict)
-
-    @factory.post_generation
-    def reviews(commit_data, create, extracted, **kwargs):
-        # extra_data is a complex JSON field, serialized and de-serialized at
-        # database read/write time.  We'll streamline setting the field's value
-        # here.
-        # To build the list the user has to pass a special form:
-        #   [parent-review [child-review-1, child-review-2, ...]]
-        if not extracted:
-            return
-        parent_review, child_reviews = extracted
-
-        # The COMMITS_KEY field is JSON-encoded as a list of lists, like so:
-        # [['commitid1', db-obj-id-for-commit-1],
-        #  ['commitid2', db-obj-id-for-commit-2], ...]
-        reviews = []
-        for i, child_request in enumerate(child_reviews):
-            commit_id = 'commitidforrequestnumber' + str(child_request.id)
-            reviews.append([commit_id, child_request.id])
-        commit_data.set_for(parent_review, COMMITS_KEY, json.dumps(reviews))
+from mozreview.tests.helpers import (
+    CommitDataFactory,
+    ReviewFactory,
+    ReviewRequestFactory,
+    UserFactory,
+)
 
 
 def fake_get(gettable_objects_list):
     """A fake Model.objects.get() function.  Takes a list of fake objects.
-    Returns a function that, when given the 'id=' keyword argument, returns that
-    key from the dict.
+    Returns a function that, when given the 'id=' keyword argument, returns
+    that key from the dict.
     """
     fake_ids_dict = dict((obj.id, obj) for obj in gettable_objects_list)
 
     def _fake_get(**kwargs):
         return fake_ids_dict[kwargs.get('id')]
 
     return _fake_get
 
@@ -119,18 +43,19 @@ class APICallValidationTest(unittest.Tes
 
     @patch.object(rbmodels.ReviewRequest, 'objects')
     @patch('mozreview.resources.commit_rewrite.get_parent_rr')
     def test_review_has_no_parents_returns_does_not_exist(self,
                                                           mock_get_parent_rr,
                                                           mock_get):
         # FIXME this test name appears to be wrong?  The first set
         # of code in CommitRewriteResource.get() asserts that the review is
-        # a child commit.  The *second* assertion in CommitRewriteResource.get()
-        # checks that the review is also a parent of another review?
+        # a child commit.  The *second* assertion in
+        # CommitRewriteResource.get() checks that the review is also a parent
+        # of another review?
         review = ReviewRequestFactory()
 
         # Return our fake review from the database
         mock_get.get = fake_get([review])
 
         # Return false from checking that our review has a parent.
         mock_get_parent_rr.return_value = None
 
@@ -140,17 +65,18 @@ class APICallValidationTest(unittest.Tes
         self.assertEqual(response, rberrors.DOES_NOT_EXIST)
 
     @patch.object(rbmodels.ReviewRequest.objects, 'get')
     @patch('mozreview.resources.commit_rewrite.get_parent_rr')
     @patch('mozreview.resources.commit_rewrite.fetch_commit_data')
     @patch('mozreview.resources.commit_rewrite.is_parent')
     def test_review_not_parent_returns_not_parent(self, mock_is_parent,
                                                   mock_fetch_commit_data,
-                                                  mock_get_parent_rr, mock_get):
+                                                  mock_get_parent_rr,
+                                                  mock_get):
         review = ReviewRequestFactory()
 
         # Return our review from database calls.
         mock_get.return_value = review
 
         # Return our review as the parent to get past first parent check.
         mock_get_parent_rr.return_value = review
 
@@ -214,17 +140,17 @@ class SummaryRewriteTest(unittest.TestCa
         mock_has_shipit_carryforward.return_value = True
 
     def test_reviewing_myself_rewrites_summary(self):
         viking = UserFactory(username='viking')
         parent_request = ReviewRequestFactory(submitter=viking)
         child_request = ReviewRequestFactory(
             submitter=viking, description="Bug 1 r?viking")
 
-        # Create an approved review.  The reviewer is the same as the submitter.
+        # Create an approved review. The reviewer is the same as the submitter.
         reviews = [ReviewFactory(user=viking, ship_it=True)]
 
         self.setup_fake_db(parent_request, [child_request], reviews)
 
         # Build the request and view.
         request = DjangoRequestFactory().get('/')
         request.user = parent_request.submitter
         crr = CommitRewriteResource()
@@ -249,17 +175,17 @@ class SummaryRewriteTest(unittest.TestCa
         # Build request and view.
         request = DjangoRequestFactory().get('/')
         request.user = parent_request.submitter
         crr = CommitRewriteResource()
         response = crr.get(request, review_request=parent_request.id)
 
         self.assertEqual(response, mrerrors.AUTOLAND_REVIEW_NOT_APPROVED)
 
-    def test_approved_with_no_valid_reviews_changes_reviewer_to_submitter(self):
+    def test_approved_and_no_valid_reviews_changes_reviewer_to_submitter(self):
         viking = UserFactory(username='viking')
         parent_request = ReviewRequestFactory(submitter=viking)
         child_request = ReviewRequestFactory(
             submitter=viking, description="Bug 1 r?foo")
         reviews = []
 
         self.setup_fake_db(parent_request, [child_request], reviews)