def test_reject(self, mocker): u = UserFactory() req = ApprovalRequestFactory() mocker.patch.object(req, 'verify_approver') req.reject(u, 'r-') assert not req.approved assert req.approver == u assert req.comment == 'r-' req.verify_approver.assert_called_with(u) recipe = req.revision.recipe assert not recipe.is_approved
def test_no_errors(self): action = ActionFactory(name="show-heartbeat") arguments = { "repeatOption": "nag", "surveyId": "001", "message": "Message!", "learnMoreMessage": "More!?!", "learnMoreUrl": "https://example.com/learnmore", "engagementButtonLabel": "Label!", "thanksMessage": "Thanks!", "postAnswerUrl": "https://example.com/answer", "includeTelemetryUUID": True, } # 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()) assert rev.arguments["surveyId"] == "001"
def test_publish_raises_an_error_if_request_fails(self, rs_urls, rs_settings, requestsmock): recipe = RecipeFactory(name="Test", approver=UserFactory()) record_url = rs_urls["workspace"]["capabilities"]["record"].format( recipe.id) requestsmock.request("put", record_url, status_code=503) remotesettings = exports.RemoteSettings() with pytest.raises(kinto_http.KintoException): remotesettings.publish(recipe) assert requestsmock.call_count == rs_settings.REMOTE_SETTINGS_RETRY_REQUESTS + 1
def test_error_during_approval_rolls_back_changes(self, mocker): recipe = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) old_approved_revision = recipe.approved_revision recipe.revise(name="New name") latest_revision = recipe.latest_revision approval_request = recipe.latest_revision.request_approval(UserFactory()) # Simulate an error during signing mocked_update_signature = mocker.patch.object(recipe, "update_signature") mocked_update_signature.side_effect = Exception with pytest.raises(Exception): approval_request.approve(UserFactory(), "r+") # Ensure the changes to the approval request and the recipe are rolled back and the recipe # is still enabled recipe.refresh_from_db() approval_request.refresh_from_db() assert approval_request.approved is None assert recipe.approved_revision == old_approved_revision assert recipe.latest_revision == latest_revision assert recipe.enabled
def test_list_filter_status(self, api_client): r1 = RecipeFactory(enabled=False) r2 = RecipeFactory(approver=UserFactory(), enabled=True) res = api_client.get('/api/v1/recipe/?status=enabled') assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]['id'] == r2.id res = api_client.get('/api/v1/recipe/?status=disabled') assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]['id'] == r1.id
def test_signature_is_cleared_if_autograph_unavailable(self, mocker): # Mock the Autographer to return an error mock_autograph = mocker.patch("normandy.recipes.models.Autographer") mock_autograph.side_effect = ImproperlyConfigured recipe = RecipeFactory(approver=UserFactory(), name="unchanged", signed=True) original_signature = recipe.signature recipe.revise(name="changed") assert recipe.latest_revision.name == "changed" assert recipe.signature is not original_signature assert recipe.signature is None
def test_signatures_are_updated(self, mocked_autograph, storage): extension = ExtensionFactory() fake_old_url = extension.xpi.url.replace("/media/", "/media-old/") action = ActionFactory(name="opt-out-study") recipe = RecipeFactory( action=action, arguments={addonUrl: fake_old_url}, approver=UserFactory(), enabler=UserFactory(), signed=True, ) # preconditions assert recipe.signature is not None assert recipe.signature.signature == hashlib.sha256(recipe.canonical_json()).hexdigest() signature_before = recipe.signature.signature call_command("update_addon_urls") recipe.refresh_from_db() assert recipe.signature is not None assert recipe.signature != signature_before assert recipe.signature.signature == hashlib.sha256(recipe.canonical_json()).hexdigest()
def test_approve(self, mocker): u = UserFactory() req = ApprovalRequestFactory() mocker.patch.object(req, "verify_approver") req.approve(u, "r+") assert req.approved assert req.approver == u assert req.comment == "r+" req.verify_approver.assert_called_with(u) recipe = req.revision.recipe assert recipe.is_approved
def test_verify_approver_unenforced(self, settings, mocker): logger = mocker.patch("normandy.recipes.models.logger") settings.PEER_APPROVAL_ENFORCED = False creator = UserFactory() user = UserFactory() req = ApprovalRequestFactory(creator=creator) # Do not raise when creator and approver are different req.verify_approver(user) # Do not raise when creator and approver are the same since enforcement # is disabled. req.verify_approver(creator) logger.warning.assert_called_with( Whatever(), extra={ "code": WARNING_BYPASSING_PEER_APPROVAL, "approval_id": req.id, "approver": creator, }, )
def test_must_have_permission(self, api_client): user = UserFactory(is_superuser=False) api_client.force_authenticate(user=user) res = api_client.get("/api/v3/group/") assert res.status_code == 403 ct = ContentType.objects.get_for_model(User) permission = Permission.objects.get(codename="change_user", content_type=ct) user.user_permissions.add(permission) user = User.objects.get(pk=user.pk) api_client.force_authenticate(user=user) res = api_client.get("/api/v3/group/") assert res.status_code == 200
def test_publish_reverts_changes_if_approval_fails(self, rs_urls, rs_settings, requestsmock): # This test forces the recipe to not use baseline capabilities to # simplify the test. This simplifies the test. recipe = RecipeFactory(name="Test", approver=UserFactory()) assert not recipe.uses_only_baseline_capabilities() capabilities_record_url = rs_urls["workspace"]["capabilities"][ "record"].format(recipe.id) # Creating the record works. requestsmock.request("put", capabilities_record_url, json={"data": {}}, status_code=201) requestsmock.register_uri( "patch", rs_urls["workspace"]["capabilities"]["collection"], [ # Approving fails. { "status_code": 403 }, # Rollback succeeds. { "status_code": 200, "json": { "data": {} } }, ], ) remotesettings = exports.RemoteSettings() with pytest.raises(kinto_http.KintoException): remotesettings.publish(recipe) requests = requestsmock.request_history assert len(requests) == 3 # First it publishes a recipe assert requests[0].method == "PUT" assert requests[0].url == capabilities_record_url # and then tries to approve it, which fails. assert requests[1].method == "PATCH" assert requests[1].url == rs_urls["workspace"]["capabilities"][ "collection"] # so it rollsback assert requests[2].method == "PATCH" assert requests[2].url == rs_urls["workspace"]["capabilities"][ "collection"]
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_publish_raises_an_error_if_request_fails(self, rs_settings, requestsmock): recipe = RecipeFactory(name="Test", approver=UserFactory()) record_url = ( f"{rs_settings.REMOTE_SETTINGS_URL}/buckets/{rs_settings.REMOTE_SETTINGS_BUCKET_ID}" f"/collections/{rs_settings.REMOTE_SETTINGS_COLLECTION_ID}" f"/records/{recipe.id}") requestsmock.request("put", record_url, status_code=503) remotesettings = exports.RemoteSettings() with pytest.raises(kinto_http.KintoException): remotesettings.publish(recipe) assert requestsmock.call_count == rs_settings.REMOTE_SETTINGS_RETRY_REQUESTS + 1
def test_publish_puts_record_and_approves(self, rs_urls, rs_settings, requestsmock, mock_logger): """Test that requests are sent to Remote Settings on publish.""" recipe = RecipeFactory(name="Test", approver=UserFactory()) rs_settings.BASELINE_CAPABILITIES |= recipe.capabilities 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()}", } for collection in ["baseline", "capabilities"]: requestsmock.request( "PUT", rs_urls["workspace"][collection]["record"].format(recipe.id), json={"data": {}}, request_headers=request_headers, ) requestsmock.request( "PATCH", rs_urls["workspace"][collection]["collection"], json={"data": {}}, request_headers=request_headers, ) remotesettings = exports.RemoteSettings() remotesettings.publish(recipe) requests = requestsmock.request_history assert len(requests) == 4 assert requests[0].url == rs_urls["workspace"]["capabilities"][ "record"].format(recipe.id) assert requests[0].method == "PUT" assert requests[0].json() == {"data": exports.recipe_as_record(recipe)} assert requests[1].url == rs_urls["workspace"]["baseline"][ "record"].format(recipe.id) assert requests[1].method == "PUT" assert requests[1].json() == {"data": exports.recipe_as_record(recipe)} assert requests[2].method == "PATCH" assert requests[2].url == rs_urls["workspace"]["capabilities"][ "collection"] assert requests[3].method == "PATCH" assert requests[3].url == rs_urls["workspace"]["baseline"][ "collection"] mock_logger.info.assert_called_with( f"Published record '{recipe.id}' for recipe '{recipe.name}'")
def test_recipe_as_remotesettings_record(self, mocked_autograph): """Test that recipes are serialized as expected by our clients.""" recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory(), signed=True) record = exports.recipe_as_record(recipe) assert record == { "id": str(recipe.id), "recipe": { "action": recipe.approved_revision.action.name, "arguments": recipe.approved_revision.arguments, "filter_expression": recipe.approved_revision.filter_expression, "id": recipe.id, "name": recipe.approved_revision.name, "revision_id": str(recipe.approved_revision.id), "capabilities": Whatever(lambda caps: set(caps) == recipe.approved_revision. capabilities), "uses_only_baseline_capabilities": False, }, "signature": { "public_key": Whatever.regex(r"[a-zA-Z0-9/+]{160}"), "signature": Whatever.regex(r"[a-f0-9]{40}"), "timestamp": Whatever.iso8601(), "x5u": Whatever.startswith("https://"), }, }
def test_signatures_are_updated(self, mocked_autograph): action = ActionFactory(name="opt-out-study") recipe = RecipeFactory( action=action, arguments={ "addonUrl": "https://before.example.com/extensions/addon.xpi" }, approver=UserFactory(), enabler=UserFactory(), signed=True, ) # preconditions assert recipe.signature is not None assert recipe.signature.signature == hashlib.sha256( recipe.canonical_json()).hexdigest() signature_before = recipe.signature.signature call_command("update_addon_urls", "after.example.com") recipe.refresh_from_db() assert recipe.signature is not None assert recipe.signature != signature_before assert recipe.signature.signature == hashlib.sha256( recipe.canonical_json()).hexdigest()
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_publishes_missing_recipes(self, rs_settings, requestsmock): # Some records will be created with PUT. requestsmock.put(requests_mock.ANY, json={}) # A signature request will be sent. requestsmock.patch(self.workspace_collection_url, json={}) # Instantiate local recipes. r1 = RecipeFactory(name="Test 1", enabler=UserFactory(), approver=UserFactory()) r2 = RecipeFactory(name="Test 2", enabler=UserFactory(), approver=UserFactory()) # Mock the server responses. # `r2` should be on the server requestsmock.get(self.published_records_url, json={"data": [exports.recipe_as_record(r1)]}) # It will be created. r2_url = self.workspace_collection_url + f"/records/{r2.id}" requestsmock.put(r2_url, json={}) call_command("sync_remote_settings") assert requestsmock.request_history[-2].method == "PUT" assert requestsmock.request_history[-2].url.endswith(r2_url)
def test_signed_listing_filters_by_enabled(Self, api_client): enabled_recipe = RecipeFactory(signed=True, approver=UserFactory(), enabled=True) disabled_recipe = RecipeFactory(signed=True, enabled=False) 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_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_unpublish_ignores_error_about_missing_record( self, rs_settings, requestsmock, mock_logger): recipe = RecipeFactory(name="Test", approver=UserFactory()) record_url = ( f"{rs_settings.REMOTE_SETTINGS_URL}/buckets/{rs_settings.REMOTE_SETTINGS_BUCKET_ID}" f"/collections/{rs_settings.REMOTE_SETTINGS_COLLECTION_ID}" f"/records/{recipe.id}") requestsmock.request("delete", record_url, status_code=404) remotesettings = exports.RemoteSettings() # Assert doesn't raise. remotesettings.unpublish(recipe) assert requestsmock.call_count == 1 mock_logger.warning.assert_called_with( f"The recipe '{recipe.id}' was never published. Skip.")
def test_unpublish_reverts_changes_if_approval_fails( self, rs_urls, rs_settings, requestsmock): recipe = RecipeFactory(name="Test", approver=UserFactory()) capabilities_record_url = rs_urls["workspace"]["record"].format( recipe.id) # Deleting the record works. requestsmock.request("delete", capabilities_record_url, json={"data": { "deleted": True }}) requestsmock.register_uri( "patch", rs_urls["workspace"]["collection"], [ # Approving fails. { "status_code": 403 }, # Rollback succeeds. { "status_code": 200, "json": { "data": {} } }, ], ) remotesettings = exports.RemoteSettings() with pytest.raises(kinto_http.KintoException): remotesettings.unpublish(recipe) requests = requestsmock.request_history assert len(requests) == 3 # Unpublish the recipe in collection assert requests[0].url == capabilities_record_url assert requests[0].method == "DELETE" # Try (and fail) to approve the capabilities change assert requests[1].url == rs_urls["workspace"]["collection"] assert requests[1].method == "PATCH" # so it rollsback collection assert requests[2].method == "PATCH" assert requests[2].url == rs_urls["workspace"]["collection"]
def test_current_revision_property(self): """Ensure current revision properties work as expected.""" recipe = RecipeFactory(name="first") assert recipe.name == "first" recipe.revise(name="second") assert recipe.name == "second" approval = ApprovalRequestFactory(revision=recipe.latest_revision) approval.approve(UserFactory(), "r+") assert recipe.name == "second" # When `revise` is called on a recipe with at least one approved revision, the new revision # is treated as a draft and as such the `name` property of the recipe should return the # `name` from the `approved_revision` not the `latest_revision`. recipe.revise(name="third") assert recipe.latest_revision.name == "third" # The latest revision ("draft") is updated assert recipe.name == "second" # The current revision is unchanged
def test_unpublish_ignores_error_about_missing_records( self, rs_urls, rs_settings, requestsmock, mock_logger): recipe = RecipeFactory(name="Test", approver=UserFactory()) capabilities_record_url = rs_urls["workspace"]["record"].format( recipe.id) warning_message = ( f"The recipe '{recipe.id}' was not published in the {{}} collection. Skip." ) requestsmock.request("delete", capabilities_record_url, status_code=404) remotesettings = exports.RemoteSettings() # Assert doesn't raise. remotesettings.unpublish(recipe) assert mock_logger.warning.call_args_list == [ call(warning_message.format("capabilities")) ]
def test_enable(self): recipe = RecipeFactory(name="Test") with pytest.raises(EnabledState.NotActionable): recipe.latest_revision.enable(user=UserFactory()) approval_request = recipe.latest_revision.request_approval(creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") recipe.revise(name="New name") with pytest.raises(EnabledState.NotActionable): recipe.latest_revision.enable(user=UserFactory()) recipe.approved_revision.enable(user=UserFactory()) assert recipe.approved_revision.enabled with pytest.raises(EnabledState.NotActionable): recipe.approved_revision.enable(user=UserFactory()) approval_request = recipe.latest_revision.request_approval(creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") assert recipe.approved_revision.enabled
def test_signatures_update_correctly_on_enable(self, mocker): mock_autograph = mocker.patch('normandy.recipes.models.Autographer') def fake_sign(datas): sigs = [] for d in datas: sigs.append({'signature': hashlib.sha256(d).hexdigest()}) return sigs mock_autograph.return_value.sign_data.side_effect = fake_sign recipe = RecipeFactory(enabled=False, signed=False, approver=UserFactory()) recipe.enabled = True recipe.save() recipe.refresh_from_db() assert recipe.signature is not None assert recipe.signature.signature == hashlib.sha256( recipe.canonical_json()).hexdigest()
def test_retry_on_5xx_error(self, csrf_api_client, settings, requestsmock, caplog): user = UserFactory() user_data = json.dumps({ "email": user.email, "given_name": user.first_name, "family_name": user.last_name }).encode("utf-8") requestsmock.get( settings.OIDC_USER_ENDPOINT, [ { "status_code": 504 }, { "status_code": 504 }, { "content": user_data, "status_code": 200 }, ], ) response = csrf_api_client.get("/bearer/", HTTP_AUTHORIZATION="Bearer valid-token") assert response.status_code == 200 assert response.data.get("user") == user.email # Count the number of retries retry_count = 0 expected_message = "requests.exceptions.RequestException: 504 on {}".format( settings.OIDC_USER_ENDPOINT) for record in caplog.records: if expected_message in record.message: retry_count += 1 assert retry_count == 2
def test_publish_puts_record_and_approves(self, rs_settings, requestsmock, mock_logger): """Test that requests are sent to Remote Settings on publish.""" 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("put", 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.publish(recipe) assert len(requestsmock.request_history) == 2 assert requestsmock.request_history[0].url == record_url assert requestsmock.request_history[0].method == "PUT" assert requestsmock.request_history[0].json() == { "data": exports.recipe_as_record(recipe) } assert requestsmock.request_history[1].method == "PATCH" assert requestsmock.request_history[1].url == collection_url mock_logger.info.assert_called_with( f"Published record '{recipe.id}' for recipe '{recipe.name}'")
def test_unpublish_deletes_record_and_approves(self, rs_urls, rs_settings, requestsmock, mock_logger): """Test that requests are sent to Remote Settings on unpublish.""" recipe = RecipeFactory(name="Test", approver=UserFactory()) urls = rs_urls["workspace"] 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", urls["record"].format(recipe.id), json={"data": {}}, request_headers=request_headers, ) requestsmock.request("patch", urls["collection"], json={"data": {}}, request_headers=request_headers) remotesettings = exports.RemoteSettings() remotesettings.unpublish(recipe) assert len(requestsmock.request_history) == 2 requests = requestsmock.request_history assert requests[0].url == urls["record"].format(recipe.id) assert requests[0].method == "DELETE" assert requests[1].url == urls["collection"] assert requests[1].method == "PATCH" mock_logger.info.assert_called_with( f"Deleted record '{recipe.id}' of recipe '{recipe.approved_revision.name}'" )
def test_full_approval_flow(self, settings, api_client, mocked_autograph): settings.PEER_APPROVAL_ENFORCED = True action = ActionFactory() user1 = UserFactory(is_superuser=True) user2 = UserFactory(is_superuser=True) api_client.force_authenticate(user1) # Create a recipe res = api_client.post( '/api/v1/recipe/', { 'action': action.name, 'arguments': {}, 'name': 'test recipe', 'extra_filter_expression': 'counter == 0', 'enabled': 'false', }) assert res.status_code == 201 recipe_data_0 = res.json() # Request approval for it res = api_client.post( '/api/v1/recipe_revision/{}/request_approval/'.format( recipe_data_0['revision_id'])) approval_data = res.json() assert res.status_code == 201 # The requester isn't allowed to approve a recipe res = api_client.post( '/api/v1/approval_request/{}/approve/'.format(approval_data['id']), {'comment': 'r+'}) assert res.status_code == 403 # Forbidden # Approve the recipe api_client.force_authenticate(user2) res = api_client.post( '/api/v1/approval_request/{}/approve/'.format(approval_data['id']), {'comment': 'r+'}) assert res.status_code == 200 # It is now visible in the API res = api_client.get('/api/v1/recipe/{}/'.format(recipe_data_0['id'])) assert res.status_code == 200 recipe_data_1 = res.json() self.verify_signatures(api_client, expected_count=1) # Make another change api_client.force_authenticate(user1) res = api_client.patch( '/api/v1/recipe/{}/'.format(recipe_data_1['id']), { 'extra_filter_expression': 'counter == 1', }) assert res.status_code == 200 # The change should not be visible yet, since it isn't approved res = api_client.get('/api/v1/recipe/{}/'.format(recipe_data_1['id'])) assert res.status_code == 200 recipe_data_2 = res.json() assert recipe_data_2['extra_filter_expression'] == 'counter == 0' self.verify_signatures(api_client, expected_count=1) # Request approval for the change res = api_client.post( '/api/v1/recipe_revision/{}/request_approval/'.format( recipe_data_2['latest_revision_id'])) approval_data = res.json() recipe_data_2['approval_request'] = approval_data assert res.status_code == 201 # The change should not be visible yet, since it isn't approved res = api_client.get('/api/v1/recipe/{}/'.format(recipe_data_1['id'])) assert res.status_code == 200 assert res.json() == recipe_data_2 self.verify_signatures(api_client, expected_count=1) # Reject the change api_client.force_authenticate(user2) res = api_client.post( '/api/v1/approval_request/{}/reject/'.format(approval_data['id']), {'comment': 'r-'}) approval_data = res.json() recipe_data_2['approval_request'] = approval_data assert res.status_code == 200 # The change should not be visible yet, since it isn't approved res = api_client.get('/api/v1/recipe/{}/'.format(recipe_data_1['id'])) assert res.status_code == 200 assert res.json() == recipe_data_2 self.verify_signatures(api_client, expected_count=1) # Make a third version of the recipe api_client.force_authenticate(user1) res = api_client.patch( '/api/v1/recipe/{}/'.format(recipe_data_1['id']), { 'extra_filter_expression': 'counter == 2', }) recipe_data_3 = res.json() assert res.status_code == 200 # Request approval res = api_client.post( '/api/v1/recipe_revision/{}/request_approval/'.format( recipe_data_3['latest_revision_id'])) approval_data = res.json() assert res.status_code == 201 # Approve the change api_client.force_authenticate(user2) res = api_client.post( '/api/v1/approval_request/{}/approve/'.format(approval_data['id']), {'comment': 'r+'}) assert res.status_code == 200 # The change should be visible now, since it is approved res = api_client.get('/api/v1/recipe/{}/'.format(recipe_data_1['id'])) assert res.status_code == 200 recipe_data_4 = res.json() assert recipe_data_4['extra_filter_expression'] == 'counter == 2' assert recipe_data_4['latest_revision_id'] == recipe_data_4[ 'revision_id'] self.verify_signatures(api_client, expected_count=1)