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_signatures_update_correctly_on_enable(self, mocked_autograph): recipe = RecipeFactory(signed=False, approver=UserFactory()) recipe.approved_revision.enable(user=UserFactory()) recipe.refresh_from_db() assert recipe.signature is not None assert recipe.signature.signature == fake_sign([recipe.canonical_json()])[0]["signature"]
def test_delete_pending_approval_request_on_revise(self): recipe = RecipeFactory(name="old") approval = ApprovalRequestFactory(revision=recipe.latest_revision) recipe.revise(name="new") with pytest.raises(ApprovalRequest.DoesNotExist): ApprovalRequest.objects.get(pk=approval.pk)
def test_filter_exclude_many(self): locale_match1, locale_match2, locale_not = LocaleFactory.create_batch(3) recipe = RecipeFactory(locales=[locale_match1, locale_match2]) client = ClientFactory(locale=locale_not.code) assert not recipe.matches(client) assert recipe.matches(client, exclude=[get_locales])
def test_cant_change_signature_and_other_fields(self): recipe = RecipeFactory(name='unchanged', signed=False) recipe.signature = SignatureFactory() recipe.name = 'changed' with pytest.raises(ValidationError) as exc_info: recipe.save() assert exc_info.value.message == 'Signatures must change alone'
def test_filter_by_sample_rate(self): always_match = RecipeFactory(sample_rate=1.0) never_match = RecipeFactory(sample_rate=0.0) client = ClientFactory() assert always_match.matches(client) assert not never_match.matches(client)
def test_enabled_state_carried_over_on_approval(self): recipe = RecipeFactory(approver=UserFactory(), enabler=UserFactory()) carryover_from = recipe.approved_revision.enabled_state recipe.revise(name="New name") approval_request = recipe.latest_revision.request_approval(UserFactory()) approval_request.approve(UserFactory(), "r+") assert recipe.enabled assert recipe.approved_revision.enabled_state.carryover_from == carryover_from
def test_filter_by_locale_one(self): locale1 = LocaleFactory() locale2 = LocaleFactory() recipe = RecipeFactory(locales=[locale1]) client1 = ClientFactory(locale=locale1.code) client2 = ClientFactory(locale=locale2.code) assert recipe.matches(client1) assert not recipe.matches(client2)
def test_can_update_extensions_no_longer_in_use(self, api_client, storage): xpi = WebExtensionFileFactory() e = ExtensionFactory(xpi__from_func=xpi.open) a = ActionFactory(name="opt-out-study") r = RecipeFactory(action=a, arguments={"extensionId": e.id}) r.revise(arguments={"extensionId": 0}) res = api_client.patch(f"/api/v3/extension/{e.id}/", {"name": "new name"}) assert res.status_code == 200 assert res.data["name"] == "new name"
def test_revision_id_increments(self): """Ensure that the revision id is incremented on each save""" recipe = RecipeFactory() # The factory saves a couple times so revision id is not 0 revision_id = recipe.revision_id recipe.save() assert recipe.revision_id == revision_id + 1
def test_can_delete_extensions_no_longer_in_use(self, api_client, storage): xpi = WebExtensionFileFactory() e = ExtensionFactory(xpi__from_func=xpi.open) a = ActionFactory(name="opt-out-study") r = RecipeFactory(action=a, arguments={"extensionId": e.id}) r.revise(arguments={"extensionId": 0}) res = api_client.delete(f"/api/v3/extension/{e.id}/") assert res.status_code == 204 assert Extension.objects.count() == 0
def test_recipe_doesnt_revise_when_clean(self): recipe = RecipeFactory(name="my name") revision_id = recipe.revision_id last_updated = recipe.last_updated recipe.revise(name="my name") assert revision_id == recipe.revision_id assert last_updated == recipe.last_updated
def test_filter_by_country_one(self): country1 = CountryFactory() country2 = CountryFactory() recipe = RecipeFactory(countries=[country1]) client1 = ClientFactory(country=country1.code) client2 = ClientFactory(country=country2.code) assert recipe.matches(client1) assert not recipe.matches(client2)
def test_filter_by_channel_one(self): beta = ReleaseChannelFactory(slug='beta') recipe = RecipeFactory(release_channels=[beta]) release_client = ClientFactory(release_channel='release') beta_client = ClientFactory(release_channel='beta') assert not recipe.matches(release_client) assert recipe.matches(beta_client)
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/v1/recipe/%s/history/" % recipe.id) assert res.data[0]["recipe"]["name"] == "version 3" assert res.data[1]["recipe"]["name"] == "version 2" assert res.data[2]["recipe"]["name"] == "version 1"
def test_signature_is_updated_if_autograph_available(self, mocked_autograph): recipe = RecipeFactory(name="unchanged", approver=UserFactory(), enabler=UserFactory()) original_signature = recipe.signature assert original_signature is not None recipe.revise(name="changed") assert recipe.latest_revision.name == "changed" assert recipe.signature is not original_signature expected_sig = fake_sign([recipe.canonical_json()])[0]["signature"] assert recipe.signature.signature == expected_sig
def test_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(name="unchanged", signed=True) original_signature = recipe.signature recipe.revise(name="changed") assert recipe.name == "changed" assert recipe.signature is not original_signature assert recipe.signature is None
def test_unique_name_update_collision(self): action = ActionFactory(name="opt-out-study") arguments_a = {"name": "foo"} arguments_b = {"name": "bar"} 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_recipe_is_approved(self): recipe = RecipeFactory(name="old") assert not recipe.is_approved approval = ApprovalRequestFactory(revision=recipe.latest_revision) approval.approve(UserFactory(), "r+") assert recipe.is_approved assert recipe.approved_revision == recipe.latest_revision recipe.revise(name="new") assert recipe.is_approved assert recipe.approved_revision != recipe.latest_revision
def test_revision_id_doesnt_increment_if_no_changes(self): """ revision_id should not increment if a recipe is saved with no changes. """ recipe = RecipeFactory() # The factory saves a couple times so revision id is not 0 revision_id = recipe.revision_id recipe.save() assert recipe.revision_id == revision_id
def test_update_signature(self, mocker): # Mock the Autographer mock_autograph = mocker.patch('normandy.recipes.models.Autographer') mock_autograph.return_value.sign_data.return_value = [ {'signature': 'fake signature'}, ] recipe = RecipeFactory(signed=False) recipe.update_signature() recipe.save() assert recipe.signature is not None assert recipe.signature.signature == 'fake signature'
def test_update_signature(self, mock_logger, mocked_autograph): recipe = RecipeFactory(enabler=UserFactory(), approver=UserFactory()) recipe.signature = None recipe.update_signature() mock_logger.info.assert_called_with( Whatever.contains(str(recipe.id)), extra={"code": INFO_REQUESTING_RECIPE_SIGNATURES, "recipe_ids": [recipe.id]}, ) mocked_autograph.return_value.sign_data.assert_called_with( [Whatever(lambda s: json.loads(s)["id"] == recipe.id)] ) assert recipe.signature is not None
def test_signature_is_cleared_if_autograph_unavailable(self, mocker): # Mock the Autographer mock_autograph = mocker.patch('normandy.recipes.models.Autographer') mock_autograph.return_value.sign_data.side_effect = ImproperlyConfigured recipe = RecipeFactory(name='unchanged', signed=True) original_signature = recipe.signature recipe.name = 'changed' recipe.save() assert recipe.name == 'changed' assert recipe.signature is not original_signature assert recipe.signature is None
def test_it_doesnt_disable_recipes(self, mock_action): recipe = RecipeFactory( action__name='test-action', action__implementation='old', 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_filter_by_channel_many(self): release = ReleaseChannelFactory(slug='release') beta = ReleaseChannelFactory(slug='beta') recipe = RecipeFactory(release_channels=[release, beta]) release_client = ClientFactory(release_channel='release') beta_client = ClientFactory(release_channel='beta') aurora_client = ClientFactory(release_channel='aurora') assert recipe.matches(release_client) assert recipe.matches(beta_client) assert not recipe.matches(aurora_client)
def test_signature_is_updated_if_autograph_available(self, mocked_autograph): recipe = RecipeFactory(name='unchanged') original_signature = recipe.signature assert original_signature is not None recipe.name = 'changed' recipe.save() assert recipe.name == 'changed' assert recipe.signature is not original_signature expected_sig = hashlib.sha256(recipe.canonical_json()).hexdigest() assert recipe.signature.signature == expected_sig
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_it_publishes_new_revisions_if_enabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) assert mocked_remotesettings.return_value.publish.call_count == 1 recipe.revise(name="Modified") approval_request = recipe.latest_revision.request_approval(creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") assert mocked_remotesettings.return_value.publish.call_count == 2 second_call_args, _ = mocked_remotesettings.return_value.publish.call_args_list[1] modified_recipe, = second_call_args assert modified_recipe.name == "Modified"
def test_enabled_updates_signatures(self, mocked_autograph): recipe = RecipeFactory(name="first") ar = recipe.latest_revision.request_approval(UserFactory()) ar.approve(approver=UserFactory(), comment="r+") recipe = Recipe.objects.get() recipe.approved_revision.enable(UserFactory()) recipe.refresh_from_db() data_to_sign = recipe.canonical_json() signature_of_data = fake_sign([data_to_sign])[0]["signature"] signature_in_db = recipe.signature.signature assert signature_of_data == signature_in_db
def test_approval_request_property(self): # Make sure it works when there is no approval request recipe = RecipeFactory(name="old") assert recipe.approval_request is None # Make sure it returns an approval request if it exists approval = ApprovalRequestFactory(revision=recipe.latest_revision) assert recipe.approval_request == approval # Check the edge case where there is no latest_revision recipe.latest_revision.delete() recipe.refresh_from_db() assert recipe.approval_request is None
def test_cannot_enable_unapproved_recipes(self, api_client): recipe = RecipeFactory(enabled=False) res = api_client.post('/api/v2/recipe/%s/enable/' % recipe.id) assert res.status_code == 409 assert res.data[ 'enabled'] == 'Cannot enable a recipe that is not approved.'
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.action.name, "arguments": recipe.arguments, "filter_expression": recipe.filter_expression, "id": recipe.id, "name": recipe.name, "revision_id": str(recipe.revision_id), "capabilities": Whatever(lambda caps: set(caps) == recipe.capabilities), }, "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_it_works(self, rf): recipe = RecipeFactory(arguments={"foo": "bar"}) approval = ApprovalRequestFactory(revision=recipe.latest_revision) action = recipe.action serializer = RecipeSerializer(recipe, context={"request": rf.get("/")}) assert serializer.data == { "name": recipe.name, "id": recipe.id, "last_updated": Whatever(), "enabled": recipe.enabled, "extra_filter_expression": recipe.extra_filter_expression, "filter_expression": recipe.filter_expression, "revision_id": recipe.revision_id, "action": action.name, "arguments": { "foo": "bar" }, "is_approved": False, "latest_revision_id": recipe.latest_revision.id, "approved_revision_id": None, "approval_request": { "id": approval.id, "created": Whatever(), "creator": Whatever(), "approved": None, "approver": None, "comment": None, }, "identicon_seed": Whatever.startswith("v1:"), }
def test_disable(self): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) assert recipe.enabled recipe.approved_revision.disable(user=UserFactory()) assert not recipe.enabled with pytest.raises(EnabledState.NotActionable): recipe.approved_revision.disable(user=UserFactory()) recipe.revise(name="New name") with pytest.raises(EnabledState.NotActionable): recipe.latest_revision.disable(user=UserFactory())
def test_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_passes_expire_early_setting(self, mocker, settings): settings.CERTIFICATES_EXPIRE_EARLY_DAYS = 7 recipe = RecipeFactory(signed=True) mock_verify_x5u = mocker.patch('normandy.recipes.checks.signing.verify_x5u') errors = checks.signatures_use_good_certificates(None) mock_verify_x5u.assert_called_once_with(recipe.signature.x5u, timedelta(7)) assert errors == []
def test_it_ignores_signatures_without_x5u(self): recipe = RecipeFactory(signed=True) recipe.signature.x5u = None recipe.signature.save() actions = ActionFactory(signed=True) actions.signature.x5u = None actions.signature.save() assert checks.signatures_use_good_certificates(None) == []
def test_it_publishes_several_times_when_reenabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) recipe.approved_revision.disable(user=UserFactory()) recipe.approved_revision.enable(user=UserFactory()) assert mocked_remotesettings.return_value.unpublish.call_count == 1 assert mocked_remotesettings.return_value.publish.call_count == 2
def test_signed_listing_works(self, api_client): r1 = RecipeFactory(signed=True) res = api_client.get('/api/v1/recipe/signed/') assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]['recipe']['id'] == r1.id assert res.data[0]['signature'][ 'signature'] == r1.signature.signature
def test_classify_no_sample(self, admin_client): """The classify view should ignore sampling.""" locale = LocaleFactory() country = CountryFactory() release_channel = ReleaseChannelFactory() recipe = RecipeFactory(sample_rate=0) response = admin_client.get( '/admin/classifier_preview', { 'locale': locale.code, 'release_channel': release_channel.slug, 'country': country.code }) assert response.status_code == 200 assert not recipe.matches(response.context['client']) assert list(response.context['bundle']) == [recipe]
def test_it_can_disable_recipes(self, api_client): recipe = RecipeFactory(enabled=True) res = api_client.post('/api/v1/recipe/%s/disable/' % recipe.id) assert res.status_code == 204 recipe = Recipe.objects.all()[0] assert not recipe.enabled
def test_it_can_delete_recipes(self, api_client): recipe = RecipeFactory() res = api_client.delete('/api/v1/recipe/%s/' % recipe.id) assert res.status_code == 204 recipes = Recipe.objects.all() assert recipes.count() == 0
def test_signed_listing_works(self, api_client): r1 = RecipeFactory(signed=True) res = api_client.get("/api/v1/recipe/signed/") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["recipe"]["id"] == r1.id assert res.data[0]["signature"][ "signature"] == r1.signature.signature
def test_it_serves_recipes(self, api_client, settings): recipe = RecipeFactory() settings.BASELINE_CAPABILITIES |= recipe.latest_revision.capabilities res = api_client.get("/api/v1/recipe/") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["name"] == recipe.latest_revision.name
def test_it_publishes_new_revisions_if_enabled(self, mocked_remotesettings): recipe = RecipeFactory(name="Test", approver=UserFactory(), enabler=UserFactory()) assert mocked_remotesettings.return_value.publish.call_count == 1 recipe.revise(name="Modified") approval_request = recipe.latest_revision.request_approval( creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") assert mocked_remotesettings.return_value.publish.call_count == 2 second_call_args, _ = mocked_remotesettings.return_value.publish.call_args_list[ 1] modified_recipe, = second_call_args assert modified_recipe.name == "Modified"
def test_setting_signature_doesnt_change_canonical_json(self): recipe = RecipeFactory(name="unchanged", signed=False) serialized = recipe.canonical_json() recipe.signature = SignatureFactory() recipe.save() assert recipe.signature is not None assert recipe.canonical_json() == serialized
def test_no_errors(self): action = ActionFactory(name="opt-out-study") recipe = RecipeFactory(action=action) # 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_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_can_edit_actions_in_use_with_setting(self, api_client, settings): RecipeFactory(action__name='active', enabled=True) settings.CAN_EDIT_ACTIONS_IN_USE = True res = api_client.patch('/api/v1/action/active/', {'implementation': 'foobar'}) assert res.status_code == 200 res = api_client.delete('/api/v1/action/active/') assert res.status_code == 204
def test_action_admin_form_not_in_use(): """Actions that are not in-use can be edited.""" action = RecipeFactory(enabled=False).action FormClass = modelform_factory(Action, form=ActionAdminForm, fields=['name']) form = FormClass({'name': 'foo'}, instance=action) assert form.is_valid()
def test_signed_listing_works(self, api_client, settings): r1 = RecipeFactory(approver=UserFactory(), signed=True) settings.BASELINE_CAPABILITIES |= r1.latest_revision.capabilities res = api_client.get("/api/v1/recipe/signed/") assert res.status_code == 200 assert len(res.data) == 1 assert res.data[0]["recipe"]["id"] == r1.id assert res.data[0]["signature"][ "signature"] == r1.signature.signature
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.latest_revision.arguments[addonUrl] == extension1.xpi.url assert recipe2.latest_revision.arguments[addonUrl] == extension2.xpi.url
def test_it_warns_if_a_field_isnt_available(self, mocker): """This is to allow for un-applied to migrations to not break running migrations.""" RecipeFactory(approver=UserFactory(), signed=True) mock_canonical_json = mocker.patch( "normandy.recipes.models.Recipe.canonical_json") mock_canonical_json.side_effect = ProgrammingError("error for testing") errors = checks.recipe_signatures_are_correct(None) assert len(errors) == 1 assert errors[0].id == checks.WARNING_COULD_NOT_CHECK_SIGNATURES
def test_it_reports_x5u_network_errors(self, mocker): RecipeFactory(signed=True) mock_verify_x5u = mocker.patch( "normandy.recipes.checks.signing.verify_x5u") mock_verify_x5u.side_effect = requests.exceptions.ConnectionError errors = checks.signatures_use_good_certificates(None) mock_verify_x5u.assert_called_once() assert len(errors) == 1 assert errors[0].id == checks.ERROR_COULD_NOT_VERIFY_CERTIFICATE
def test_it_can_enable_recipes(self, api_client): recipe = RecipeFactory(enabled=False, approver=UserFactory()) res = api_client.post('/api/v1/recipe/%s/enable/' % recipe.id) assert res.status_code == 200 assert res.data['enabled'] is True recipe = Recipe.objects.all()[0] assert recipe.enabled
def test_it_sends_metrics(self, settings, mocked_autograph): # 3 to sign RecipeFactory.create_batch(3, approver=UserFactory(), enabler=UserFactory(), signed=False) # and 1 to unsign RecipeFactory(signed=True, enabled=False) with MetricsMock() as mm: call_command("update_recipe_signatures") mm.print_records() assert mm.has_record(GAUGE, stat="normandy.signing.recipes.signed", value=3) assert mm.has_record(GAUGE, stat="normandy.signing.recipes.unsigned", value=1)
def test_it_does_not_publish_when_approved_if_not_enabled( self, mocked_remotesettings): recipe = RecipeFactory(name="Test") approval_request = recipe.latest_revision.request_approval( creator=UserFactory()) approval_request.approve(approver=UserFactory(), comment="r+") assert not mocked_remotesettings.return_value.publish.called
def test_close(self, api_client): r = RecipeFactory() a = ApprovalRequestFactory(revision=r.latest_revision) res = api_client.post('/api/v1/approval_request/{}/close/'.format( a.id)) assert res.status_code == 204 with pytest.raises(ApprovalRequest.DoesNotExist): ApprovalRequest.objects.get(pk=a.pk)
def test_disabling_recipe_removes_approval(self): recipe = RecipeFactory(approver=UserFactory(), enabled=True) assert recipe.is_approved recipe.enabled = False recipe.save() recipe.refresh_from_db() assert not recipe.is_approved
def test_it_can_disable_recipes(self, api_client): recipe = RecipeFactory(approver=UserFactory(), enabled=True) res = api_client.post('/api/v1/recipe/%s/disable/' % recipe.id) assert res.status_code == 200 assert res.data['enabled'] is False recipe = Recipe.objects.all()[0] assert not recipe.is_approved assert not recipe.enabled