from datetime import timedelta from django.conf import settings from django.db import connection from django.test.utils import CaptureQueriesContext import pytest from rest_framework import serializers from rest_framework.reverse import reverse from pathlib import Path from normandy.base.api.permissions import AdminEnabledOrReadOnly from normandy.base.tests import UserFactory, Whatever from normandy.base.utils import canonical_json_dumps from normandy.recipes.models import ApprovalRequest, Recipe, RecipeRevision from normandy.recipes.tests import ( ActionFactory, ApprovalRequestFactory, ChannelFactory, CountryFactory, LocaleFactory, RecipeFactory, RecipeRevisionFactory, fake_sign, ) @pytest.mark.django_db class TestActionAPI(object): def test_it_works(self, api_client): res = api_client.get("/api/v3/action/") assert res.status_code == 200 assert res.data == {"count": 0, "next": None, "previous": None, "results": []} def test_it_serves_actions(self, api_client): action = ActionFactory( name="foo", implementation="foobar", arguments_schema={"type": "object"} ) res = api_client.get("/api/v3/action/") action_url = reverse( "recipes:v1:action-implementation", kwargs={"name": action.name, "impl_hash": action.implementation_hash}, ) assert res.status_code == 200 assert res.data == { "count": 1, "next": None, "previous": None, "results": [ { "id": action.id, "name": "foo", "implementation_url": Whatever.endswith(action_url), "arguments_schema": {"type": "object"}, } ], } def test_it_serves_actions_without_implementation(self, api_client): action = ActionFactory( name="foo-remote", implementation=None, arguments_schema={"type": "object"} ) res = api_client.get("/api/v3/action/") assert res.status_code == 200 assert res.data["results"] == [ { "id": action.id, "name": "foo-remote", "implementation_url": None, "arguments_schema": {"type": "object"}, } ] def test_list_view_includes_cache_headers(self, api_client): res = api_client.get("/api/v3/action/") assert res.status_code == 200 # It isn't important to assert a particular value for max-age assert "max-age=" in res["Cache-Control"] assert "public" in res["Cache-Control"] def test_detail_view_includes_cache_headers(self, api_client): action = ActionFactory() res = api_client.get("/api/v3/action/{id}/".format(id=action.id)) assert res.status_code == 200 # It isn't important to assert a particular value for max-age assert "max-age=" in res["Cache-Control"] assert "public" in res["Cache-Control"] def test_list_sets_no_cookies(self, api_client): res = api_client.get("/api/v3/action/") assert res.status_code == 200 assert "Cookies" not in res def test_detail_sets_no_cookies(self, api_client): action = ActionFactory() res = api_client.get("/api/v3/action/{id}/".format(id=action.id)) assert res.status_code == 200 assert res.client.cookies == {} @pytest.mark.django_db class TestRecipeAPI(object): @pytest.mark.django_db class TestListing(object): def test_it_works(self, api_client): res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 assert res.data["results"] == [] def test_it_serves_recipes(self, api_client): recipe = RecipeFactory() res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 assert res.data["results"][0]["latest_revision"]["name"] == recipe.latest_revision.name def test_available_if_admin_enabled(self, api_client, settings): settings.ADMIN_ENABLED = True res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 assert res.data["results"] == [] def test_readonly_if_admin_disabled(self, api_client, settings): settings.ADMIN_ENABLED = False res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 recipe = RecipeFactory(name="unchanged") res = api_client.patch("/api/v3/recipe/%s/" % recipe.id, {"name": "changed"}) assert res.status_code == 403 assert res.data["detail"] == AdminEnabledOrReadOnly.message def test_list_view_includes_cache_headers(self, api_client): res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 # It isn't important to assert a particular value for max_age assert "max-age=" in res["Cache-Control"] assert "public" in res["Cache-Control"] def test_list_sets_no_cookies(self, api_client): res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 assert "Cookies" not in res def test_list_can_filter_baseline_recipes( self, rs_settings, api_client, mocked_remotesettings ): recipe1 = RecipeFactory( extra_capabilities=[], approver=UserFactory(), enabler=UserFactory() ) rs_settings.BASELINE_CAPABILITIES |= recipe1.latest_revision.capabilities assert recipe1.latest_revision.uses_only_baseline_capabilities() recipe2 = RecipeFactory( extra_capabilities=["test-capability"], approver=UserFactory(), enabler=UserFactory(), ) assert not recipe2.latest_revision.uses_only_baseline_capabilities() # Only approved recipes are considered as part of uses_only_baseline_capabilities recipe3 = RecipeFactory(extra_capabilities=[]) rs_settings.BASELINE_CAPABILITIES |= recipe3.latest_revision.capabilities assert recipe3.latest_revision.uses_only_baseline_capabilities() res = api_client.get("/api/v3/recipe/") assert res.status_code == 200 assert res.data["count"] == 3 res = api_client.get("/api/v3/recipe/?uses_only_baseline_capabilities=true") assert res.status_code == 200 assert res.data["count"] == 1 assert res.data["results"][0]["id"] == recipe1.id @pytest.mark.django_db class TestCreation(object): def test_it_can_create_recipes(self, api_client): action = ActionFactory() # Enabled recipe res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", "enabled": True, }, ) assert res.status_code == 201, res.json() recipes = Recipe.objects.all() assert recipes.count() == 1 def test_it_can_create_recipes_actions_without_implementation(self, api_client): action = ActionFactory(implementation=None) assert action.implementation is None # Enabled recipe res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", "enabled": True, }, ) assert res.status_code == 201 (recipe,) = Recipe.objects.all() assert recipe.latest_revision.action.implementation is None def test_it_can_create_disabled_recipes(self, api_client): action = ActionFactory() # Disabled recipe res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", "enabled": False, }, ) assert res.status_code == 201 recipes = Recipe.objects.all() assert recipes.count() == 1 def test_creation_when_action_does_not_exist(self, api_client): res = api_client.post( "/api/v3/recipe/", {"name": "Test Recipe", "action_id": 1234, "arguments": {}} ) assert res.status_code == 400 assert res.json()["action_id"] == [ serializers.PrimaryKeyRelatedField.default_error_messages["does_not_exist"].format( pk_value=1234 ) ] recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_action_id_is_missing(self, api_client): res = api_client.post("/api/v3/recipe/", {"name": "Test Recipe", "arguments": {}}) assert res.status_code == 400 assert res.json()["action_id"] == [ serializers.PrimaryKeyRelatedField.default_error_messages["required"] ] recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_action_id_is_invalid(self, api_client): res = api_client.post( "/api/v3/recipe/", {"name": "Test Recipe", "action_id": "a string", "arguments": {}}, ) assert res.status_code == 400 assert res.json()["action_id"] == [ serializers.PrimaryKeyRelatedField.default_error_messages["incorrect_type"].format( data_type="str" ) ] recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_arguments_are_invalid(self, api_client): action = ActionFactory( name="foobarbaz", arguments_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, ) res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "enabled": True, "extra_filter_expression": "true", "action_id": action.id, "arguments": {"message": ""}, }, ) assert res.status_code == 400 assert res.json()["arguments"]["message"] == ( serializers.CharField.default_error_messages["blank"] ) recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_arguments_is_missing(self, api_client): action = ActionFactory( name="foobarbaz", arguments_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, ) res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "enabled": True, "extra_filter_expression": "true", "action_id": action.id, }, ) assert res.status_code == 400 assert res.json()["arguments"] == [ serializers.PrimaryKeyRelatedField.default_error_messages["required"] ] recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_arguments_is_a_string(self, api_client): action = ActionFactory( name="foobarbaz", arguments_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, ) data = { "name": "Test Recipe", "enabled": True, "extra_filter_expression": "true", "action_id": action.id, "arguments": '{"message": "the message"}', } res = api_client.post("/api/v3/recipe/", data) assert res.status_code == 400 assert res.data == {"arguments": ["Must be an object."]} recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_action_id_is_a_string_and_arguments_are_invalid(self, api_client): action = ActionFactory( name="foobarbaz", arguments_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, ) data = { "name": "Test Recipe", "enabled": True, "extra_filter_expression": "true", "action_id": f"{action.id}", "arguments": {}, } res = api_client.post("/api/v3/recipe/", data) assert res.status_code == 400 assert res.data == {"arguments": {"message": "This field may not be blank."}} recipes = Recipe.objects.all() assert recipes.count() == 0 def test_creation_when_identicon_seed_is_invalid(self, api_client): action = ActionFactory() res = api_client.post( "/api/v3/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_at_least_one_filter_is_required(self, api_client): action = ActionFactory() res = api_client.post( "/api/v3/recipe/", {"name": "Test Recipe", "action_id": action.id, "arguments": {}, "enabled": True}, ) assert res.status_code == 400, res.json() assert res.json() == { "non_field_errors": ["one of extra_filter_expression or filter_object is required"] } def test_with_experimenter_slug(self, api_client): action = ActionFactory() res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", "enabled": True, "experimenter_slug": "some-experimenter-slug", }, ) assert res.status_code == 201, res.json() recipe = Recipe.objects.get() assert recipe.latest_revision.experimenter_slug == "some-experimenter-slug" def test_without_experimenter_slug(self, api_client): action = ActionFactory() res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", "enabled": True, }, ) assert res.status_code == 201, res.json() recipe = Recipe.objects.get() assert recipe.latest_revision.experimenter_slug is None def test_creating_recipes_stores_the_user(self, api_client): action = ActionFactory() api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": "whatever", }, ) recipe = Recipe.objects.get() assert recipe.latest_revision.user is not None def test_it_can_create_recipes_with_only_filter_object(self, api_client): action = ActionFactory() channel = ChannelFactory() res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "extra_filter_expression": " ", "filter_object": [{"type": "channel", "channels": [channel.slug]}], "enabled": True, }, ) assert res.status_code == 201, res.json() assert Recipe.objects.count() == 1 recipe = Recipe.objects.get() assert recipe.latest_revision.extra_filter_expression == "" assert ( recipe.latest_revision.filter_expression == f'normandy.channel in ["{channel.slug}"]' ) def test_it_can_create_extra_filter_expression_omitted(self, api_client): action = ActionFactory() channel = ChannelFactory() # First try to create a recipe with 0 filter objects. res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "filter_object": [], "enabled": True, }, ) assert res.status_code == 400 assert res.json()["non_field_errors"] == [ "one of extra_filter_expression or filter_object is required" ] # Setting at least some filter_object but omitting the extra_filter_expression. res = api_client.post( "/api/v3/recipe/", { "name": "Test Recipe", "action_id": action.id, "arguments": {}, "filter_object": [{"type": "channel", "channels": [channel.slug]}], "enabled": True, }, ) assert res.status_code == 201, res.json() assert Recipe.objects.count() == 1 recipe = Recipe.objects.get() assert recipe.latest_revision.extra_filter_expression == "" assert ( recipe.latest_revision.filter_expression == f'normandy.channel in ["{channel.slug}"]' ) def test_it_accepts_capabilities(self, api_client): action = ActionFactory() res = api_client.post( "/api/v3/recipe/", { "action_id": action.id, "extra_capabilities": ["test.one", "test.two"], "arguments": {}, "name": "test recipe", "extra_filter_expression": "true", }, ) assert res.status_code == 201, res.json() assert Recipe.objects.count() == 1 recipe = Recipe.objects.get() # Passed extra capabilities: assert recipe.latest_revision.extra_capabilities == ["test.one", "test.two"] # Extra capabilities get included in capabilities assert {"test.one", "test.two"} <= set(recipe.latest_revision.capabilities) @pytest.mark.django_db class TestUpdates(object): def test_it_can_edit_recipes(self, api_client): recipe = RecipeFactory( name="unchanged", extra_filter_expression="true", filter_object_json=None ) old_revision_id = recipe.latest_revision.id res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, {"name": "changed", "extra_filter_expression": "false"}, ) assert res.status_code == 200 recipe = Recipe.objects.all()[0] assert recipe.latest_revision.name == "changed" assert recipe.latest_revision.filter_expression == "false" assert recipe.latest_revision.id != old_revision_id def test_it_can_change_action_for_recipes(self, api_client): recipe = RecipeFactory() action = ActionFactory() res = api_client.patch("/api/v3/recipe/%s/" % recipe.id, {"action_id": action.id}) assert res.status_code == 200 recipe = Recipe.objects.get(pk=recipe.id) assert recipe.latest_revision.action == action def test_it_can_change_arguments_for_recipes(self, api_client): recipe = RecipeFactory(arguments_json="{}") action = ActionFactory( name="foobarbaz", arguments_schema={ "type": "object", "properties": {"message": {"type": "string"}, "checkbox": {"type": "boolean"}}, "required": ["message", "checkbox"], }, ) arguments = {"message": "test message", "checkbox": False} res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, {"action_id": action.id, "arguments": arguments} ) assert res.status_code == 200, res.json() recipe.refresh_from_db() assert recipe.latest_revision.arguments == arguments res = api_client.get("/api/v3/recipe/%s/" % recipe.id) assert res.status_code == 200, res.json() assert res.json()["latest_revision"]["arguments"] == arguments arguments = {"message": "second message", "checkbox": True} res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, {"action_id": action.id, "arguments": arguments} ) assert res.status_code == 200, res.json() recipe.refresh_from_db() assert recipe.latest_revision.arguments == arguments res = api_client.get("/api/v3/recipe/%s/" % recipe.id) assert res.status_code == 200, res.json() assert res.json()["latest_revision"]["arguments"] == arguments def test_it_can_delete_recipes(self, api_client): recipe = RecipeFactory() res = api_client.delete("/api/v3/recipe/%s/" % recipe.id) assert res.status_code == 204 recipes = Recipe.objects.all() assert recipes.count() == 0 def test_update_recipe_action(self, api_client): r = RecipeFactory() a = ActionFactory(name="test") res = api_client.patch(f"/api/v3/recipe/{r.pk}/", {"action_id": a.id}) assert res.status_code == 200 r.refresh_from_db() assert r.latest_revision.action == a def test_update_recipe_comment(self, api_client): r = RecipeFactory(comment="foo") res = api_client.patch(f"/api/v3/recipe/{r.pk}/", {"comment": "bar"}) assert res.status_code == 200 r.refresh_from_db() assert r.latest_revision.comment == "bar" def test_update_recipe_experimenter_slug(self, api_client): r = RecipeFactory() res = api_client.patch(f"/api/v3/recipe/{r.pk}/", {"experimenter_slug": "a-new-slug"}) assert res.status_code == 200 r.refresh_from_db() assert r.latest_revision.experimenter_slug == "a-new-slug" def test_updating_recipes_stores_the_user(self, api_client): recipe = RecipeFactory() api_client.patch(f"/api/v3/recipe/{recipe.pk}/", {"name": "Test Recipe"}) recipe.refresh_from_db() assert recipe.latest_revision.user is not None def test_it_can_update_recipes_with_only_filter_object(self, api_client): recipe = RecipeFactory(name="unchanged", extra_filter_expression="true") channel = ChannelFactory() res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, { "name": "changed", "extra_filter_expression": "", "filter_object": [{"type": "channel", "channels": [channel.slug]}], }, ) assert res.status_code == 200, res.json() recipe.refresh_from_db() assert recipe.latest_revision.extra_filter_expression == "" assert recipe.latest_revision.filter_object assert ( recipe.latest_revision.filter_expression == f'normandy.channel in ["{channel.slug}"]' ) # And you can omit it too res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, { "name": "changed", "filter_object": [{"type": "channel", "channels": [channel.slug]}], }, ) assert res.status_code == 200, res.json() recipe.refresh_from_db() assert recipe.latest_revision.extra_filter_expression == "" # Let's paranoid-check that you can't unset the filter_object too. res = api_client.patch( "/api/v3/recipe/%s/" % recipe.id, {"name": "changed", "filter_object": []} ) assert res.status_code == 400 assert res.json()["non_field_errors"] == [ "if extra_filter_expression is blank, at least one filter_object is required" ] def test_it_can_update_capabilities(self, api_client): recipe = RecipeFactory(extra_capabilities=["always", "original"]) res = api_client.patch( f"/api/v3/recipe/{recipe.id}/", {"extra_capabilities": ["always", "changed"]} ) assert res.status_code == 200 recipe = Recipe.objects.get() assert {"always", "changed"} <= set(recipe.latest_revision.capabilities) assert "original" not in recipe.latest_revision.capabilities @pytest.mark.django_db class TestFilterObjects(object): def make_recipe(self, api_client, **kwargs): data = { "name": "Test Recipe", "action_id": ActionFactory().id, "arguments": {}, "enabled": True, "extra_filter_expression": "true", "filter_object": [], } data.update(kwargs) return api_client.post("/api/v3/recipe/", data) def test_bad_filter_objects(self, api_client): res = self.make_recipe(api_client, filter_object={}) # not a list assert res.status_code == 400 assert res.json() == { "filter_object": ['Expected a list of items but got type "dict".'] } res = self.make_recipe( api_client, filter_object=["1 + 1 == 2"] ) # not a list of objects assert res.status_code == 400 assert res.json() == { "filter_object": { "0": {"non field errors": ["filter_object members must be objects."]} } } res = self.make_recipe( api_client, filter_object=[{"channels": ["release"]}] ) # type is required assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"type": ["This field is required."]}}} def test_validate_filter_objects_channels(self, api_client): res = self.make_recipe( api_client, filter_object=[{"type": "channel", "channels": ["nightwolf"]}] ) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"channels": ["Unrecognized channel slug 'nightwolf'"]}} } ChannelFactory(slug="nightwolf") res = self.make_recipe( api_client, filter_object=[{"type": "channel", "channels": ["nightwolf"]}] ) assert res.status_code == 201 def test_validate_filter_objects_locales(self, api_client): res = self.make_recipe( api_client, filter_object=[{"type": "locale", "locales": ["sv"]}] ) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"locales": ["Unrecognized locale code 'sv'"]}} } LocaleFactory(code="sv") res = self.make_recipe( api_client, filter_object=[{"type": "locale", "locales": ["sv"]}] ) assert res.status_code == 201 def test_validate_filter_objects_countries(self, api_client): res = self.make_recipe( api_client, filter_object=[{"type": "country", "countries": ["SS"]}] ) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"countries": ["Unrecognized country code 'SS'"]}} } CountryFactory(code="SS", name="South Sudan") res = self.make_recipe( api_client, filter_object=[{"type": "country", "countries": ["SS"]}] ) assert res.status_code == 201 def test_channel_works(self, api_client): channel1 = ChannelFactory(slug="beta") channel2 = ChannelFactory(slug="release") res = self.make_recipe( api_client, filter_object=[{"type": "channel", "channels": [channel1.slug, channel2.slug]}], ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( f'(normandy.channel in ["{channel1.slug}","{channel2.slug}"]) && (true)' ) def test_channel_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "channel"}]) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"channels": ["This field is required."]}} } def test_locale_works(self, api_client): locale1 = LocaleFactory() locale2 = LocaleFactory(code="de") res = self.make_recipe( api_client, filter_object=[{"type": "locale", "locales": [locale1.code, locale2.code]}], ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( f'(normandy.locale in ["{locale1.code}","{locale2.code}"]) && (true)' ) def test_locale_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "locale"}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"locales": ["This field is required."]}}} def test_country_works(self, api_client): country1 = CountryFactory() country2 = CountryFactory(code="DE") res = self.make_recipe( api_client, filter_object=[{"type": "country", "countries": [country1.code, country2.code]}], ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( f'(normandy.country in ["{country1.code}","{country2.code}"]) && (true)' ) def test_country_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "country"}]) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"countries": ["This field is required."]}} } def test_bucket_sample_works(self, api_client): res = self.make_recipe( api_client, filter_object=[ { "type": "bucketSample", "start": 1, "count": 2, "total": 3, "input": ["normandy.userId", "normandy.recipeId"], } ], ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( "([normandy.userId,normandy.recipeId]|bucketSample(1,2,3)) && (true)" ) def test_bucket_sample_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "bucketSample"}]) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "start": ["This field is required."], "count": ["This field is required."], "total": ["This field is required."], "input": ["This field is required."], } } } res = self.make_recipe( api_client, filter_object=[{"type": "bucketSample", "start": "a", "count": -1, "total": -2}], ) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "start": ["A valid number is required."], "count": ["Ensure this value is greater than or equal to 0."], "total": ["Ensure this value is greater than or equal to 0."], "input": ["This field is required."], } } } def test_stable_sample_works(self, api_client): res = self.make_recipe( api_client, filter_object=[ { "type": "stableSample", "rate": 0.5, "input": ["normandy.userId", "normandy.recipeId"], } ], ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( "([normandy.userId,normandy.recipeId]|stableSample(0.5)) && (true)" ) def test_stable_sample_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "stableSample"}]) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "rate": ["This field is required."], "input": ["This field is required."], } } } res = self.make_recipe( api_client, filter_object=[{"type": "stableSample", "rate": 10}] ) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "rate": ["Ensure this value is less than or equal to 1."], "input": ["This field is required."], } } } def test_version_works(self, api_client): res = self.make_recipe( api_client, filter_object=[{"type": "version", "versions": [57, 58]}] ) assert res.status_code == 201, res.json() recipe_data = res.json() Recipe.objects.get(id=recipe_data["id"]) assert recipe_data["latest_revision"]["filter_expression"] == ( '((normandy.version>="57"&&normandy.version<"58")||' '(normandy.version>="58"&&normandy.version<"59")) && (true)' ) def test_version_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "version"}]) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"versions": ["This field is required."]}} } def test_invalid_filter(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "invalid"}]) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"type": ['Unknown filter object type "invalid".']}} } @pytest.mark.django_db class TestDetail(object): def test_history(self, api_client): recipe = RecipeFactory(name="version 1") recipe.revise(name="version 2") recipe.revise(name="version 3") res = api_client.get("/api/v3/recipe/%s/history/" % recipe.id) assert res.data[0]["name"] == "version 3" assert res.data[1]["name"] == "version 2" assert res.data[2]["name"] == "version 1" def test_it_can_enable_recipes(self, api_client): recipe = RecipeFactory(approver=UserFactory()) res = api_client.post("/api/v3/recipe/%s/enable/" % recipe.id) assert res.status_code == 200 assert res.data["approved_revision"]["enabled"] is True recipe = Recipe.objects.all()[0] assert recipe.approved_revision.enabled def test_cannot_enable_unapproved_recipes(self, api_client): recipe = RecipeFactory() res = api_client.post("/api/v3/recipe/%s/enable/" % recipe.id) assert res.status_code == 409 assert res.data["error"] == "Cannot enable a recipe that is not approved." def test_cannot_enable_enabled_recipes(self, api_client): recipe = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) res = api_client.post("/api/v3/recipe/%s/enable/" % recipe.id) assert res.status_code == 409 assert res.data["error"] == "This revision is already enabled." def test_it_can_disable_enabled_recipes(self, api_client): recipe = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) assert recipe.approved_revision.enabled res = api_client.post("/api/v3/recipe/%s/disable/" % recipe.id) assert res.status_code == 200 assert res.data["approved_revision"]["enabled"] is False recipe = Recipe.objects.all()[0] assert not recipe.approved_revision.enabled # Can't disable it a second time. res = api_client.post("/api/v3/recipe/%s/disable/" % recipe.id) assert res.status_code == 409 assert res.json()["error"] == "This revision is already disabled." def test_detail_view_includes_cache_headers(self, api_client): recipe = RecipeFactory() res = api_client.get(f"/api/v3/recipe/{recipe.id}/") assert res.status_code == 200 # It isn't important to assert a particular value for max-age assert "max-age=" in res["Cache-Control"] assert "public" in res["Cache-Control"] def test_detail_sets_no_cookies(self, api_client): recipe = RecipeFactory() res = api_client.get("/api/v3/recipe/{id}/".format(id=recipe.id)) assert res.status_code == 200 assert res.client.cookies == {} @pytest.mark.django_db class TestFiltering(object): def test_filtering_by_enabled_lowercase(self, api_client): r1 = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) RecipeFactory() res = api_client.get("/api/v3/recipe/?enabled=true") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] def test_filtering_by_enabled_fuzz(self, api_client): """ Test that we don't return 500 responses when we get unexpected boolean filters. This was a real case that showed up in our error logging. """ url = ( "/api/v3/recipe/?enabled=javascript%3a%2f*" "<%2fscript><svg%2fonload%3d'%2b%2f'%2f%2b" ) res = api_client.get(url) assert res.status_code == 200 def test_list_filter_status(self, api_client): r1 = RecipeFactory() r2 = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) res = api_client.get("/api/v3/recipe/?status=enabled") assert res.status_code == 200 results = res.data["results"] assert len(results) == 1 assert results[0]["id"] == r2.id res = api_client.get("/api/v3/recipe/?status=disabled") assert res.status_code == 200 results = res.data["results"] assert len(results) == 1 assert results[0]["id"] == r1.id def test_list_filter_text(self, api_client): r1 = RecipeFactory(name="first", extra_filter_expression="1 + 1 == 2") r2 = RecipeFactory(name="second", extra_filter_expression="one + one == two") res = api_client.get("/api/v3/recipe/?text=first") assert res.status_code == 200 results = res.data["results"] assert len(results) == 1 assert results[0]["id"] == r1.id res = api_client.get("/api/v3/recipe/?text=one") assert res.status_code == 200 results = res.data["results"] assert len(results) == 1 assert results[0]["id"] == r2.id res = api_client.get("/api/v3/recipe/?text=t") assert res.status_code == 200 results = res.data["results"] assert len(results) == 2 for recipe in results: assert recipe["id"] in [r1.id, r2.id] def test_list_filter_text_null_bytes(self, api_client): res = api_client.get("/api/v3/recipe/?text=\x00") assert res.status_code == 400 assert res.json()["detail"] == "Null bytes in text" def test_search_works_with_arguments(self, api_client): r1 = RecipeFactory(arguments={"one": 1}) r2 = RecipeFactory(arguments={"two": 2}) res = api_client.get("/api/v3/recipe/?text=one") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] res = api_client.get("/api/v3/recipe/?text=2") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id] def test_search_out_of_order(self, api_client): r1 = RecipeFactory(name="apple banana") r2 = RecipeFactory(name="cherry daikon") res = api_client.get("/api/v3/recipe/?text=banana apple") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] res = api_client.get("/api/v3/recipe/?text=daikon cherry") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id] def test_search_all_words_required(self, api_client): r1 = RecipeFactory(name="apple banana") RecipeFactory(name="apple") res = api_client.get("/api/v3/recipe/?text=apple banana") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] 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/v3/recipe/?latest_revision__action={a1.id}") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] res = api_client.get(f"/api/v3/recipe/?latest_revision__action={a2.id}") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id] assert a1.id != -1 and a2.id != -1 res = api_client.get("/api/v3/recipe/?latest_revision__action=-1") assert res.status_code == 400 assert res.data["latest_revision__action"][0].code == "invalid_choice" 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/v3/recipe/?action={a1.name}") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id] res = api_client.get(f"/api/v3/recipe/?action={a2.name}") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id] assert a1.name != "nonexistant" and a2.name != "nonexistant" res = api_client.get("/api/v3/recipe/?action=nonexistant") assert res.status_code == 200 assert res.data["count"] == 0 def test_filter_by_experimenter_slug(self, api_client): RecipeFactory() match1 = RecipeFactory(experimenter_slug="a-slug") RecipeFactory(experimenter_slug="something-else") RecipeFactory(experimenter_slug="some-other-slug") res = api_client.get("/api/v3/recipe/?experimenter_slug=a-slug") assert res.status_code == 200 assert res.data["count"] == 1 assert set(r["id"] for r in res.data["results"]) == set([match1.id]) def test_order_last_updated(self, api_client): r1 = RecipeFactory() r2 = RecipeFactory() now = r1.latest_revision.updated yesterday = now - timedelta(days=1) r1.latest_revision.updated = yesterday r2.latest_revision.updated = now # Call the super class's save method so that # `latest_revision.updated` doesn't get rewritten super(RecipeRevision, r1.latest_revision).save() super(RecipeRevision, r2.latest_revision).save() res = api_client.get("/api/v3/recipe/?ordering=last_updated") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id, r2.id] res = api_client.get("/api/v3/recipe/?ordering=-last_updated") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id, r1.id] def test_order_name(self, api_client): r1 = RecipeFactory(name="a") r2 = RecipeFactory(name="b") res = api_client.get("/api/v3/recipe/?ordering=name") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r1.id, r2.id] res = api_client.get("/api/v3/recipe/?ordering=-name") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == [r2.id, r1.id] def test_order_by_action_name(self, api_client): r1 = RecipeFactory(name="a") r1.latest_revision.action.name = "Bee" r1.latest_revision.action.save() r2 = RecipeFactory(name="b") r2.latest_revision.action.name = "Cee" r2.latest_revision.action.save() r3 = RecipeFactory(name="c") r3.latest_revision.action.name = "Ahh" r3.latest_revision.action.save() res = api_client.get("/api/v3/recipe/?ordering=action") assert res.status_code == 200 # Expected order is ['Ahh', 'Bee', 'Cee'] assert [r["id"] for r in res.data["results"]] == [r3.id, r1.id, r2.id] res = api_client.get("/api/v3/recipe/?ordering=-action") assert res.status_code == 200 # Expected order is ['Cee', 'Bee', 'Ahh'] assert [r["id"] for r in res.data["results"]] == [r2.id, r1.id, r3.id] def test_order_bogus(self, api_client): """Test that filtering by an unknown key doesn't change the sort order""" RecipeFactory(name="a") RecipeFactory(name="b") res = api_client.get("/api/v3/recipe/?ordering=bogus") assert res.status_code == 200 first_ordering = [r["id"] for r in res.data["results"]] res = api_client.get("/api/v3/recipe/?ordering=-bogus") assert res.status_code == 200 assert [r["id"] for r in res.data["results"]] == first_ordering @pytest.mark.django_db class TestRecipeRevisionAPI(object): def test_it_works(self, api_client): res = api_client.get("/api/v3/recipe_revision/") assert res.status_code == 200 assert res.data == {"count": 0, "next": None, "previous": None, "results": []} def test_it_serves_revisions(self, api_client): recipe = RecipeFactory() res = api_client.get("/api/v3/recipe_revision/%s/" % recipe.latest_revision.id) assert res.status_code == 200 assert res.data["id"] == recipe.latest_revision.id def test_request_approval(self, api_client): recipe = RecipeFactory() res = api_client.post( "/api/v3/recipe_revision/{}/request_approval/".format(recipe.latest_revision.id) ) assert res.status_code == 201 assert res.data["id"] == recipe.latest_revision.approval_request.id def test_cannot_open_second_approval_request(self, api_client): recipe = RecipeFactory() ApprovalRequestFactory(revision=recipe.latest_revision) res = api_client.post( "/api/v3/recipe_revision/{}/request_approval/".format(recipe.latest_revision.id) ) assert res.status_code == 400 def test_it_has_an_identicon_seed(self, api_client): recipe = RecipeFactory(enabler=UserFactory(), approver=UserFactory()) res = api_client.get(f"/api/v3/recipe_revision/{recipe.latest_revision.id}/") assert res.data["identicon_seed"] == recipe.latest_revision.identicon_seed @pytest.mark.django_db class TestApprovalRequestAPI(object): def test_it_works(self, api_client): res = api_client.get("/api/v3/approval_request/") assert res.status_code == 200 assert res.data == {"count": 0, "next": None, "previous": None, "results": []} def test_approve(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post( "/api/v3/approval_request/{}/approve/".format(a.id), {"comment": "r+"} ) assert res.status_code == 200 r.refresh_from_db() assert r.is_approved assert r.approved_revision.approval_request.comment == "r+" def test_approve_no_comment(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post("/api/v3/approval_request/{}/approve/".format(a.id)) assert res.status_code == 400 assert res.data["comment"] == "This field is required." def test_approve_not_actionable(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) a.approve(UserFactory(), "r+") res = api_client.post( "/api/v3/approval_request/{}/approve/".format(a.id), {"comment": "r+"} ) assert res.status_code == 400 assert res.data["error"] == "This approval request has already been approved or rejected." def test_reject(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post( "/api/v3/approval_request/{}/reject/".format(a.id), {"comment": "r-"} ) assert res.status_code == 200 r.latest_revision.approval_request.refresh_from_db() assert r.latest_revision.approval_status == r.latest_revision.REJECTED assert r.latest_revision.approval_request.comment == "r-" def test_reject_no_comment(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post("/api/v3/approval_request/{}/reject/".format(a.id)) assert res.status_code == 400 assert res.data["comment"] == "This field is required." def test_reject_not_actionable(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) a.approve(UserFactory(), "r+") res = api_client.post( "/api/v3/approval_request/{}/reject/".format(a.id), {"comment": "-r"} ) assert res.status_code == 400 assert res.data["error"] == "This approval request has already been approved or rejected." def test_close(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post("/api/v3/approval_request/{}/close/".format(a.id)) assert res.status_code == 204 with pytest.raises(ApprovalRequest.DoesNotExist): ApprovalRequest.objects.get(pk=a.pk) @pytest.mark.django_db class TestApprovalFlow(object): def verify_signatures(self, api_client, expected_count=None): # v1 usage here is correct, since v3 doesn't yet provide signatures res = api_client.get("/api/v1/recipe/signed/") assert res.status_code == 200 signed_data = res.json() if expected_count is not None: assert len(signed_data) == expected_count for recipe_and_signature in signed_data: recipe = recipe_and_signature["recipe"] expected_signature = recipe_and_signature["signature"]["signature"] data = canonical_json_dumps(recipe).encode() actual_signature = fake_sign([data])[0]["signature"] assert actual_signature == expected_signature def test_full_approval_flow(self, settings, api_client, mocked_autograph): # The `mocked_autograph` fixture is provided so that recipes can be signed settings.PEER_APPROVAL_ENFORCED = True action = ActionFactory() user1 = UserFactory(is_superuser=True) user2 = UserFactory(is_superuser=True) api_client.force_authenticate(user1) settings.BASELINE_CAPABILITIES |= action.capabilities # Create a recipe res = api_client.post( "/api/v3/recipe/", { "action_id": action.id, "arguments": {}, "name": "test recipe", "extra_filter_expression": "counter == 0", "enabled": "false", }, ) assert res.status_code == 201, res.data recipe_data_0 = res.json() # It is visible in the api but not approved res = api_client.get(f"/api/v3/recipe/{recipe_data_0['id']}/") assert res.status_code == 200 assert res.json()["latest_revision"] is not None assert res.json()["approved_revision"] is None # Request approval for it res = api_client.post( "/api/v3/recipe_revision/{}/request_approval/".format( recipe_data_0["latest_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/v3/approval_request/{}/approve/".format(approval_data["id"]), {"comment": "r+"} ) assert res.status_code == 403 # Forbidden # Approve and enable the recipe api_client.force_authenticate(user2) res = api_client.post( "/api/v3/approval_request/{}/approve/".format(approval_data["id"]), {"comment": "r+"} ) assert res.status_code == 200 res = api_client.post("/api/v3/recipe/{}/enable/".format(recipe_data_0["id"])) assert res.status_code == 200 # It is now visible in the API as approved and signed res = api_client.get("/api/v3/recipe/{}/".format(recipe_data_0["id"])) assert res.status_code == 200 recipe_data_1 = res.json() assert recipe_data_1["approved_revision"] is not None assert ( Recipe.objects.get(id=recipe_data_1["id"]).latest_revision.capabilities <= settings.BASELINE_CAPABILITIES ) self.verify_signatures(api_client, expected_count=1) # Make another change api_client.force_authenticate(user1) res = api_client.patch( "/api/v3/recipe/{}/".format(recipe_data_1["id"]), {"extra_filter_expression": "counter == 1"}, ) assert res.status_code == 200 # The change should only be seen in the latest revision, not the approved res = api_client.get("/api/v3/recipe/{}/".format(recipe_data_1["id"])) assert res.status_code == 200 recipe_data_2 = res.json() assert recipe_data_2["approved_revision"]["extra_filter_expression"] == "counter == 0" assert recipe_data_2["latest_revision"]["extra_filter_expression"] == "counter == 1" self.verify_signatures(api_client, expected_count=1) # Request approval for the change res = api_client.post( "/api/v3/recipe_revision/{}/request_approval/".format( recipe_data_2["latest_revision"]["id"] ) ) approval_data = res.json() recipe_data_2["latest_revision"]["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/v3/recipe/{}/".format(recipe_data_1["id"])) assert res.status_code == 200 assert res.json()["approved_revision"] == recipe_data_2["approved_revision"] assert res.json()["latest_revision"] == recipe_data_2["latest_revision"] self.verify_signatures(api_client, expected_count=1) # Can't reject your own approval res = api_client.post( "/api/v3/approval_request/{}/reject/".format(approval_data["id"]), {"comment": "r-"} ) assert res.status_code == 403 assert res.json()["error"] == "You cannot reject your own approval request." # Reject the change api_client.force_authenticate(user2) res = api_client.post( "/api/v3/approval_request/{}/reject/".format(approval_data["id"]), {"comment": "r-"} ) approval_data = res.json() recipe_data_2["approval_request"] = approval_data recipe_data_2["latest_revision"]["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/v3/recipe/{}/".format(recipe_data_1["id"])) assert res.status_code == 200 assert res.json()["approved_revision"] == recipe_data_2["approved_revision"] assert res.json()["latest_revision"] == recipe_data_2["latest_revision"] 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/v3/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/v3/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/v3/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/v3/recipe/{}/".format(recipe_data_1["id"])) assert res.status_code == 200 recipe_data_4 = res.json() assert recipe_data_4["approved_revision"]["extra_filter_expression"] == "counter == 2" self.verify_signatures(api_client, expected_count=1) def test_cancel_approval(self, api_client, mocked_autograph, settings): action = ActionFactory() settings.BASELINE_CAPABILITIES |= action.capabilities user1 = UserFactory(is_superuser=True) user2 = UserFactory(is_superuser=True) api_client.force_authenticate(user1) # Create a recipe res = api_client.post( "/api/v3/recipe/", { "action_id": action.id, "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"] assert ( Recipe.objects.get(id=recipe_id).latest_revision.capabilities <= settings.BASELINE_CAPABILITIES ) # Request approval res = api_client.post(f"/api/v3/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/v3/approval_request/{approval_request_id}/approve/", {"comment": "r+"} ) assert res.status_code == 200 # The API shouldn't have any signed recipe yet self.verify_signatures(api_client, expected_count=0) # Enable the recipe res = api_client.post(f"/api/v3/recipe/{recipe_id}/enable/") assert res.status_code == 200 # The API should have correct signatures now self.verify_signatures(api_client, expected_count=1) # Make another change api_client.force_authenticate(user1) res = api_client.patch( f"/api/v3/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/v3/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/v3/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) @pytest.mark.django_db @pytest.mark.parametrize( "endpoint,Factory", [ ("/api/v3/action/", ActionFactory), ("/api/v3/recipe/", RecipeFactory), ("/api/v3/recipe_revision/", RecipeRevisionFactory), ("/api/v3/approval_request/", ApprovalRequestFactory), ], ) def test_apis_makes_a_reasonable_number_of_db_queries(endpoint, Factory, client, settings): # Naive versions of this view could easily make several queries # per item, which is very slow. Make sure that isn't the case. Factory.create_batch(100) queries = CaptureQueriesContext(connection) with queries: res = client.get(endpoint) assert res.status_code == 200 # Pagination naturally makes one query per item in the page. Anything # under `page_size * 2` isn't doing any additional queries per recipe. page_size = settings.REST_FRAMEWORK["PAGE_SIZE"] assert len(queries) < page_size * 2, queries class TestIdenticonAPI(object): def test_it_works(self, client): res = client.get("/api/v3/identicon/v1:foobar.svg") assert res.status_code == 200 def test_it_returns_the_same_output(self, client): res1 = client.get("/api/v3/identicon/v1:foobar.svg") res2 = client.get("/api/v3/identicon/v1:foobar.svg") assert res1.content == res2.content def test_it_returns_known_output(self, client): res = client.get("/api/v3/identicon/v1:foobar.svg") reference_svg = Path(settings.BASE_DIR).joinpath( "normandy", "recipes", "tests", "api", "v3", "foobar.svg" ) with open(reference_svg, "rb") as svg_file: assert svg_file.read() == res.content def test_includes_cache_headers(self, client): res = client.get("/api/v3/identicon/v1:foobar.svg") assert f"max-age={settings.IMMUTABLE_CACHE_TIME}" in res["Cache-Control"] assert "public" in res["Cache-Control"] assert "immutable" in res["Cache-Control"] def test_unrecognized_generation(self, client): res = client.get("/api/v3/identicon/v9:foobar.svg") assert res.status_code == 400 assert res.json()["error"] == "Invalid identicon generation, only v1 is supported." @pytest.mark.django_db class TestFilterObjects(object): def make_recipe(self, api_client, **kwargs): data = { "name": "Test Recipe", "action_id": ActionFactory().id, "arguments": {}, "enabled": True, } data.update(kwargs) return api_client.post("/api/v3/recipe/", data) def test_bad_filter_objects(self, api_client): res = self.make_recipe(api_client, filter_object={}) # not a list assert res.status_code == 400 assert res.json() == {"filter_object": ['Expected a list of items but got type "dict".']} res = self.make_recipe(api_client, filter_object=["1 + 1 == 2"]) # not a list of objects assert res.status_code == 400 assert res.json() == { "filter_object": { "0": {"non field errors": ["filter_object members must be objects."]} } } res = self.make_recipe(api_client, filter_object=[{"channels": ["release"]}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"type": ["This field is required."]}}} def test_channel_works(self, api_client): channel1 = ChannelFactory(slug="beta") channel2 = ChannelFactory(slug="release") res = self.make_recipe( api_client, filter_object=[{"type": "channel", "channels": [channel1.slug, channel2.slug]}], ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( f'normandy.channel in ["{channel1.slug}","{channel2.slug}"]' ) def test_channel_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "channel"}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"channels": ["This field is required."]}}} def test_locale_works(self, api_client): locale1 = LocaleFactory() locale2 = LocaleFactory(code="de") res = self.make_recipe( api_client, filter_object=[{"type": "locale", "locales": [locale1.code, locale2.code]}] ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( f'normandy.locale in ["{locale1.code}","{locale2.code}"]' ) def test_locale_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "locale"}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"locales": ["This field is required."]}}} def test_country_works(self, api_client): country1 = CountryFactory() country2 = CountryFactory(code="DE") res = self.make_recipe( api_client, filter_object=[{"type": "country", "countries": [country1.code, country2.code]}], ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( f'normandy.country in ["{country1.code}","{country2.code}"]' ) def test_country_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "country"}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"countries": ["This field is required."]}}} def test_bucket_sample_works(self, api_client): res = self.make_recipe( api_client, filter_object=[ { "type": "bucketSample", "start": 1, "count": 2, "total": 3, "input": ["normandy.userId", "normandy.recipeId"], } ], ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( "[normandy.userId,normandy.recipeId]|bucketSample(1,2,3)" ) def test_bucket_sample_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "bucketSample"}]) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "start": ["This field is required."], "count": ["This field is required."], "total": ["This field is required."], "input": ["This field is required."], } } } res = self.make_recipe( api_client, filter_object=[{"type": "bucketSample", "start": "a", "count": -1, "total": -2}], ) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "start": ["A valid number is required."], "count": ["Ensure this value is greater than or equal to 0."], "total": ["Ensure this value is greater than or equal to 0."], "input": ["This field is required."], } } } def test_stable_sample_works(self, api_client): res = self.make_recipe( api_client, filter_object=[ { "type": "stableSample", "rate": 0.5, "input": ["normandy.userId", "normandy.recipeId"], } ], ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( "[normandy.userId,normandy.recipeId]|stableSample(0.5)" ) def test_stable_sample_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "stableSample"}]) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": {"rate": ["This field is required."], "input": ["This field is required."]} } } res = self.make_recipe(api_client, filter_object=[{"type": "stableSample", "rate": 10}]) assert res.status_code == 400 assert res.json() == { "filter_object": { "0": { "rate": ["Ensure this value is less than or equal to 1."], "input": ["This field is required."], } } } def test_version_works(self, api_client): res = self.make_recipe( api_client, filter_object=[{"type": "version", "versions": [57, 58]}] ) assert res.status_code == 201, res.json() assert res.json()["latest_revision"]["filter_expression"] == ( '(normandy.version>="57"&&normandy.version<"58")||' '(normandy.version>="58"&&normandy.version<"59")' ) def test_version_correct_fields(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "version"}]) assert res.status_code == 400 assert res.json() == {"filter_object": {"0": {"versions": ["This field is required."]}}} def test_invalid_filter(self, api_client): res = self.make_recipe(api_client, filter_object=[{"type": "invalid"}]) assert res.status_code == 400 assert res.json() == { "filter_object": {"0": {"type": ['Unknown filter object type "invalid".']}} } @pytest.mark.django_db class TestFilters(object): def test_it_works(self, api_client): country = CountryFactory() locale = LocaleFactory() channel = ChannelFactory() res = api_client.get("/api/v3/filters/") assert res.status_code == 200, res.json() assert res.json() == { "countries": [{"key": country.code, "value": country.name}], "locales": [{"key": locale.code, "value": locale.name}], "channels": [{"key": channel.slug, "value": channel.name}], "status": [ {"key": "enabled", "value": "Enabled"}, {"key": "disabled", "value": "Disabled"}, ], }