def test_preference_experiments_unique_branch_values(self): action = ActionFactory(name="preference-experiment") arguments = { "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_enable_rollback_enable_rollout_invariance(self): rollout_recipe = RecipeFactory( name="Rollout", approver=UserFactory(), enabler=UserFactory(), action=ActionFactory(name="preference-rollout"), arguments={"slug": "myslug"}, ) assert rollout_recipe.enabled rollback_recipe = RecipeFactory( name="Rollback", action=ActionFactory(name="preference-rollback"), arguments={"rolloutSlug": "myslug"}, ) approval_request = rollback_recipe.latest_revision.request_approval( creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") with pytest.raises(ValidationError) as exc_info: rollback_recipe.approved_revision.enable(user=UserFactory()) assert exc_info.value.message == "Rollout recipe 'Rollout' is currently enabled" rollout_recipe.approved_revision.disable(user=UserFactory()) assert not rollout_recipe.enabled # Now it should be possible to enable the rollback recipe. rollback_recipe.approved_revision.enable(user=UserFactory()) assert rollback_recipe.enabled # Can't make up your mind. Now try to enable the rollout recipe again even though # the rollback recipe is enabled. with pytest.raises(ValidationError) as exc_info: rollout_recipe.approved_revision.enable(user=UserFactory()) assert exc_info.value.message == "Rollback recipe 'Rollback' is currently enabled"
def test_it_does_not_resign_up_to_date_actions(self, settings, mocked_autograph): a = ActionFactory(signed=True) a.signature.signature = "original signature" a.signature.save() call_command("update_action_signatures") a.refresh_from_db() assert a.signature.signature == "original signature"
def test_preference_exeriments_unique_branch_values(self): action = ActionFactory(name='preference-experiment') arguments = { '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) error = action.errors['duplicate_branch_value'] assert exc_info.value.detail == { 'arguments': { 'branches': { 2: { 'value': error } } } }
def test_it_resigns_signed_actions_with_force(self, mocked_autograph): a = ActionFactory(signed=True) a.signature.signature = 'old signature' a.signature.save() call_command('update_action_signatures', '--force') a.refresh_from_db() assert a.signature.signature != 'old signature'
def test_it_ignores_missing_actions(self, mock_action): dont_update_action = ActionFactory(name='dont-update-action', implementation='old') mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema) call_command('update_actions', 'missing-action') dont_update_action.refresh_from_db() assert dont_update_action.implementation == 'old'
def test_it_sends_metrics(self, settings, mocked_autograph): ActionFactory.create_batch(3, signed=False) with MetricsMock() as mm: call_command("update_action_signatures") mm.print_records() assert mm.has_record(GAUGE, stat="normandy.signing.actions.signed", value=3)
def test_it_signs_out_of_date_actions(self, settings, mocked_autograph): a = ActionFactory(signed=True) a.signature.timestamp -= timedelta(seconds=settings.AUTOGRAPH_SIGNATURE_MAX_AGE * 2) a.signature.signature = "old signature" a.signature.save() call_command("update_action_signatures") a.refresh_from_db() assert a.signature.signature != "old signature"
def test_no_errors(self): rollback_action = ActionFactory(name="preference-rollback") rollout_action = ActionFactory(name="preference-rollout") rollout_recipe = RecipeFactory(action=rollout_action, arguments={"slug": "test-rollout"}) # does not throw when saving the revision arguments = {"rolloutSlug": rollout_recipe.arguments["slug"]} RecipeFactory(action=rollback_action, arguments=arguments)
def test_unique_branch_slugs(self): action = ActionFactory(name="multi-preference-experiment") arguments = MultiPreferenceExperimentArgumentsFactory( branches=[{"slug": "unique"}, {"slug": "duplicate"}, {"slug": "duplicate"}], ) with pytest.raises(serializers.ValidationError) as exc_info: action.validate_arguments(arguments, RecipeRevisionFactory()) error = action.errors["duplicate_branch_slug"] assert exc_info.value.detail == {"arguments": {"branches": {2: {"slug": error}}}}
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_no_errors(self): rollback_action = ActionFactory(name="preference-rollback") assert rollback_action.arguments_schema != {} rollout_action = ActionFactory(name="preference-rollout") assert rollout_action.arguments_schema != {} rollout_recipe = RecipeFactory(action=rollout_action) # does not throw when saving the revision arguments = {"rolloutSlug": rollout_recipe.latest_revision.arguments["slug"]} RecipeFactory(action=rollback_action, arguments=arguments)
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_preference_exeriments_unique_branch_values(self): action = ActionFactory(name="preference-experiment") arguments = { "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_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_recipe_revise_partial(self): a1 = ActionFactory() recipe = RecipeFactory( name="unchanged", action=a1, arguments={"message": "something"}, extra_filter_expression="something !== undefined", filter_object_json=None, ) a2 = ActionFactory() recipe.revise(name="changed", action=a2) assert recipe.action == a2 assert recipe.name == "changed" assert recipe.arguments == {"message": "something"} assert recipe.filter_expression == "something !== undefined"
def test_update_signature(self, mocker, mock_logger): # Mock the Autographer mock_autograph = mocker.patch("normandy.recipes.models.Autographer") mock_autograph.return_value.sign_data.return_value = [{"signature": "fake signature"}] action = ActionFactory(signed=False) action.update_signature() mock_logger.info.assert_called_with( Whatever.contains(action.name), extra={"code": INFO_REQUESTING_ACTION_SIGNATURES, "action_names": [action.name]}, ) action.save() assert action.signature is not None assert action.signature.signature == "fake signature"
def test_only_signed_when_approved_and_enabled(self, mocked_autograph): sign_data_mock = mocked_autograph.return_value.sign_data # This uses the signer, so do it first action = ActionFactory() sign_data_mock.reset_mock() sign_data_mock.side_effect = Exception("Can't sign yet") recipe = RecipeFactory(name="unchanged", action=action) assert not recipe.enabled assert not recipe.is_approved assert recipe.signature is None # Updating does not generate a signature recipe.revise(name="changed") assert recipe.signature is None # Approving does not sign the recipe rev = recipe.latest_revision approval_request = rev.request_approval(UserFactory()) approval_request.approve(UserFactory(), "r+") recipe.refresh_from_db() assert recipe.signature is None mocked_autograph.return_value.sign_data.assert_not_called() # Enabling signs the recipe mocked_autograph.return_value.sign_data.side_effect = fake_sign rev.enable(UserFactory()) recipe.refresh_from_db() expected_sig = fake_sign([recipe.canonical_json()])[0]["signature"] assert recipe.signature.signature == expected_sig assert mocked_autograph.return_value.sign_data.called_once()
def test_unique_experiment_slug_no_collision(self): action = ActionFactory(name="preference-experiment") arguments_a = PreferenceExperimentArgumentsFactory() arguments_b = PreferenceExperimentArgumentsFactory() # Does not throw when saving revisions RecipeFactory(action=action, arguments=arguments_a) RecipeFactory(action=action, arguments=arguments_b)
def test_canonical_json(self): recipe = RecipeFactory( action=ActionFactory(name="action"), arguments_json='{"foo": 1, "bar": 2}', extra_filter_expression="2 + 2 == 4", name="canonical", filter_object_json=None, ) # Yes, this is really ugly, but we really do need to compare an exact # byte sequence, since this is used for hashing and signing filter_expression = "2 + 2 == 4" expected = ("{" '"action":"action",' '"arguments":{"bar":2,"foo":1},' '"filter_expression":"%(filter_expression)s",' '"id":%(id)s,' '"name":"canonical",' '"revision_id":"%(revision_id)s"' "}") % { "id": recipe.id, "revision_id": recipe.revision_id, "filter_expression": filter_expression, } expected = expected.encode() assert recipe.canonical_json() == expected
def test_repeated_identical_survey_ids(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, } RecipeFactory(action=action, arguments=arguments) # Reusing the same "surveyId" should cause a ValidationError. # But you can change other things. arguments["message"] += " And this!" with pytest.raises(serializers.ValidationError) as exc_info: RecipeFactory(action=action, arguments=arguments) expected_error = action.errors["duplicate_survey_id"] assert exc_info.value.detail == { "arguments": { "surveyId": expected_error } }
def test_no_error_distinctly_different_survey_ids(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" arguments["surveyId"] = "002" recipe = RecipeFactory(action=action, arguments=arguments) rev = recipe.latest_revision assert rev.arguments["surveyId"] == "002"
def test_slug_must_match_a_rollout(self): rollback_action = ActionFactory(name="preference-rollback") arguments = {"rolloutSlug": "does-not-exist"} with pytest.raises(serializers.ValidationError) as exc_info: RecipeFactory(action=rollback_action, arguments=arguments) error = rollback_action.errors["rollout_slug_not_found"] assert exc_info.value.detail == {"arguments": {"slug": error}}
def test_it_works_for_multiple_extensions(self, storage): extension1 = ExtensionFactory(name="1.xpi") extension2 = ExtensionFactory(name="2.xpi") fake_old_url1 = extension1.xpi.url.replace("/media/", "/media-old/") fake_old_url2 = extension2.xpi.url.replace("/media/", "/media-old/") action = ActionFactory(name="opt-out-study") recipe1 = RecipeFactory(action=action, arguments={ "name": "1", addonUrl: fake_old_url1 }) recipe2 = RecipeFactory(action=action, arguments={ "name": "2", addonUrl: fake_old_url2 }) call_command("update_addon_urls") # For reasons that I don't understand, recipe.update_from_db() doesn't work here. recipe1 = Recipe.objects.get(id=recipe1.id) recipe2 = Recipe.objects.get(id=recipe2.id) assert recipe1.arguments[addonUrl] == extension1.xpi.url assert recipe2.arguments[addonUrl] == extension2.xpi.url
def test_unique_experiment_slug_no_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) RecipeFactory(action=action, arguments=arguments_b)
def test_recipes_used_by(self): recipe = RecipeFactory(enabled=True) assert [recipe] == list(recipe.action.recipes_used_by) action = ActionFactory() recipes = RecipeFactory.create_batch(2, action=action, enabled=True) assert set(action.recipes_used_by) == set(recipes)
def test_signed_listing_works(self, api_client): a1 = ActionFactory(signed=True) res = api_client.get('/api/v1/action/signed/') assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]['action']['name'] == a1.name assert res.data[0]['signature']['signature'] == a1.signature.signature
def test_detail_view_includes_cache_headers(self, api_client): action = ActionFactory() res = api_client.get('/api/v1/action/{name}/'.format(name=action.name)) 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_signed_listing_works(self, api_client): a1 = ActionFactory(signed=True) res = api_client.get("/api/v1/action/signed/") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["action"]["name"] == a1.name assert res.data[0]["signature"]["signature"] == a1.signature.signature
def test_creation_when_arguments_are_invalid(self, api_client): ActionFactory(name='foobarbaz', arguments_schema={ 'type': 'object', 'properties': { 'message': { 'type': 'string' } }, 'required': ['message'] }) res = api_client.post( '/api/v1/recipe/', { 'name': 'Test Recipe', 'enabled': True, 'extra_filter_expression': 'true', 'action': 'foobarbaz', 'arguments': { 'message': '' } }) assert res.status_code == 400 recipes = Recipe.objects.all() assert recipes.count() == 0
def test_validation_with_invalid_filter_expression(self): ActionFactory(name="show-heartbeat", arguments_schema=ARGUMENTS_SCHEMA) serializer = RecipeSerializer( data={ "name": "bar", "enabled": True, "extra_filter_expression": "inv(-alsid", "action": "show-heartbeat", "arguments": { "surveyId": "lorem-ipsum-dolor", "surveys": [ { "title": "adipscing", "weight": 1 }, { "title": "consequetar", "weight": 1 }, ], }, }) assert not serializer.is_valid() assert serializer.errors["extra_filter_expression"] == [ "Could not parse expression: inv(-alsid" ]
def test_validation_with_invalid_filter_expression(self): ActionFactory(name='show-heartbeat', arguments_schema=ARGUMENTS_SCHEMA) serializer = RecipeSerializer( data={ 'name': 'bar', 'enabled': True, 'extra_filter_expression': 'inv(-alsid', 'action': 'show-heartbeat', 'arguments': { 'surveyId': 'lorem-ipsum-dolor', 'surveys': [{ 'title': 'adipscing', 'weight': 1 }, { 'title': 'consequetar', 'weight': 1 }] } }) assert not serializer.is_valid() assert serializer.errors['extra_filter_expression'] == [ 'Could not parse expression: inv(-alsid' ]
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_it_only_updates_given_actions(self, mock_action): update_action = ActionFactory(name='update-action', implementation='old') dont_update_action = ActionFactory(name='dont-update-action', implementation='old') mock_action(update_action.name, 'new', update_action.arguments_schema) mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema) call_command('update_actions', 'update-action') update_action.refresh_from_db() assert update_action.implementation == 'new' dont_update_action.refresh_from_db() assert dont_update_action.implementation == 'old'
def test_it_works(self): action = ActionFactory(name="nothing special") # does not raise an exception action.validate_arguments({}, RecipeRevisionFactory())
def test_it_gets_the_right_hash(self): a = ActionFactory.build() old_hash = a.implementation_hash a.save() assert a.implementation_hash == old_hash