def test_enable_rollback_enable_rollout_invariance(self): rollout_recipe = RecipeFactory( name="Rollout", approver=UserFactory(), enabler=UserFactory(), action=ActionFactory(name="preference-rollout"), arguments={"slug": "myslug"}, ) assert rollout_recipe.approved_revision.enabled rollback_recipe = RecipeFactory( name="Rollback", action=ActionFactory(name="preference-rollback"), arguments={"rolloutSlug": "myslug"}, ) approval_request = rollback_recipe.latest_revision.request_approval( creator=UserFactory() ) approval_request.approve(approver=UserFactory(), comment="r+") with pytest.raises(ValidationError) as exc_info: rollback_recipe.approved_revision.enable(user=UserFactory()) assert exc_info.value.message == "Rollout recipe 'Rollout' is currently enabled" rollout_recipe.approved_revision.disable(user=UserFactory()) assert not rollout_recipe.approved_revision.enabled # Now it should be possible to enable the rollback recipe. rollback_recipe.approved_revision.enable(user=UserFactory()) assert rollback_recipe.approved_revision.enabled # Can't make up your mind. Now try to enable the rollout recipe again even though # the rollback recipe is enabled. with pytest.raises(ValidationError) as exc_info: rollout_recipe.approved_revision.enable(user=UserFactory()) assert exc_info.value.message == "Rollback recipe 'Rollback' is currently enabled"
def test_disable(self): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) assert recipe.approved_revision.enabled recipe.approved_revision.disable(user=UserFactory()) assert not recipe.approved_revision.enabled with pytest.raises(EnabledState.NotActionable): recipe.approved_revision.disable(user=UserFactory()) recipe.revise(name="New name") with pytest.raises(EnabledState.NotActionable): recipe.latest_revision.disable(user=UserFactory())
def test_no_errors(self): action = ActionFactory(name="preference-rollout") arguments = { "slug": "test-rollout", "preferences": [{"preferenceName": "foo", "value": 5}], } # does not throw when saving the revision recipe = RecipeFactory(action=action, arguments=arguments) # Approve and enable the revision rev = recipe.latest_revision approval_request = rev.request_approval(UserFactory()) approval_request.approve(UserFactory(), "r+") rev.enable(UserFactory())
def test_signature_is_updated_if_autograph_available( self, mocked_autograph): recipe = RecipeFactory(name="unchanged", approver=UserFactory(), enabler=UserFactory()) original_signature = recipe.signature assert original_signature is not None recipe.revise(name="changed") assert recipe.latest_revision.name == "changed" assert recipe.signature is not original_signature expected_sig = fake_sign([recipe.canonical_json()])[0]["signature"] assert recipe.signature.signature == expected_sig
def test_no_errors(self): action = ActionFactory(name="preference-experiment") arguments = { "slug": "a", "branches": [{"slug": "a", "value": "a"}, {"slug": "b", "value": "b"}], } # does not throw when saving the revision recipe = RecipeFactory(action=action, arguments=arguments) # Approve and enable the revision rev = recipe.latest_revision approval_request = rev.request_approval(UserFactory()) approval_request.approve(UserFactory(), "r+") rev.enable(UserFactory())
def test_update_signature(self, mock_logger, mocked_autograph): recipe = RecipeFactory(enabler=UserFactory(), approver=UserFactory()) recipe.signature = None recipe.update_signature() mock_logger.info.assert_called_with( Whatever.contains(str(recipe.id)), extra={ "code": INFO_REQUESTING_RECIPE_SIGNATURES, "recipe_ids": [recipe.id] }, ) mocked_autograph.return_value.sign_data.assert_called_with( [Whatever(lambda s: json.loads(s)["id"] == recipe.id)]) assert recipe.signature is not None
def test_update_user(self, api_client): u = UserFactory(first_name="John", last_name="Doe") res = api_client.patch(f"/api/v3/user/{u.id}/", {"first_name": "Jane"}) assert res.status_code == 200 u.refresh_from_db() assert u.first_name == "Jane" res = api_client.patch( f"/api/v3/user/{u.id}/", {"first_name": "Lejames", "last_name": "Bron"} ) assert res.status_code == 200 u.refresh_from_db() assert u.first_name == "Lejames" assert u.last_name == "Bron"
def test_it_unpublishes_when_disabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) recipe.approved_revision.disable(user=UserFactory()) mocked_remotesettings.return_value.unpublish.assert_called_with(recipe) # Unpublishes once when disabled twice. with pytest.raises(EnabledState.NotActionable): recipe.approved_revision.disable(user=UserFactory()) assert mocked_remotesettings.return_value.publish.call_count == 1
def test_it_updates_remote_settings_if_enabled(self, mocker, mocked_autograph): mocked_remotesettings = mocker.patch( "normandy.recipes.management.commands.update_recipe_signatures.RemoteSettings" ) for i in range(3): r = RecipeFactory(approver=UserFactory(), enabler=UserFactory(), signed=True) r.signature.signature = "old signature" r.signature.save() mocked_remotesettings.reset_mock() call_command("update_recipe_signatures", "--force") assert mocked_remotesettings.return_value.publish.call_count == 3 assert mocked_remotesettings.return_value.approve_changes.call_count == 1
def test_it_publishes_when_enabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test") approval_request = recipe.latest_revision.request_approval(creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") recipe.approved_revision.enable(user=UserFactory()) mocked_remotesettings.return_value.publish.assert_called_with(recipe) # Publishes once when enabled twice. with pytest.raises(EnabledState.NotActionable): recipe.approved_revision.enable(user=UserFactory()) assert mocked_remotesettings.return_value.publish.call_count == 1
def test_it_does_nothing_on_dry_run(self, rs_settings, requestsmock, mocked_remotesettings): r1 = RecipeFactory(name="Test 1", enabler=UserFactory(), approver=UserFactory()) requestsmock.get(self.baseline_published_records_url, json={"data": [exports.recipe_as_record(r1)]}) requestsmock.get(self.capabilities_published_records_url, json={"data": [exports.recipe_as_record(r1)]}) call_command("sync_remote_settings", "--dry-run") assert not mocked_remotesettings.publish.called assert not mocked_remotesettings.unpublish.called
def test_cannot_update_email(self, api_client): u = UserFactory(email="*****@*****.**") res = api_client.patch(f"/api/v3/user/{u.id}/", {"email": "*****@*****.**"}) assert res.status_code == 200 u.refresh_from_db() assert u.email == "*****@*****.**" res = api_client.put( f"/api/v3/user/{u.id}/", {"first_name": "Lejames", "last_name": "Bron", "email": "*****@*****.**"}, ) assert res.status_code == 200 u.refresh_from_db() assert u.email == "*****@*****.**"
def test_existing_user_with_no_email(self, csrf_api_client, mock_oidc): user = UserFactory(username="******", email="") mock_oidc( content=json.dumps( {"email": "*****@*****.**", "given_name": "John", "family_name": "Doe"} ).encode("utf-8") ) response = csrf_api_client.post( "/bearer/", {"example": "example"}, HTTP_AUTHORIZATION="Bearer valid-token" ) assert response.status_code == 200 assert response.data.get("user") == "*****@*****.**" user.refresh_from_db() assert user.email == "*****@*****.**"
def test_existing_user_with_no_email(self, csrf_api_client, mock_oidc): user = UserFactory(username="******", email="") mock_oidc(content=json.dumps({ "email": "*****@*****.**", "given_name": "John", "family_name": "Doe" }).encode("utf-8")) response = csrf_api_client.post( "/bearer/", {"example": "example"}, HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 200 assert response.data.get("user") == "*****@*****.**" user.refresh_from_db() assert user.email == "*****@*****.**"
def test_signed_listing_filters_by_enabled(Self, api_client): enabled_recipe = RecipeFactory( signed=True, approver=UserFactory(), enabler=UserFactory() ) disabled_recipe = RecipeFactory(signed=True) res = api_client.get("/api/v1/recipe/signed/?enabled=1") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["recipe"]["id"] == enabled_recipe.id res = api_client.get("/api/v1/recipe/signed/?enabled=0") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["recipe"]["id"] == disabled_recipe.id
def test_it_works(self): author = UserFactory() reviewer = UserFactory() recipe = RecipeFactory(name="first", user=author) revision1 = recipe.latest_revision approval_request = revision1.request_approval(author) approval_request.approve(reviewer, "r+") revision1.enable(author) state1 = revision1.enabled_state assert state1 is not None recipe.revise("second", user=author) revision2 = recipe.latest_revision approval_request = revision2.request_approval(author) approval_request.approve(reviewer, "r+") state2 = revision2.enabled_state assert state2 is not None serializer = EnabledStateSerializer(state1) assert serializer.data == { "id": state1.id, "carryover_from": None, "created": Whatever.iso8601(), "creator": { "id": author.id, "first_name": author.first_name, "last_name": author.last_name, "email": author.email, }, "enabled": True, "revision_id": revision1.id, } serializer = EnabledStateSerializer(state2) assert serializer.data == { "id": state2.id, "carryover_from": state1.id, "created": Whatever.iso8601(), "creator": { "id": reviewer.id, "first_name": reviewer.first_name, "last_name": reviewer.last_name, "email": reviewer.email, }, "enabled": True, "revision_id": revision2.id, }
def test_canonical_json(self): recipe = RecipeFactory( action=ActionFactory(name="action"), arguments_json='{"foo": 1, "bar": 2}', extra_filter_expression="2 + 2 == 4", name="canonical", approver=UserFactory(), filter_object_json=None, ) # Yes, this is really ugly, but we really do need to compare an exact # byte sequence, since this is used for hashing and signing filter_expression = "2 + 2 == 4" expected = ( "{" '"action":"action",' '"arguments":{"bar":2,"foo":1},' '"capabilities":["action.action","capabilities-v1"],' '"filter_expression":"%(filter_expression)s",' '"id":%(id)s,' '"name":"canonical",' '"revision_id":"%(revision_id)s",' '"uses_only_baseline_capabilities":false' "}" ) % { "id": recipe.id, "revision_id": recipe.latest_revision.id, "filter_expression": filter_expression, } expected = expected.encode() assert recipe.canonical_json() == expected
def test_it_unsigns_out_of_date_disabled_recipes(self, settings, mocked_autograph): r = RecipeFactory(approver=UserFactory(), signed=True, enabled=False) r.signature.timestamp -= timedelta(seconds=settings.AUTOGRAPH_SIGNATURE_MAX_AGE * 2) r.signature.save() call_command("update_recipe_signatures") r.refresh_from_db() assert r.signature is None
def test_cannot_reject_already_rejected(self): u = UserFactory() req = ApprovalRequestFactory() req.reject(u, "r-") with pytest.raises(req.NotActionable): req.reject(u, "r-")
def test_filtering_by_enabled_lowercase(self, api_client): r1 = RecipeFactory(approver=UserFactory(), enabled=True) RecipeFactory(enabled=False) res = api_client.get('/api/v1/recipe/?enabled=true') assert res.status_code == 200 assert [r['id'] for r in res.data] == [r1.id]
def test_it_resigns_signed_recipes_with_force(self, mocked_autograph): r = RecipeFactory(approver=UserFactory(), enabled=True, signed=True) r.signature.signature = 'old signature' r.signature.save() call_command('update_recipe_signatures', '--force') r.refresh_from_db() assert r.signature.signature is not 'old signature'
def test_setting_signature_doesnt_change_canonical_json(self): recipe = RecipeFactory(approver=UserFactory(), name="unchanged", signed=False) serialized = recipe.canonical_json() recipe.signature = SignatureFactory() recipe.save() assert recipe.signature is not None assert recipe.canonical_json() == serialized
def test_unpublish_reverts_changes_if_approval_fails( self, rs_settings, requestsmock): recipe = RecipeFactory(name="Test", approver=UserFactory()) collection_url = ( f"{rs_settings.REMOTE_SETTINGS_URL}/buckets/{rs_settings.REMOTE_SETTINGS_BUCKET_ID}" f"/collections/{rs_settings.REMOTE_SETTINGS_COLLECTION_ID}") record_url = f"{collection_url}/records/{recipe.id}" requestsmock.request("delete", record_url, content=b'{"data": {"deleted":true}}') requestsmock.request("patch", collection_url, status_code=403) record_prod_url = record_url.replace( f"/buckets/{rs_settings.REMOTE_SETTINGS_BUCKET_ID}/", f"/buckets/{exports.RemoteSettings.MAIN_BUCKET_ID}/", ) record_in_prod = json.dumps({ "data": exports.recipe_as_record(recipe) }).encode() requestsmock.request("get", record_prod_url, content=record_in_prod) requestsmock.request("put", record_url, content=b'{"data": {}}') remotesettings = exports.RemoteSettings() with pytest.raises(kinto_http.KintoException): remotesettings.unpublish(recipe) assert len(requestsmock.request_history) == 4 assert requestsmock.request_history[0].url == record_url assert requestsmock.request_history[0].method == "DELETE" assert requestsmock.request_history[1].url == collection_url assert requestsmock.request_history[2].url == record_prod_url assert requestsmock.request_history[3].url == record_url assert requestsmock.request_history[3].method == "PUT" assert requestsmock.request_history[3].json()["data"]["name"] == "Test"
def test_add_user(self, api_client): user = UserFactory() group = GroupFactory() res = api_client.post(f"/api/v3/group/{group.id}/add_user/", {"user_id": user.id}) assert res.status_code == 204 assert user.groups.filter(pk=group.pk).count() == 1
def test_unpublish_deletes_record_and_approves(self, rs_settings, requestsmock, mock_logger): """Test that requests are sent to Remote Settings on unpublish.""" recipe = RecipeFactory(name="Test", approver=UserFactory()) collection_url = ( f"{rs_settings.REMOTE_SETTINGS_URL}/buckets/{rs_settings.REMOTE_SETTINGS_BUCKET_ID}" f"/collections/{rs_settings.REMOTE_SETTINGS_COLLECTION_ID}") record_url = f"{collection_url}/records/{recipe.id}" auth = (rs_settings.REMOTE_SETTINGS_USERNAME + ":" + rs_settings.REMOTE_SETTINGS_PASSWORD).encode() request_headers = { "User-Agent": KINTO_USER_AGENT, "Content-Type": "application/json", "Authorization": f"Basic {base64.b64encode(auth).decode()}", } requestsmock.request("delete", record_url, content=b'{"data": {}}', request_headers=request_headers) requestsmock.request("patch", collection_url, content=b'{"data": {}}', request_headers=request_headers) remotesettings = exports.RemoteSettings() remotesettings.unpublish(recipe) assert len(requestsmock.request_history) == 2 assert requestsmock.request_history[0].url == record_url assert requestsmock.request_history[1].url == collection_url mock_logger.info.assert_called_with( f"Deleted record '{recipe.id}' of recipe '{recipe.name}'")
def test_user_exists(self, csrf_api_client, mock_oidc): user = UserFactory() mock_oidc(user=user) response = csrf_api_client.get("/bearer/", HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 200 assert response.data.get("user") == user.email
def test_cannot_approve_already_approved(self): u = UserFactory() req = ApprovalRequestFactory() req.approve(u, "r+") with pytest.raises(req.NotActionable): req.approve(u, "r+")
def test_caching(self, csrf_api_client, requestsmock, settings): user = UserFactory() user_data = json.dumps({ "email": user.email, "given_name": user.first_name, "family_name": user.last_name }).encode("utf-8") # The reason for the `+ 2` here is that if we only add +1 second, there's a small # chance that the `time.mktime(utcnow)` done here is *different* from the # `time.mktime(utcnow)` that happens within the `fetch_oidc_user_profile()` method # inside BearerTokenAuthentication. # I.e. # # print(time.mktime(datetime.utcnow().timetuple())) # outputs 1534380068.0 # time.sleep(0.1) # print(time.mktime(datetime.utcnow().timetuple())) # outputs 1534380069.0 # # Note! This doesn't always happen. Run those three lines 100 times and it's # guaranteed to be different ~10% of the time. That's when the milliseconds is 900 and # with the sleep(0.1) it will round up to the next second. # By adding +2 to the mock here we allow a whole 1 second between now and when # the `fetch_oidc_user_profile()` uses `time.mktime(datetime.utcnow().timetuple())`. ratelimit_reset = int(time.mktime(datetime.utcnow().timetuple())) + 2 requestsmock.get( settings.OIDC_USER_ENDPOINT, [ { "content": user_data, "status_code": 200, "headers": { "X-RateLimit-Reset": f"{ratelimit_reset}" }, }, { "status_code": 401 }, ], ) response = csrf_api_client.post( "/bearer/", {"example": "example"}, HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 200 assert response.data.get("user") == user.email # Response should be cached and you shouldn't hit the 401 response = csrf_api_client.post( "/bearer/", {"example": "example"}, HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 200 assert response.data.get("user") == user.email # Sleep till cache expires time.sleep(2) response = csrf_api_client.post( "/bearer/", {"example": "example"}, HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 401
def test_disabling_recipe_removes_approval(self): recipe = RecipeFactory(approver=UserFactory(), enabled=True) assert recipe.is_approved recipe.enabled = False recipe.save() recipe.refresh_from_db() assert not recipe.is_approved
def test_it_does_not_resign_up_to_date_recipes(self, settings, mocked_autograph): r = RecipeFactory(approver=UserFactory(), enabled=True, signed=True) r.signature.signature = 'original signature' r.signature.save() call_command('update_recipe_signatures') r.refresh_from_db() assert r.signature.signature == 'original signature'
def test_user_is_not_active(self, bare_api_client): user = UserFactory(username="******", email="*****@*****.**", is_active=False) response = bare_api_client.post( "/insecure/", {"example": "example"}, HTTP_AUTHORIZATION=f"Insecure {user.email}") assert response.status_code == 401, (response.data, response.user)
def test_it_publishes_new_revisions_if_enabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) assert mocked_remotesettings.return_value.publish.call_count == 1 recipe.revise(name="Modified") approval_request = recipe.latest_revision.request_approval( creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") assert mocked_remotesettings.return_value.publish.call_count == 2 second_call_args, _ = mocked_remotesettings.return_value.publish.call_args_list[ 1] modified_recipe, = second_call_args assert modified_recipe.name == "Modified"
def test_it_can_enable_recipes(self, api_client): recipe = RecipeFactory(enabled=False, approver=UserFactory()) res = api_client.post('/api/v1/recipe/%s/enable/' % recipe.id) assert res.status_code == 200 assert res.data['enabled'] is True recipe = Recipe.objects.all()[0] assert recipe.enabled