def test_unique_name_update_collision(self): action = ActionFactory(name="opt-out-study") arguments_a = OptOutStudyArgumentsFactory() arguments_b = OptOutStudyArgumentsFactory() RecipeFactory(action=action, arguments=arguments_a) recipe = RecipeFactory(action=action, arguments=arguments_b) with pytest.raises(serializers.ValidationError) as exc_info1: recipe.revise(arguments=arguments_a) error = action.errors["duplicate_study_name"] assert exc_info1.value.detail == {"arguments": {"name": error}}
def test_list_filter_action(self, api_client): a1 = ActionFactory() a2 = ActionFactory() r1 = RecipeFactory(action=a1) r2 = RecipeFactory(action=a2) assert a1.name != a2.name res = api_client.get(f"/api/v1/recipe/?action={a1.name}") assert res.status_code == 200 assert [r["id"] for r in res.data] == [r1.id] res = api_client.get(f"/api/v1/recipe/?action={a2.name}") assert res.status_code == 200 assert [r["id"] for r in res.data] == [r2.id] assert a1.name != "nonexistant" and a2.name != "nonexistant" res = api_client.get("/api/v1/recipe/?action=nonexistant") assert res.status_code == 200 assert res.data == []
def test_cannot_delete_in_use_extension(self, api_client, storage): xpi = WebExtensionFileFactory() e = ExtensionFactory(xpi__from_func=xpi.open) a = ActionFactory(name="opt-out-study") RecipeFactory(action=a, arguments={"extensionId": e.id}) res = api_client.delete(f"/api/v3/extension/{e.id}/") assert res.status_code == 400 assert res.data == [ "Extension cannot be updated while in use by a recipe." ] assert Extension.objects.count() == 1
def test_it_doesnt_disable_recipes(self, mock_action): action = ActionFactory(name='test-action', implementation='old') recipe = RecipeFactory(action=action, approver=UserFactory(), enabled=True) action = recipe.action mock_action(action.name, 'impl', action.arguments_schema) call_command('update_actions') recipe.refresh_from_db() assert recipe.enabled
def test_it_works(self, 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}) call_command("update_addon_urls") # For reasons that I don't understand, recipe.update_from_db() doesn't work here. recipe = Recipe.objects.get(id=recipe.id) assert recipe.arguments[addonUrl] == extension.xpi.url
def test_it_doesnt_disable_recipes(self, mock_action): action = ActionFactory(name="test-action", implementation="old") recipe = RecipeFactory(action=action, approver=UserFactory(), enabler=UserFactory()) action = recipe.action mock_action(action.name, "impl", action.arguments_schema) call_command("update_actions") recipe.refresh_from_db() assert recipe.enabled
def test_unique_experiment_slug_update_collision(self): action = ActionFactory(name="preference-experiment") arguments_a = {"slug": "a", "branches": []} arguments_b = {"slug": "b", "branches": []} # Does not throw when saving revisions RecipeFactory(action=action, arguments=arguments_a) recipe = RecipeFactory(action=action, arguments=arguments_b) with pytest.raises(serializers.ValidationError) as exc_info1: recipe.revise(arguments=arguments_a) error = action.errors["duplicate_experiment_slug"] assert exc_info1.value.detail == {"arguments": {"slug": error}}
def test_recipes_used_by(self): approver = UserFactory() enabler = UserFactory() recipe = RecipeFactory(approver=approver, enabler=enabler) assert [recipe] == list(recipe.action.recipes_used_by) action = ActionFactory() recipes = RecipeFactory.create_batch(2, action=action, approver=approver, enabler=enabler) assert set(action.recipes_used_by) == set(recipes)
def test_it_updates_existing_actions(self, mock_action): action = ActionFactory(name="test-action", implementation="old_impl", arguments_schema={}) mock_action(action.name, {"type": "int"}, "new_impl") call_command("update_actions") assert Action.objects.count() == 1 action.refresh_from_db() assert action.implementation == "new_impl" assert action.arguments_schema == {"type": "int"}
def test_it_serves_actions_without_implementation(self, api_client): ActionFactory(name="foo-remote", implementation=None, arguments_schema={"type": "object"}) res = api_client.get("/api/v1/action/") assert res.status_code == 200 assert res.data == [ { "name": "foo-remote", "implementation_url": None, "arguments_schema": {"type": "object"}, } ]
def test_it_404s_if_hash_doesnt_match(self, api_client): action = ActionFactory(implementation='asdf') bad_hash = hashlib.sha1('nomatch'.encode()).hexdigest() res = api_client.get( '/api/v1/action/{name}/implementation/{hash}/'.format( name=action.name, hash=bad_hash, )) assert res.status_code == 404 assert res.content.decode( ) == '/* Hash does not match current stored action. */' assert res['Content-Type'] == 'application/javascript; charset=utf-8'
def test_cancel_approval(self, api_client, mocked_autograph): 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_id = res.json()['id'] revision_id = res.json()['latest_revision_id'] # Request approval res = api_client.post( f'/api/v1/recipe_revision/{revision_id}/request_approval/') assert res.status_code == 201 approval_request_id = res.json()['id'] # Approve the recipe api_client.force_authenticate(user2) res = api_client.post( f'/api/v1/approval_request/{approval_request_id}/approve/', {'comment': 'r+'}) assert res.status_code == 200 # Make another change api_client.force_authenticate(user1) res = api_client.patch(f'/api/v1/recipe/{recipe_id}/', {'extra_filter_expression': 'counter == 1'}) assert res.status_code == 200 revision_id = res.json()['latest_revision_id'] # Request approval for the second change res = api_client.post( f'/api/v1/recipe_revision/{revision_id}/request_approval/') approval_request_id = res.json()['id'] assert res.status_code == 201 # Cancel the approval request res = api_client.post( f'/api/v1/approval_request/{approval_request_id}/close/') assert res.status_code == 204 # The API should still have correct signatures self.verify_signatures(api_client, expected_count=1)
def test_validation_with_valid_data(self): mockAction = ActionFactory(name='show-heartbeat', arguments_schema=ARGUMENTS_SCHEMA) channel = ChannelFactory(slug='release') country = CountryFactory(code='CA') locale = LocaleFactory(code='en-US') serializer = RecipeSerializer( data={ 'name': 'bar', 'enabled': True, 'extra_filter_expression': '[]', 'action': 'show-heartbeat', 'channels': ['release'], 'countries': ['CA'], 'locales': ['en-US'], 'arguments': { 'surveyId': 'lorem-ipsum-dolor', 'surveys': [{ 'title': 'adipscing', 'weight': 1 }, { 'title': 'consequetar', 'weight': 1 }] } }) assert serializer.is_valid() assert serializer.validated_data == { 'name': 'bar', 'enabled': True, 'extra_filter_expression': '[]', 'action': mockAction, 'arguments': { 'surveyId': 'lorem-ipsum-dolor', 'surveys': [{ 'title': 'adipscing', 'weight': 1 }, { 'title': 'consequetar', 'weight': 1 }] }, 'channels': [channel], 'countries': [country], 'locales': [locale], } assert serializer.errors == {}
def test_cant_change_signature_and_other_fields(self, mocker): # Mock the Autographer mock_autograph = mocker.patch("normandy.recipes.models.Autographer") mock_autograph.return_value.sign_data.return_value = [{ "signature": "fake signature" }] action = ActionFactory(name="unchanged", signed=False) action.update_signature() action.name = "changed" with pytest.raises(ValidationError) as exc_info: action.save() assert exc_info.value.message == "Signatures must change alone"
def test_unique_experiment_slug_update_collision(self): """A recipe can't be updated to have the same slug as another existing recipe""" action = ActionFactory(name="multi-preference-experiment") arguments_a = MultiPreferenceExperimentArgumentsFactory() arguments_b = MultiPreferenceExperimentArgumentsFactory() # Does not throw when saving revisions RecipeFactory(action=action, arguments=arguments_a) recipe = RecipeFactory(action=action, arguments=arguments_b) with pytest.raises(serializers.ValidationError) as exc_info1: recipe.revise(arguments=arguments_a) error = action.errors["duplicate_experiment_slug"] assert exc_info1.value.detail == {"arguments": {"slug": error}}
def test_cannot_update_in_use_extension(self, api_client, storage): xpi = WebExtensionFileFactory() e = ExtensionFactory(xpi__from_func=xpi.open) a = ActionFactory(name="opt-out-study") RecipeFactory(action=a, arguments={"extensionId": e.id}) res = api_client.patch(f"/api/v3/extension/{e.id}/", {"name": "new name"}) assert res.status_code == 400 assert res.data == [ "Extension cannot be updated while in use by a recipe." ] e.refresh_from_db() assert e.name != "new name"
def test_no_errors(self): action = ActionFactory(name="preference-experiment") arguments = PreferenceExperimentArgumentsFactory( 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_creation_when_identicon_seed_is_invalid(self, api_client): action = ActionFactory() res = api_client.post( '/api/v2/recipe/', { 'name': 'Test Recipe', 'action_id': action.id, 'arguments': {}, 'extra_filter_expression': 'whatever', 'enabled': True, 'identicon_seed': 'invalid_identicon_seed' }) assert res.status_code == 400
def test_it_includes_cache_headers(self, api_client, settings): # Note: Can't override the cache time setting, since it is read # when invoking the decorator at import time. Changing it would # require mocking, and that isn't worth it. action = ActionFactory() res = api_client.get( "/api/v1/action/{name}/implementation/{hash}/".format( name=action.name, hash=action.implementation_hash)) assert res.status_code == 200 max_age = "max-age={}".format(settings.IMMUTABLE_CACHE_TIME) assert max_age in res["Cache-Control"] assert "public" in res["Cache-Control"]
def test_it_doesnt_update_other_actions(self): action = ActionFactory(name="some-other-action") recipe = RecipeFactory( action=action, arguments={ addonUrl: "https://before.example.com/extensions/addon.xpi" }) call_command("update_addon_urls") # For reasons that I don't understand, recipe.update_from_db() doesn't work here. recipe = Recipe.objects.get(id=recipe.id) # Url should not be not updated assert recipe.arguments[ addonUrl] == "https://before.example.com/extensions/addon.xpi"
def test_preference_experiments_unique_experiment_slug_update_collision( self): action = ActionFactory(name='preference-experiment') arguments_a = {'slug': 'a', 'branches': []} arguments_b = {'slug': 'b', 'branches': []} # Does not throw when saving revisions RecipeFactory(action=action, arguments=arguments_a) recipe = RecipeFactory(action=action, arguments=arguments_b) with pytest.raises(serializers.ValidationError) as exc_info1: recipe.revise(arguments=arguments_a) error = action.errors['duplicate_experiment_slug'] assert exc_info1.value.detail == {'arguments': {'slug': error}}
def test_v1_marker_included_only_if_non_baseline_capabilities_are_present( self, settings): action = ActionFactory() settings.BASELINE_CAPABILITIES |= action.capabilities recipe = RecipeFactory(extra_capabilities=[], action=action) assert recipe.latest_revision.capabilities <= settings.BASELINE_CAPABILITIES assert "capabilities-v1" not in recipe.latest_revision.capabilities recipe = RecipeFactory(extra_capabilities=["non-baseline"], action=action) assert "non-baseline" not in settings.BASELINE_CAPABILITIES assert "capabilities-v1" in recipe.latest_revision.capabilities
def test_list_filter_action_legacy(self, api_client): a1 = ActionFactory() a2 = ActionFactory() r1 = RecipeFactory(action=a1) r2 = RecipeFactory(action=a2) assert a1.id != a2.id res = api_client.get( f'/api/v1/recipe/?latest_revision__action={a1.id}') assert res.status_code == 200 assert [r['id'] for r in res.data] == [r1.id] res = api_client.get( f'/api/v1/recipe/?latest_revision__action={a2.id}') assert res.status_code == 200 assert [r['id'] for r in res.data] == [r2.id] assert a1.id != -1 and a2.id != -1 res = api_client.get('/api/v1/recipe/?latest_revision__action=-1') assert res.status_code == 200 assert res.data == []
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_it_updates_existing_actions(self, mock_action): action = ActionFactory( name='test-action', implementation='old_impl', arguments_schema={}, ) mock_action(action.name, 'new_impl', {'type': 'int'}) call_command('update_actions') assert Action.objects.count() == 1 action.refresh_from_db() assert action.implementation == 'new_impl' assert action.arguments_schema == {'type': 'int'}
def test_list_filter_action_legacy(self, api_client): a1 = ActionFactory() a2 = ActionFactory() r1 = RecipeFactory(action=a1) r2 = RecipeFactory(action=a2) assert a1.id != a2.id res = api_client.get( f"/api/v1/recipe/?latest_revision__action={a1.id}") assert res.status_code == 200 assert [r["id"] for r in res.data] == [r1.id] res = api_client.get( f"/api/v1/recipe/?latest_revision__action={a2.id}") assert res.status_code == 200 assert [r["id"] for r in res.data] == [r2.id] assert a1.id != -1 and a2.id != -1 res = api_client.get("/api/v1/recipe/?latest_revision__action=-1") assert res.status_code == 400 assert res.data["latest_revision__action"][ 0].code == "invalid_choice"
def test_preference_experiments_unique_branch_values(self): action = ActionFactory(name="preference-experiment") arguments = PreferenceExperimentArgumentsFactory( slug="test", branches=[ {"slug": "a", "value": "unique"}, {"slug": "b", "value": "duplicate"}, {"slug": "c", "value": "duplicate"}, ], ) with pytest.raises(serializers.ValidationError) as exc_info: action.validate_arguments(arguments, RecipeRevisionFactory()) error = action.errors["duplicate_branch_value"] assert exc_info.value.detail == {"arguments": {"branches": {2: {"value": error}}}}
def test_canonical_json(self): action = ActionFactory(name="test-action", implementation="console.log(true)") # Yes, this is ugly, but it needs to compare an exact byte # sequence, since this is used for hashing and signing expected = ( "{" '"arguments_schema":{},' '"implementation_url":"/api/v1/action/test-action/implementation' '/sha384-ZRkmoh4lizeQ_jdtJBOQZmPzc3x09DKCA4gkdJmwEnO31F7Ttl8RyXkj3wG93lAP/",' '"name":"test-action"' "}") expected = expected.encode() assert action.canonical_json() == expected
def test_it_works(self): action = ActionFactory(name="opt-out-study") recipe = RecipeFactory( action=action, arguments={ "addonUrl": "https://before.example.com/extensions/addon.xpi" }, ) call_command("update_addon_urls", "after.example.com") # For reasons that I don't understand, recipe.update_from_db() doesn't work here. recipe = Recipe.objects.get(id=recipe.id) assert recipe.arguments[ "addonUrl"] == "https://after.example.com/extensions/addon.xpi"
def test_validation_with_wrong_arguments(self): action = ActionFactory(name="show-heartbeat", arguments_schema=ARGUMENTS_SCHEMA) serializer = RecipeSerializer( data={ "action_id": action.id, "name": "Any name", "extra_filter_expression": "true", "arguments": { "surveyId": "", "surveys": [ { "title": "", "weight": 1 }, { "title": "bar", "weight": 1 }, { "title": "foo", "weight": 0 }, { "title": "baz", "weight": "lorem ipsum" }, ], }, }) with pytest.raises(serializers.ValidationError): serializer.is_valid(raise_exception=True) assert serializer.errors["arguments"] == { "surveyId": "This field may not be blank.", "surveys": { 0: { "title": "This field may not be blank." }, 2: { "weight": "0 is less than the minimum of 1" }, 3: { "weight": "'lorem ipsum' is not of type 'integer'" }, }, }