Allow Abuse reports to be sent against Rating (#21364)

* Allow Abuse reports to be sent against Rating

Includes refactors to share some logic between different kinds of abuse reports
This commit is contained in:
Mathieu Pillard 2023-11-02 13:59:33 +01:00 коммит произвёл GitHub
Родитель 1c6a16ada8
Коммит 1657c2d938
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 675 добавлений и 115 удалений

Просмотреть файл

@ -53,7 +53,9 @@ to if necessary.
:<json string|null install_date: The add-on install date.
:<json string|null operating_system: The client's operating system.
:<json string|null operating_system_version: The client's operating system version.
:<json string|null reason: The reason for the report. The accepted values are documented in the :ref:`table below <abuse-reason-parameter>`.
:<json string|null reason: The reason for the report. The accepted values are documented in the :ref:`table below <abuse-addon-reason-parameter>`.
:<json string|null reporter_name: The provided name of the reporter, if not authenticated.
:<json string|null reporter_email: The provided email of the reporter, if not authenticated.
:>json object|null reporter: The user who submitted the report, if authenticated.
:>json int reporter.id: The id of the user who submitted the report.
:>json string reporter.name: The name of the user who submitted the report.
@ -191,9 +193,9 @@ to if necessary.
privileged Privileged
=========================== =================================================
.. _abuse-reason-parameter:
.. _abuse-addon-reason-parameter:
Accepted values for the ``reason`` parameter:
Accepted values for the ``reason`` parameter (for add-on abuse reports):
=========================== ================================================================
Value Description
@ -205,6 +207,10 @@ to if necessary.
policy Hateful, violent, or illegal content
deceptive Doesn't match description
unwanted Wasn't wanted / impossible to get rid of
hateful_violent_deceptive Hateful, violent, deceptive, or other inappropriate content
illegal Violates the law or contains content that violates the law
does_not_work Doesnt work, breaks websites, or slows Firefox down
feedback_spam Spam
other Something else
=========================== ================================================================
@ -238,6 +244,8 @@ so reports can be responded to if necessary.
:<json string user: The id or username of the user to report for abuse (required).
:<json string message: The body/content of the abuse report (required).
:<json string|null reporter_name: The provided name of the reporter, if not authenticated.
:<json string|null reporter_email: The provided email of the reporter, if not authenticated.
:>json object|null reporter: The user who submitted the report, if authenticated.
:>json int reporter.id: The id of the user who submitted the report.
:>json string reporter.name: The name of the user who submitted the report.
@ -251,3 +259,48 @@ so reports can be responded to if necessary.
:>json string user.url: The link to the profile page for of the user reported.
:>json string user.username: The username of the user reported.
:>json string message: The body/content of the abuse report.
--------------------------------
Submitting a rating abuse report
--------------------------------
.. _`ratingabusereport-create`:
The following API endpoint allows an abuse report to be submitted for a rating
on https://addons.mozilla.org. Authentication is not required, but is recommended
so reports can be responded to if necessary.
.. http:post:: /api/v5/abuse/report/rating/
.. _ratingabusereport-create-request:
:<json string rating: The id of the rating to report for abuse (required).
:<json string message: The body/content of the abuse report (required).
:<json string|null reason: The reason for the report. The accepted values are documented in the :ref:`table below <abuse-rating-reason-parameter>`.
:<json string|null reporter_name: The provided name of the reporter, if not authenticated.
:<json string|null reporter_email: The provided email of the reporter, if not authenticated.
:>json object|null reporter: The user who submitted the report, if authenticated.
:>json int reporter.id: The id of the user who submitted the report.
:>json string reporter.name: The name of the user who submitted the report.
:>json string reporter.url: The link to the profile page for of the user who submitted the report.
:>json string reporter.username: The username of the user who submitted the report.
:>json string|null reporter_name: The provided name of the reporter, if not authenticated.
:>json string|null reporter_email: The provided email of the reporter, if not authenticated.
:>json object rating: The user reported for abuse.
:>json int rating.id: The id of the rating reported.
:>json string message: The body/content of the abuse report.
:>json string|null reason: The reason for the report.
.. _abuse-rating-reason-parameter:
Accepted values for the ``reason`` parameter (for rating abuse reports):
=========================== ================================================================
Value Description
=========================== ================================================================
hateful_violent_deceptive Hateful, violent, deceptive, or other inappropriate content
illegal Violates the law or contains content that violates the law
other Something else
=========================== ================================================================

Просмотреть файл

@ -2,11 +2,17 @@ from django.urls import include, re_path
from rest_framework.routers import SimpleRouter
from .views import AddonAbuseViewSet, UserAbuseViewSet, cinder_webhook
from .views import (
AddonAbuseViewSet,
RatingAbuseViewSet,
UserAbuseViewSet,
cinder_webhook,
)
reporting = SimpleRouter()
reporting.register(r'addon', AddonAbuseViewSet, basename='abusereportaddon')
reporting.register(r'rating', RatingAbuseViewSet, basename='abusereportrating')
reporting.register(r'user', UserAbuseViewSet, basename='abusereportuser')
urlpatterns = [

Просмотреть файл

@ -109,11 +109,14 @@ class CinderUser(Cinder):
}
def get_context(self):
addons = [CinderAddon(addon) for addon in self.user.addons.all()]
cinder_addons = [CinderAddon(addon) for addon in self.user.addons.all()]
return {
'entities': [addon.get_entity_data() for addon in addons],
'entities': [
cinder_addon.get_entity_data() for cinder_addon in cinder_addons
],
'relationships': [
self.get_relationship_data(addon, 'amo_author_of') for addon in addons
self.get_relationship_data(cinder_addon, 'amo_author_of')
for cinder_addon in cinder_addons
],
}
@ -163,11 +166,39 @@ class CinderAddon(Cinder):
}
def get_context(self):
authors = [CinderUser(author) for author in self.addon.authors.all()]
cinder_users = [CinderUser(author) for author in self.addon.authors.all()]
return {
'entities': [author.get_entity_data() for author in authors],
'entities': [cinder_user.get_entity_data() for cinder_user in cinder_users],
'relationships': [
author.get_relationship_data(self, 'amo_author_of')
for author in authors
cinder_user.get_relationship_data(self, 'amo_author_of')
for cinder_user in cinder_users
],
}
class CinderRating(Cinder):
type = 'amo_rating'
def __init__(self, rating):
self.rating = rating
@property
def id(self):
return str(self.rating.id)
def get_attributes(self):
return {
'id': self.id,
'body': self.rating.body,
}
def get_context(self):
# Note: we are not currently sending the add-on the rating is for as
# part of the context.
cinder_user = CinderUser(self.rating.user)
return {
'entities': [cinder_user.get_entity_data()],
'relationships': [
cinder_user.get_relationship_data(self, 'amo_rating_author_of')
],
}

Просмотреть файл

@ -0,0 +1,53 @@
# Generated by Django 4.2.6 on 2023-10-27 10:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ratings', '0009_alter_deniedratingword_word'),
('abuse', '0014_abusereport_location'),
]
operations = [
migrations.RemoveConstraint(
model_name='abusereport',
name='just_one_of_guid_and_user_must_be_set',
),
migrations.AddField(
model_name='abusereport',
name='rating',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='abuse_reports',
to='ratings.rating',
),
),
migrations.AddConstraint(
model_name='abusereport',
constraint=models.CheckConstraint(
check=models.Q(
models.Q(
models.Q(('guid', ''), _negated=True),
('guid__isnull', False),
('rating__isnull', True),
('user__isnull', True),
),
models.Q(
('guid__isnull', True),
('rating__isnull', True),
('user__isnull', False),
),
models.Q(
('guid__isnull', True),
('rating__isnull', False),
('user__isnull', True),
),
_connector='OR',
),
name='just_one_of_guid_user_rating_must_be_set',
),
),
]

Просмотреть файл

@ -8,9 +8,10 @@ from olympia import amo
from olympia.addons.models import Addon
from olympia.amo.models import BaseQuerySet, ManagerBase, ModelBase
from olympia.api.utils import APIChoices, APIChoicesWithNone
from olympia.ratings.models import Rating
from olympia.users.models import UserProfile
from .cinder import CinderAddon, CinderUnauthenticatedReporter, CinderUser
from .cinder import CinderAddon, CinderRating, CinderUnauthenticatedReporter, CinderUser
class AbuseReportQuerySet(BaseQuerySet):
@ -107,11 +108,13 @@ class AbuseReport(ModelBase):
),
('OTHER', 127, 'Other'),
)
REPORTABLE_REASONS = (
REASONS.HATEFUL_VIOLENT_DECEPTIVE,
REASONS.ILLEGAL,
REASONS.POLICY_VIOLATION,
REASONS.OTHER,
REASONS.add_subset(
'RATING_REASONS', ('HATEFUL_VIOLENT_DECEPTIVE', 'ILLEGAL', 'OTHER')
)
# Those reasons will be reported to Cinder.
REASONS.add_subset(
'REPORTABLE_REASONS',
('HATEFUL_VIOLENT_DECEPTIVE', 'ILLEGAL', 'POLICY_VIOLATION', 'OTHER'),
)
# https://searchfox.org
@ -204,13 +207,17 @@ class AbuseReport(ModelBase):
reporter_email = models.CharField(max_length=255, default=None, null=True)
reporter_name = models.CharField(max_length=255, default=None, null=True)
country_code = models.CharField(max_length=2, default=None, null=True)
# An abuse report can be for an addon or a user.
# If user is set then guid should be null.
# If user is null then guid should be set.
# An abuse report can be for an addon, a user or a rating.
# - If user is set then guid and rating should be null.
# - If guid is set then user and rating should be null.
# - If rating is set then user and guid should be null.
guid = models.CharField(max_length=255, null=True)
user = models.ForeignKey(
UserProfile, null=True, related_name='abuse_reports', on_delete=models.SET_NULL
)
rating = models.ForeignKey(
Rating, null=True, related_name='abuse_reports', on_delete=models.SET_NULL
)
message = models.TextField(blank=True)
state = models.PositiveSmallIntegerField(
@ -295,16 +302,23 @@ class AbuseReport(ModelBase):
]
constraints = [
models.CheckConstraint(
name='just_one_of_guid_and_user_must_be_set',
name='just_one_of_guid_user_rating_must_be_set',
check=(
models.Q(
~models.Q(guid=''),
guid__isnull=False,
user__isnull=True,
rating__isnull=True,
)
| models.Q(
guid__isnull=True,
user__isnull=False,
rating__isnull=True,
)
| models.Q(
guid__isnull=True,
user__isnull=True,
rating__isnull=False,
)
),
),
@ -320,12 +334,16 @@ class AbuseReport(ModelBase):
def type(self):
if self.guid:
type_ = 'Addon'
else:
elif self.user_id:
type_ = 'User'
elif self.rating_id:
type_ = 'Rating'
else:
type_ = 'Unknown'
return type_
def __str__(self):
name = self.guid if self.guid else self.user
name = self.guid or self.user_id or self.rating_id
return f'Abuse Report for {self.type} {name}'
@property
@ -336,8 +354,10 @@ class AbuseReport(ModelBase):
if self.guid:
return Addon.unfiltered.filter(guid=self.guid).first()
elif self.user:
elif self.user_id:
return self.user
elif self.rating_id:
return self.rating
return None
@ -385,7 +405,8 @@ class CinderReport(ModelBase):
return CinderAddon(target)
elif isinstance(target, UserProfile):
return CinderUser(target)
# TODO: More helpers here
elif isinstance(target, Rating):
return CinderRating(target)
return None
def get_cinder_reporter(self):

Просмотреть файл

@ -38,6 +38,16 @@ class BaseAbuseReportSerializer(AMOModelSerializer):
output['reporter'] = request.user
return output
def create(self, validated_data):
instance = super().create(validated_data)
if (
waffle.switch_is_active('enable-cinder-reporting')
and validated_data.get('reason') in AbuseReport.REASONS.REPORTABLE_REASONS
):
# call task to fire off cinder report
report_to_cinder.delay(instance.id)
return instance
class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
error_messages = {
@ -194,7 +204,7 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
}
if view := self.context.get('view'):
try:
addon = view.get_addon_object()
addon = view.get_target_object()
output['id'] = addon.pk
output['slug'] = addon.slug
except Http404:
@ -204,16 +214,6 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
pass
return output
def create(self, validated_data):
instance = super().create(validated_data)
if (
waffle.switch_is_active('enable-cinder-reporting')
and validated_data.get('reason') in AbuseReport.REPORTABLE_REASONS
):
# call task to fire off cinder report
report_to_cinder.delay(instance.id)
return instance
class UserAbuseReportSerializer(BaseAbuseReportSerializer):
user = BaseUserSerializer(required=False) # We validate it ourselves.
@ -228,9 +228,36 @@ class UserAbuseReportSerializer(BaseAbuseReportSerializer):
def to_internal_value(self, data):
view = self.context.get('view')
self.validate_target(data, 'user')
output = {'user': view.get_user_object()}
output = {'user': view.get_target_object()}
# Pop 'user' before passing it to super(), we already have the
# output value and did the validation above.
data.pop('user')
output.update(super().to_internal_value(data))
return output
class RatingAbuseReportSerializer(BaseAbuseReportSerializer):
rating = serializers.SerializerMethodField()
reason = ReverseChoiceField(
choices=list(AbuseReport.REASONS.RATING_REASONS.api_choices),
required=True,
allow_null=True,
)
message = serializers.CharField(required=True, allow_blank=False, max_length=10000)
class Meta:
model = AbuseReport
fields = BaseAbuseReportSerializer.Meta.fields + ('rating', 'reason')
def to_internal_value(self, data):
self.validate_target(data, 'rating')
view = self.context.get('view')
output = {'rating': view.get_target_object()}
# Pop 'rating' before passing it to super(), we already have the
# output value and did the validation above.
data.pop('rating')
output.update(super().to_internal_value(data))
return output
def get_rating(self, obj):
return {'id': obj.rating.pk}

Просмотреть файл

@ -3,11 +3,22 @@ from django.conf import settings
import responses
from olympia.amo.tests import TestCase, addon_factory, user_factory
from olympia.ratings.models import Rating
from ..cinder import CinderAddon, CinderUnauthenticatedReporter, CinderUser
from ..cinder import (
CinderAddon,
CinderRating,
CinderUnauthenticatedReporter,
CinderUser,
)
class BaseTestCinderCase:
cinder_class = None # Override in child classes
def _create_dummy_target(self, **kwargs):
raise NotImplementedError
def _test_report(self, cinder_instance):
responses.add(
responses.POST,
@ -54,9 +65,12 @@ class BaseTestCinderCase:
with self.assertRaises(ConnectionError):
cinder_instance.report(report_text='reason', category=None, reporter=None)
def test_report(self):
def test_build_report_payload(self):
raise NotImplementedError
def test_report(self):
self._test_report(self.cinder_class(self._create_dummy_target()))
def _test_appeal(self, appealer):
fake_decision_id = 'decision-id-to-appeal-666'
cinder_instance = self.cinder_class(self._create_dummy_target())
@ -252,9 +266,6 @@ class TestCinderAddon(BaseTestCinderCase, TestCase):
],
}
def test_report(self):
self._test_report(self.cinder_class(self._create_dummy_target()))
class TestCinderUser(BaseTestCinderCase, TestCase):
cinder_class = CinderUser
@ -417,5 +428,53 @@ class TestCinderUser(BaseTestCinderCase, TestCase):
],
}
def test_report(self):
self._test_report(self.cinder_class(self._create_dummy_target()))
class TestCinderRating(BaseTestCinderCase, TestCase):
cinder_class = CinderRating
def setUp(self):
self.user = user_factory()
self.addon = addon_factory()
def _create_dummy_target(self, **kwargs):
return Rating.objects.create(addon=self.addon, user=self.user, **kwargs)
def test_build_report_payload(self):
rating = self._create_dummy_target()
cinder_rating = self.cinder_class(rating)
reason = 'bad rating!'
data = cinder_rating.build_report_payload(
report_text=reason, category=None, reporter=None
)
assert data == {
'queue_slug': 'amo-content-infringement',
'entity_type': 'amo_rating',
'entity': {
'id': str(rating.id),
'body': rating.body,
},
'reasoning': reason,
'context': {
'entities': [
{
'entity_type': 'amo_user',
'attributes': {
'id': str(self.user.id),
'name': self.user.display_name,
'email': self.user.email,
'fxa_id': self.user.fxa_id,
},
}
],
'relationships': [
{
'source_id': str(self.user.id),
'source_type': 'amo_user',
'target_id': str(rating.id),
'target_type': 'amo_rating',
'relationship_type': 'amo_rating_author_of',
}
],
},
}

Просмотреть файл

@ -1,10 +1,12 @@
from django.conf import settings
from django.core.exceptions import ValidationError
import responses
from olympia.amo.tests import TestCase, addon_factory, user_factory
from olympia.ratings.models import Rating
from ..cinder import CinderAddon, CinderUser
from ..cinder import CinderAddon, CinderRating, CinderUser
from ..models import AbuseReport, CinderReport
@ -238,6 +240,36 @@ class TestAbuse(TestCase):
report.update(guid=None, user=user)
assert report.target == user
rating = Rating.objects.create(user=user, addon=addon, rating=5)
report.update(user=None, rating=rating)
assert report.target == rating
def test_constraint(self):
report = AbuseReport()
constraints = report.get_constraints()
assert len(constraints) == 1
constraint = constraints[0][1][0]
# ooooh addon is wrong.
with self.assertRaises(ValidationError):
constraint.validate(AbuseReport, report)
report.user_id = 48151
constraint.validate(AbuseReport, report)
report.guid = '@guid'
with self.assertRaises(ValidationError):
constraint.validate(AbuseReport, report)
report.guid = None
report.rating_id = 62342
with self.assertRaises(ValidationError):
constraint.validate(AbuseReport, report)
report.user_id = None
constraint.validate(AbuseReport, report)
class TestAbuseManager(TestCase):
def test_deleted(self):
@ -293,6 +325,12 @@ class TestCinderReport(TestCase):
assert isinstance(helper, CinderUser)
assert helper.user == user
rating = Rating.objects.create(addon=addon, user=user, rating=4)
cinder_report.abuse_report.update(user=None, rating=rating)
helper = cinder_report.get_helper()
assert isinstance(helper, CinderRating)
assert helper.rating == rating
def test_report(self):
cinder_report = CinderReport.objects.create(
abuse_report=AbuseReport.objects.create(

Просмотреть файл

@ -10,10 +10,12 @@ from olympia import amo
from olympia.abuse.models import AbuseReport
from olympia.abuse.serializers import (
AddonAbuseReportSerializer,
RatingAbuseReportSerializer,
UserAbuseReportSerializer,
)
from olympia.accounts.serializers import BaseUserSerializer
from olympia.amo.tests import TestCase, addon_factory, user_factory
from olympia.ratings.models import Rating
class TestAddonAbuseReportSerializer(TestCase):
@ -26,8 +28,8 @@ class TestAddonAbuseReportSerializer(TestCase):
request.user = AnonymousUser()
view = Mock()
view.get_guid.return_value = addon.guid
view.get_addon_object.return_value.slug = addon.slug
view.get_addon_object.return_value.pk = addon.pk
view.get_target_object.return_value.slug = addon.slug
view.get_target_object.return_value.pk = addon.pk
context = {
'request': request,
'view': view,
@ -124,7 +126,7 @@ class TestAddonAbuseReportSerializer(TestCase):
request.user = AnonymousUser()
view = Mock()
view.get_guid.return_value = '@someguid'
view.get_addon_object.return_value = None
view.get_target_object.return_value = None
context = {
'request': request,
'view': view,
@ -186,7 +188,7 @@ class TestAddonAbuseReportSerializer(TestCase):
request.user = AnonymousUser()
view = Mock()
view.get_guid.return_value = '@someguid'
view.get_addon_object.return_value = None
view.get_target_object.return_value = None
context = {
'request': request,
'view': view,
@ -221,7 +223,7 @@ class TestAddonAbuseReportSerializer(TestCase):
request.user = AnonymousUser()
view = Mock()
view.get_guid.return_value = '@someguid'
view.get_addon_object.return_value = None
view.get_target_object.return_value = None
context = {
'request': request,
'view': view,
@ -239,7 +241,7 @@ class TestAddonAbuseReportSerializer(TestCase):
request.user = AnonymousUser()
view = Mock()
view.get_guid.return_value = '@someguid'
view.get_addon_object.return_value = None
view.get_target_object.return_value = None
context = {
'request': request,
'view': view,
@ -266,3 +268,37 @@ class TestUserAbuseReportSerializer(TestCase):
'user': serialized_user,
'message': 'bad stuff',
}
class TestRatingAbuseReportSerializer(TestCase):
def serialize(self, report, context=None):
return dict(RatingAbuseReportSerializer(report, context=context or {}).data)
def test_user_report(self):
user = user_factory()
addon = addon_factory()
rating = Rating.objects.create(
body='evil rating', addon=addon, user=user, rating=1
)
report = AbuseReport(
rating=rating, message='bad stuff', reason=AbuseReport.REASONS.ILLEGAL
)
request = RequestFactory().get('/')
request.user = AnonymousUser()
view = Mock()
view.get_target_object.return_value = rating
context = {
'request': request,
'view': view,
}
serialized = self.serialize(report, context=context)
assert serialized == {
'reporter': None,
'reporter_email': None,
'reporter_name': None,
'rating': {
'id': rating.pk,
},
'reason': 'illegal',
'message': 'bad stuff',
}

Просмотреть файл

@ -17,6 +17,7 @@ from olympia.amo.tests import (
reverse_ns,
user_factory,
)
from olympia.ratings.models import Rating
class AddonAbuseViewSetTestBase:
@ -45,7 +46,7 @@ class AddonAbuseViewSetTestBase:
# It was a public add-on, so we found its guid.
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == 'abuse!'
def test_report_addon_by_slug(self):
@ -61,7 +62,7 @@ class AddonAbuseViewSetTestBase:
# It was a public add-on, so we found its guid.
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
def test_report_addon_by_guid(self):
addon = addon_factory(guid='@badman')
@ -75,7 +76,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == 'abuse!'
def test_report_addon_by_id_not_public(self):
@ -112,7 +113,7 @@ class AddonAbuseViewSetTestBase:
# simply inexistant (see test below) since we're not linking them
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == 'abuse!'
def test_report_addon_guid_not_on_amo(self):
@ -159,7 +160,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == 'abuse!'
def test_no_addon_fails(self):
@ -195,7 +196,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == ''
def test_message_can_be_blank_if_reason_is_provided(self):
@ -210,7 +211,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.message == ''
def test_message_length_limited(self):
@ -427,7 +428,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.addon_install_method == (AbuseReport.ADDON_INSTALL_METHODS.OTHER)
assert report.addon_install_source == (AbuseReport.ADDON_INSTALL_SOURCES.OTHER)
@ -444,7 +445,7 @@ class AddonAbuseViewSetTestBase:
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(guid=addon.guid)
self.check_report(report, 'Abuse Report for Addon %s' % addon.guid)
self.check_report(report, f'Abuse Report for Addon {addon.guid}')
assert report.country_code == 'YY'
def test_abuse_report_with_invalid_data(self):
@ -471,19 +472,19 @@ class AddonAbuseViewSetTestBase:
)
assert response.status_code == 201, response.content
@mock.patch('olympia.abuse.serializers.report_to_cinder.delay')
@mock.patch('olympia.abuse.tasks.report_to_cinder.delay')
@override_switch('enable-cinder-reporting', active=True)
def test_reportable_reason_calls_cinder_task(self, task_mock):
self._setup_reportable_reason('hateful_violent_deceptive')
task_mock.assert_called()
@mock.patch('olympia.abuse.serializers.report_to_cinder.delay')
@mock.patch('olympia.abuse.tasks.report_to_cinder.delay')
@override_switch('enable-cinder-reporting', active=False)
def test_reportable_reason_does_not_call_cinder_with_waffle_off(self, task_mock):
self._setup_reportable_reason('hateful_violent_deceptive')
task_mock.assert_not_called()
@mock.patch('olympia.abuse.serializers.report_to_cinder.delay')
@mock.patch('olympia.abuse.tasks.report_to_cinder.delay')
@override_switch('enable-cinder-reporting', active=True)
def test_not_reportable_reason_does_not_call_cinder_task(self, task_mock):
self._setup_reportable_reason('feedback_spam')
@ -553,7 +554,7 @@ class UserAbuseViewSetTestBase:
assert AbuseReport.objects.filter(user_id=user.id).exists()
report = AbuseReport.objects.get(user_id=user.id)
self.check_report(report, 'Abuse Report for User %s' % user)
self.check_report(report, f'Abuse Report for User {user.pk}')
def test_report_user_username(self):
user = user_factory()
@ -566,7 +567,7 @@ class UserAbuseViewSetTestBase:
assert AbuseReport.objects.filter(user_id=user.id).exists()
report = AbuseReport.objects.get(user_id=user.id)
self.check_report(report, 'Abuse Report for User %s' % user)
self.check_report(report, f'Abuse Report for User {user.pk}')
def test_no_user_fails(self):
response = self.client.post(self.url, data={'message': 'abuse!'})
@ -620,7 +621,7 @@ class UserAbuseViewSetTestBase:
assert AbuseReport.objects.filter(user_id=user.id).exists()
report = AbuseReport.objects.get(user_id=user.id)
self.check_report(report, 'Abuse Report for User %s' % user)
self.check_report(report, f'Abuse Report for User {user.pk}')
assert report.country_code == 'YY'
@ -663,6 +664,198 @@ class TestUserAbuseViewSetLoggedIn(UserAbuseViewSetTestBase, TestCase):
assert response.status_code == 429
class RatingAbuseViewSetTestBase:
client_class = APITestClientSessionID
def setUp(self):
self.url = reverse_ns('abusereportrating-list')
def check_reporter(self, report):
raise NotImplementedError
def check_report(self, report, text):
assert str(report) == text
self.check_reporter(report)
def test_report_rating_id(self):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(rating_id=target_rating.pk).exists()
report = AbuseReport.objects.get(rating=target_rating)
self.check_report(report, f'Abuse Report for Rating {target_rating.pk}')
def test_no_rating_fails(self):
response = self.client.post(
self.url, data={'message': 'abuse!', 'reason': 'illegal'}
)
assert response.status_code == 400
assert json.loads(response.content) == {'rating': ['This field is required.']}
def test_reason_required(self):
pass
def test_reason_only_ratings_ones(self):
pass
def test_message_required_empty(self):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
response = self.client.post(
self.url,
data={'rating': str(target_rating.pk), 'message': '', 'reason': 'illegal'},
)
assert response.status_code == 400
assert json.loads(response.content) == {
'message': ['This field may not be blank.']
}
def test_message_required_missing(self):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
response = self.client.post(
self.url, data={'rating': str(target_rating.pk), 'reason': 'illegal'}
)
assert response.status_code == 400
assert json.loads(response.content) == {'message': ['This field is required.']}
def test_throttle(self):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
for x in range(20):
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
HTTP_X_FORWARDED_FOR=f'123.45.67.89, {get_random_ip()}',
)
assert response.status_code == 201, x
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
HTTP_X_FORWARDED_FOR=f'123.45.67.89, {get_random_ip()}',
)
assert response.status_code == 429
def test_report_country_code(self):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
HTTP_X_COUNTRY_CODE='YY',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(rating_id=target_rating.pk).exists()
report = AbuseReport.objects.get(rating=target_rating)
self.check_report(report, f'Abuse Report for Rating {target_rating.pk}')
assert report.country_code == 'YY'
def _setup_reportable_reason(self, reason):
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
response = self.client.post(
self.url,
data={'rating': target_rating.pk, 'reason': reason, 'message': 'bad!'},
REMOTE_ADDR='123.45.67.89',
HTTP_X_FORWARDED_FOR=f'123.45.67.89, {get_random_ip()}',
)
assert response.status_code == 201, response.content
@mock.patch('olympia.abuse.tasks.report_to_cinder.delay')
@override_switch('enable-cinder-reporting', active=True)
def test_reportable_reason_calls_cinder_task(self, task_mock):
self._setup_reportable_reason('hateful_violent_deceptive')
task_mock.assert_called()
@mock.patch('olympia.abuse.tasks.report_to_cinder.delay')
@override_switch('enable-cinder-reporting', active=False)
def test_reportable_reason_does_not_call_cinder_with_waffle_off(self, task_mock):
self._setup_reportable_reason('hateful_violent_deceptive')
task_mock.assert_not_called()
class TestRatingAbuseViewSetLoggedOut(RatingAbuseViewSetTestBase, TestCase):
def check_reporter(self, report):
assert not report.reporter
class TestRatingAbuseViewSetLoggedIn(RatingAbuseViewSetTestBase, TestCase):
def setUp(self):
super().setUp()
self.user = user_factory()
self.client.login_api(self.user)
def check_reporter(self, report):
assert report.reporter == self.user
def test_throttle_ip_for_authenticated_users(self):
user = user_factory()
self.client.login_api(user)
target_rating = Rating.objects.create(
addon=addon_factory(), user=user_factory(), body='Booh', rating=1
)
for x in range(20):
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
HTTP_X_FORWARDED_FOR=f'123.45.67.89, {get_random_ip()}',
)
assert response.status_code == 201, x
# Different user, same IP: should still be blocked (> 20 / day).
new_user = user_factory()
self.client.login_api(new_user)
response = self.client.post(
self.url,
data={
'rating': str(target_rating.pk),
'message': 'abuse!',
'reason': 'illegal',
},
REMOTE_ADDR='123.45.67.89',
HTTP_X_FORWARDED_FOR=f'123.45.67.89, {get_random_ip()}',
)
assert response.status_code == 429
class TestAppeal(TestCase):
def setUp(self):
self.addon = addon_factory()

Просмотреть файл

@ -24,6 +24,7 @@ from olympia.abuse.forms import AbuseAppealEmailForm, AbuseAppealForm
from olympia.abuse.models import CinderReport
from olympia.abuse.serializers import (
AddonAbuseReportSerializer,
RatingAbuseReportSerializer,
UserAbuseReportSerializer,
)
from olympia.abuse.tasks import appeal_to_cinder
@ -31,6 +32,7 @@ from olympia.accounts.utils import redirect_for_login
from olympia.accounts.views import AccountViewSet
from olympia.addons.views import AddonViewSet
from olympia.api.throttling import GranularIPRateThrottle, GranularUserRateThrottle
from olympia.ratings.views import RatingViewSet
from .cinder import Cinder
@ -48,35 +50,56 @@ class AbuseIPThrottle(GranularIPRateThrottle):
scope = 'ip_abuse'
class AddonAbuseViewSet(CreateModelMixin, GenericViewSet):
class AbuseTargetMixin:
target_viewset_class = None # Implement in child classes
target_viewset_action = None # Implement in child classes or leave None
target_parameter_name = None # Implement in child classes (must match serializer)
def get_target_viewset(self):
if hasattr(self, 'target_viewset'):
return self.target_viewset
if 'target_pk' not in self.kwargs:
self.kwargs['target_pk'] = self.request.data.get(
self.target_parameter_name
) or self.request.GET.get(self.target_parameter_name)
self.target_viewset = self.target_viewset_class(
request=self.request,
permission_classes=[],
kwargs={'pk': self.kwargs['target_pk']},
action=self.target_viewset_action,
)
return self.target_viewset
# This method is used by the serializer.
def get_target_object(self):
if hasattr(self, 'target_object'):
return self.target_object
self.target_object = self.get_target_viewset().get_object()
return self.target_object
class AddonAbuseViewSet(AbuseTargetMixin, CreateModelMixin, GenericViewSet):
permission_classes = []
serializer_class = AddonAbuseReportSerializer
throttle_classes = (AbuseUserThrottle, AbuseIPThrottle)
target_viewset_class = AddonViewSet
target_viewset_action = 'retrieve_from_related'
target_parameter_name = 'addon'
def get_addon_viewset(self):
if hasattr(self, 'addon_viewset'):
return self.addon_viewset
if 'addon_pk' not in self.kwargs:
self.kwargs['addon_pk'] = self.request.data.get(
'addon'
) or self.request.GET.get('addon')
self.addon_viewset = AddonViewSet(
request=self.request,
permission_classes=[],
kwargs={'pk': self.kwargs['addon_pk']},
action='retrieve_from_related',
)
return self.addon_viewset
def get_addon_object(self):
if hasattr(self, 'addon_object'):
return self.addon_object
self.addon_object = self.get_addon_viewset().get_object()
if self.addon_object and not self.addon_object.is_public():
def get_target_object(self):
super().get_target_object()
# It's possible to submit reports against non-public add-ons via guid,
# but we can't reveal their slug/pk and can't accept their slug/pk as
# input either. We raise a 404 if get_target_object() is called for a
# non-public add-on: the view avoids calling it when a guid is passed
# (see get_guid()) and the serializer will handle the Http404 when
# returning the internal value (see get_addon() in serializer).
if self.target_object and not self.target_object.is_public():
raise Http404
return self.addon_object
return self.target_object
def get_guid(self):
"""
@ -91,36 +114,34 @@ class AddonAbuseViewSet(CreateModelMixin, GenericViewSet):
look like a guid and there is no public add-on with a matching slug or
pk.
"""
if self.get_addon_viewset().get_lookup_field(self.kwargs['addon_pk']) == 'guid':
guid = self.kwargs['addon_pk']
if (
self.get_target_viewset().get_lookup_field(self.kwargs['target_pk'])
== 'guid'
):
guid = self.kwargs['target_pk']
else:
# At this point the parameter is a slug or pk. For backwards-compatibility
# we accept that, but ultimately record only the guid.
self.get_addon_object()
if self.addon_object:
guid = self.addon_object.guid
self.get_target_object()
if self.target_object:
guid = self.target_object.guid
return guid
class UserAbuseViewSet(CreateModelMixin, GenericViewSet):
class UserAbuseViewSet(AbuseTargetMixin, CreateModelMixin, GenericViewSet):
permission_classes = []
serializer_class = UserAbuseReportSerializer
throttle_classes = (AbuseUserThrottle, AbuseIPThrottle)
target_viewset_class = AccountViewSet
target_parameter_name = 'user'
def get_user_object(self):
if hasattr(self, 'user_object'):
return self.user_object
if 'user_pk' not in self.kwargs:
self.kwargs['user_pk'] = self.request.data.get(
'user'
) or self.request.GET.get('user')
return AccountViewSet(
request=self.request,
permission_classes=[],
kwargs={'pk': self.kwargs['user_pk']},
).get_object()
class RatingAbuseViewSet(AbuseTargetMixin, CreateModelMixin, GenericViewSet):
permission_classes = []
serializer_class = RatingAbuseReportSerializer
throttle_classes = (AbuseUserThrottle, AbuseIPThrottle)
target_viewset_class = RatingViewSet
target_parameter_name = 'rating'
class CinderInboundPermission:
@ -222,9 +243,10 @@ def appeal(request, *, decision_id, **kwargs):
allowed_users = []
if hasattr(abuse_report.target, 'authors'):
allowed_users = abuse_report.target.authors.all()
# FIXME: when we implement collections in abuse reports
# elif hasattr(abuse_report.target, 'author'):
# allowed_users = [abuse_report.target.author]
elif hasattr(abuse_report.target, 'author'):
allowed_users = [abuse_report.target.author]
elif hasattr(abuse_report.target, 'user'):
allowed_users = [abuse_report.target.user]
valid_user_or_email_provided = request.user in allowed_users
if not valid_user_or_email_provided and not appeal_email_form:

Просмотреть файл

@ -0,0 +1,23 @@
# Generated by Django 4.2.6 on 2023-10-27 10:17
from django.db import migrations, models
import olympia.ratings.models
class Migration(migrations.Migration):
dependencies = [
('ratings', '0008_alter_deniedratingword_options_alter_ratingflag_flag'),
]
operations = [
migrations.AlterField(
model_name='deniedratingword',
name='word',
field=models.CharField(
help_text='Can only contain alphanumeric characters ("\\w", exc. "_"). If contains a "." it will be interpreted as a domain name instead, and can contain any character',
max_length=255,
unique=True,
validators=[olympia.ratings.models.word_validator],
),
),
]

Просмотреть файл

@ -29,8 +29,6 @@ urlpatterns = [
# Home.
re_path(r'^$', frontend_view, name='home'),
# Abuse.
# FIXME: modify nginx config to route /<locale>/abuse/ to addons-server
# This will need to not clash with new frontend reporting pages !
re_path(r'abuse/', include('olympia.abuse.urls')),
# Add-ons.
re_path(r'', include('olympia.addons.urls')),
@ -109,7 +107,7 @@ urlpatterns = [
),
]
if settings.DEBUG:
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar
# Remove leading and trailing slashes so the regex matches.