• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 5f2f614e-d4ec-4f64-b15a-86ff7a171194

19 Sep 2025 06:01PM UTC coverage: 88.146% (-0.7%) from 88.863%
5f2f614e-d4ec-4f64-b15a-86ff7a171194

Pull #5885

circleci

joeherm
fix(twilio): Add error handling for flaky Twilio calls
Pull Request #5885: fix(twilio): Add error handling for erroring Twilio calls

2926 of 3955 branches covered (73.98%)

Branch coverage included in aggregate %.

42 of 42 new or added lines in 2 files covered. (100.0%)

115 existing lines in 7 files now uncovered.

18200 of 20012 relevant lines covered (90.95%)

11.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.98
/api/views/privaterelay.py
1
from logging import getLogger
1✔
2
from typing import Any, Literal
1✔
3

4
from django.conf import settings
1✔
5
from django.contrib.auth.models import User
1✔
6
from django.db.models.query import QuerySet
1✔
7
from django.urls.exceptions import NoReverseMatch
1✔
8

9
import requests
1✔
10
from allauth.account.adapter import get_adapter as get_account_adapter
1✔
11
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
1✔
12
from allauth.socialaccount.helpers import complete_social_login
1✔
13
from allauth.socialaccount.models import SocialAccount
1✔
14
from django_filters.rest_framework import FilterSet
1✔
15
from drf_spectacular.utils import (
1✔
16
    OpenApiExample,
17
    OpenApiParameter,
18
    OpenApiRequest,
19
    OpenApiResponse,
20
    extend_schema,
21
)
22
from rest_framework.authentication import get_authorization_header
1✔
23
from rest_framework.decorators import (
1✔
24
    api_view,
25
    authentication_classes,
26
    permission_classes,
27
)
28
from rest_framework.exceptions import AuthenticationFailed, ErrorDetail, ParseError
1✔
29
from rest_framework.permissions import AllowAny, IsAuthenticated
1✔
30
from rest_framework.response import Response
1✔
31
from rest_framework.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
1✔
32
from rest_framework.viewsets import ModelViewSet
1✔
33
from waffle import get_waffle_flag_model
1✔
34
from waffle.models import Sample, Switch
1✔
35

36
from emails.utils import incr_if_enabled
1✔
37
from privaterelay.models import Profile
1✔
38
from privaterelay.plans import (
1✔
39
    PlanCountryLangMapping,
40
    get_bundle_country_language_mapping,
41
    get_megabundle_country_language_mapping,
42
    get_phone_country_language_mapping,
43
    get_premium_country_language_mapping,
44
)
45
from privaterelay.sp3_plans import (
1✔
46
    SP3PlanCountryLangMapping,
47
    get_sp3_country_language_mapping,
48
)
49
from privaterelay.utils import get_countries_info_from_request_and_mapping
1✔
50

51
from ..authentication import get_fxa_uid_from_oauth_token
1✔
52
from ..permissions import CanManageFlags, IsOwner
1✔
53
from ..serializers.privaterelay import (
1✔
54
    FlagSerializer,
55
    ProfileSerializer,
56
    UserSerializer,
57
    WebcompatIssueSerializer,
58
)
59

60
logger = getLogger("events")
1✔
61
info_logger = getLogger("eventsinfo")
1✔
62
FXA_PROFILE_URL = (
1✔
63
    f"{settings.SOCIALACCOUNT_PROVIDERS['fxa']['PROFILE_ENDPOINT']}/profile"
64
)
65

66

67
class FlagFilter(FilterSet):
1✔
68
    class Meta:
1✔
69
        model = get_waffle_flag_model()
1✔
70
        fields = [
1✔
71
            "name",
72
            "everyone",
73
            # "users",
74
            # read-only
75
            "id",
76
        ]
77

78

79
@extend_schema(tags=["privaterelay"])
1✔
80
class FlagViewSet(ModelViewSet):
1✔
81
    """Feature flags."""
82

83
    serializer_class = FlagSerializer
1✔
84
    permission_classes = [IsAuthenticated, CanManageFlags]
1✔
85
    filterset_class = FlagFilter
1✔
86
    http_method_names = ["get", "post", "head", "patch"]
1✔
87

88
    def get_queryset(self):
1✔
89
        flags = get_waffle_flag_model().objects
1✔
90
        return flags
1✔
91

92

93
@extend_schema(tags=["privaterelay"])
1✔
94
class ProfileViewSet(ModelViewSet):
1✔
95
    """Relay user extended profile data."""
96

97
    serializer_class = ProfileSerializer
1✔
98
    permission_classes = [IsAuthenticated, IsOwner]
1✔
99
    http_method_names = ["get", "post", "head", "put", "patch"]
1✔
100

101
    def get_queryset(self) -> QuerySet[Profile]:
1✔
102
        if isinstance(self.request.user, User):
1✔
103
            return Profile.objects.filter(user=self.request.user)
1✔
104
        return Profile.objects.none()
1✔
105

106

107
@extend_schema(tags=["privaterelay"])
1✔
108
class UserViewSet(ModelViewSet):
1✔
109
    """Relay user data stored in Django user model."""
110

111
    serializer_class = UserSerializer
1✔
112
    permission_classes = [IsAuthenticated, IsOwner]
1✔
113
    http_method_names = ["get", "head"]
1✔
114

115
    def get_queryset(self) -> QuerySet[User]:
1✔
116
        if isinstance(self.request.user, User):
1!
UNCOV
117
            return User.objects.filter(id=self.request.user.id)
×
118
        return User.objects.none()
1✔
119

120

121
@permission_classes([IsAuthenticated])
1✔
122
@extend_schema(
1✔
123
    tags=["privaterelay"],
124
    request=WebcompatIssueSerializer,
125
    examples=[
126
        OpenApiExample(
127
            "mask not accepted",
128
            {
129
                "issue_on_domain": "https://accounts.firefox.com",
130
                "user_agent": "Firefox",
131
                "email_mask_not_accepted": True,
132
                "add_on_visual_issue": False,
133
                "email_not_received": False,
134
                "other_issue": "",
135
            },
136
        )
137
    ],
138
    responses={
139
        "201": OpenApiResponse(description="Report was submitted"),
140
        "400": OpenApiResponse(description="Report was rejected due to errors."),
141
        "401": OpenApiResponse(description="Authentication required."),
142
    },
143
)
144
@api_view(["POST"])
1✔
145
def report_webcompat_issue(request):
1✔
146
    """Report a Relay issue from an extension or integration."""
147

148
    serializer = WebcompatIssueSerializer(data=request.data)
×
149
    if serializer.is_valid():
×
150
        info_logger.info("webcompat_issue", extra=serializer.data)
×
151
        incr_if_enabled("webcompat_issue", 1)
×
152
        for k, v in serializer.data.items():
×
153
            if v and k != "issue_on_domain":
×
154
                incr_if_enabled(f"webcompat_issue_{k}", 1)
×
155
        return Response(status=HTTP_201_CREATED)
×
UNCOV
156
    return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
×
157

158

159
def _get_example_plan(
1✔
160
    plan: Literal["premium", "phones", "bundle", "megabundle"],
161
) -> dict[str, Any]:
162
    prices = {
1✔
163
        "premium": {"monthly": 1.99, "yearly": 0.99},
164
        "phones": {"monthly": 4.99, "yearly": 4.99},
165
        "bundle": {"monthly": 6.99, "yearly": 6.99},
166
        "megabundle": {"monthly": 8.25, "yearly": 99},
167
    }
168
    monthly_price = {
1✔
169
        "id": f"price_{plan.title()}Monthlyxxxx",
170
        "currency": "usd",
171
        "price": prices[plan]["monthly"],
172
    }
173
    yearly_price = {
1✔
174
        "id": f"price_{plan.title()}Yearlyxxxx",
175
        "currency": "usd",
176
        "price": prices[plan]["yearly"],
177
    }
178
    return {
1✔
179
        "country_code": "US",
180
        "countries": ["CA", "US"],
181
        "available_in_country": True,
182
        "plan_country_lang_mapping": {
183
            "CA": {
184
                "*": {
185
                    "monthly": monthly_price,
186
                    "yearly": yearly_price,
187
                }
188
            },
189
            "US": {
190
                "*": {
191
                    "monthly": monthly_price,
192
                    "yearly": yearly_price,
193
                }
194
            },
195
        },
196
    }
197

198

199
@extend_schema(
1✔
200
    tags=["privaterelay"],
201
    responses={
202
        "200": OpenApiResponse(
203
            {"type": "object"},
204
            description="Site parameters",
205
            examples=[
206
                OpenApiExample(
207
                    "relay.firefox.com (partial)",
208
                    {
209
                        "FXA_ORIGIN": "https://accounts.firefox.com",
210
                        "PERIODICAL_PREMIUM_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
211
                        "GOOGLE_ANALYTICS_ID": "UA-########-##",
212
                        "GA4_MEASUREMENT_ID": "G-XXXXXXXXX",
213
                        "BUNDLE_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
214
                        "MEGABUNDLE_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
215
                        "PHONE_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
216
                        "PERIODICAL_PREMIUM_PLANS": _get_example_plan("premium"),
217
                        "PHONE_PLANS": _get_example_plan("phones"),
218
                        "BUNDLE_PLANS": _get_example_plan("bundle"),
219
                        "MEGABUNDLE_PLANS": _get_example_plan("megabundle"),
220
                        "BASKET_ORIGIN": "https://basket.mozilla.org",
221
                        "WAFFLE_FLAGS": [
222
                            ["foxfood", False],
223
                            ["phones", True],
224
                            ["bundle", True],
225
                            ["megabundle", True],
226
                        ],
227
                        "WAFFLE_SWITCHES": [],
228
                        "WAFFLE_SAMPLES": [],
229
                        "MAX_MINUTES_TO_VERIFY_REAL_PHONE": 5,
230
                    },
231
                )
232
            ],
233
        )
234
    },
235
)
236
@api_view()
1✔
237
@permission_classes([AllowAny])
1✔
238
def runtime_data(request):
1✔
239
    """Get data needed to present the Relay dashboard to a vistor or user."""
240
    flags = get_waffle_flag_model().get_all()
1✔
241
    flag_values = [(f.name, f.is_active(request)) for f in flags]
1✔
242
    switches = Switch.get_all()
1✔
243
    switch_values = [(s.name, s.is_active()) for s in switches]
1✔
244
    samples = Sample.get_all()
1✔
245
    sample_values = [(s.name, s.is_active()) for s in samples]
1✔
246
    premium_plans: SP3PlanCountryLangMapping | PlanCountryLangMapping
247
    phone_plans: SP3PlanCountryLangMapping | PlanCountryLangMapping
248
    bundle_plans: SP3PlanCountryLangMapping | PlanCountryLangMapping
249
    megabundle_plans: SP3PlanCountryLangMapping | PlanCountryLangMapping
250
    if settings.USE_SUBPLAT3:
1✔
251
        premium_plans = get_sp3_country_language_mapping("premium")
1✔
252
        phone_plans = get_sp3_country_language_mapping("phones")
1✔
253
        bundle_plans = get_sp3_country_language_mapping("bundle")
1✔
254
        megabundle_plans = get_sp3_country_language_mapping("megabundle")
1✔
255
    else:
256
        premium_plans = get_premium_country_language_mapping()
1✔
257
        phone_plans = get_phone_country_language_mapping()
1✔
258
        bundle_plans = get_bundle_country_language_mapping()
1✔
259
        megabundle_plans = get_megabundle_country_language_mapping()
1✔
260
    return Response(
1✔
261
        {
262
            "FXA_ORIGIN": settings.FXA_BASE_ORIGIN,
263
            "PERIODICAL_PREMIUM_PRODUCT_ID": settings.PERIODICAL_PREMIUM_PROD_ID,
264
            "GOOGLE_ANALYTICS_ID": settings.GOOGLE_ANALYTICS_ID,
265
            "GA4_MEASUREMENT_ID": settings.GA4_MEASUREMENT_ID,
266
            "BUNDLE_PRODUCT_ID": settings.BUNDLE_PROD_ID,
267
            "PHONE_PRODUCT_ID": settings.PHONE_PROD_ID,
268
            "MEGABUNDLE_PRODUCT_ID": settings.MEGABUNDLE_PROD_ID,
269
            "PERIODICAL_PREMIUM_PLANS": get_countries_info_from_request_and_mapping(
270
                request, premium_plans
271
            ),
272
            "PHONE_PLANS": get_countries_info_from_request_and_mapping(
273
                request, phone_plans
274
            ),
275
            "BUNDLE_PLANS": get_countries_info_from_request_and_mapping(
276
                request, bundle_plans
277
            ),
278
            "MEGABUNDLE_PLANS": get_countries_info_from_request_and_mapping(
279
                request, megabundle_plans
280
            ),
281
            "BASKET_ORIGIN": settings.BASKET_ORIGIN,
282
            "WAFFLE_FLAGS": flag_values,
283
            "WAFFLE_SWITCHES": switch_values,
284
            "WAFFLE_SAMPLES": sample_values,
285
            "MAX_MINUTES_TO_VERIFY_REAL_PHONE": (
286
                settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE
287
            ),
288
        }
289
    )
290

291

292
@extend_schema(
1✔
293
    tags=["privaterelay"],
294
    parameters=[
295
        OpenApiParameter(
296
            name="Authorization",
297
            required=True,
298
            location="header",
299
            examples=[OpenApiExample("bearer", "Bearer XXXX-ZZZZ")],
300
            description="FXA Bearer Token. Can not be set in browsable API.",
301
        )
302
    ],
303
    request=OpenApiRequest(),
304
    responses={
305
        201: OpenApiResponse(description="Created; returned when user is created."),
306
        202: OpenApiResponse(
307
            description="Accepted; returned when user already exists."
308
        ),
309
        400: OpenApiResponse(
310
            description=(
311
                "Bad request; returned when request is missing Authorization: Bearer"
312
                " header or token value."
313
            )
314
        ),
315
        401: OpenApiResponse(
316
            description=(
317
                "Unauthorized; returned when the FXA token is invalid or expired."
318
            )
319
        ),
320
        404: OpenApiResponse(description="FXA did not return a user."),
321
        500: OpenApiResponse(description="No response from FXA server."),
322
    },
323
)
324
@api_view(["POST"])
1✔
325
@permission_classes([AllowAny])
1✔
326
@authentication_classes([])
1✔
327
def terms_accepted_user(request):
1✔
328
    """
329
    Create a Relay user from an FXA token.
330

331
    See [API Auth doc][api-auth-doc] for details.
332

333
    [api-auth-doc]: https://github.com/mozilla/fx-private-relay/blob/main/docs/api_auth.md#firefox-oauth-token-authentication-and-accept-terms-of-service
334
    """  # noqa: E501
335
    # Setting authentication_classes to empty due to
336
    # authentication still happening despite permissions being set to allowany
337
    # https://forum.djangoproject.com/t/solved-allowany-override-does-not-work-on-apiview/9754
338
    # TODO: Implement an FXA token authentication class
339
    authorization = get_authorization_header(request).decode()
1✔
340
    if not authorization or not authorization.startswith("Bearer "):
1✔
341
        raise ParseError("Missing Bearer header.")
1✔
342

343
    token = authorization.split(" ")[1]
1✔
344
    if token == "":
1✔
345
        raise ParseError("Missing FXA Token after 'Bearer'.")
1✔
346

347
    try:
1✔
348
        fxa_uid = get_fxa_uid_from_oauth_token(token, use_cache=False)
1✔
349
    except AuthenticationFailed as e:
1✔
350
        # AuthenticationFailed exception returns 403 instead of 401 because we are not
351
        # using the proper config that comes with the authentication_classes. See:
352
        # https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication
353
        if isinstance(e.detail, ErrorDetail):
1!
354
            return Response(data={"detail": e.detail.title()}, status=e.status_code)
1✔
355
        else:
UNCOV
356
            return Response(data={"detail": e.get_full_details()}, status=e.status_code)
×
357
    status_code = 201
1✔
358

359
    try:
1✔
360
        sa = SocialAccount.objects.get(uid=fxa_uid, provider="fxa")
1✔
361
        status_code = 202
1✔
362
    except SocialAccount.DoesNotExist:
1✔
363
        # User does not exist, create a new Relay user
364
        fxa_profile_resp = requests.get(
1✔
365
            FXA_PROFILE_URL,
366
            headers={"Authorization": f"Bearer {token}"},
367
            timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
368
        )
369
        if not (fxa_profile_resp.ok and fxa_profile_resp.content):
1✔
370
            logger.error(
1✔
371
                "terms_accepted_user: bad account profile response",
372
                extra={
373
                    "status_code": fxa_profile_resp.status_code,
374
                    "content": fxa_profile_resp.content,
375
                },
376
            )
377
            return Response(
1✔
378
                data={"detail": "Did not receive a 200 response for account profile."},
379
                status=500,
380
            )
381

382
        # This is not exactly the request object that FirefoxAccountsProvider expects,
383
        # but it has all of the necessary attributes to initialize the Provider
384
        provider = get_social_adapter().get_provider(request, "fxa")
1✔
385
        # This may not save the new user that was created
386
        # https://github.com/pennersr/django-allauth/blob/77368a84903d32283f07a260819893ec15df78fb/allauth/socialaccount/providers/base/provider.py#L44
387
        social_login = provider.sociallogin_from_response(
1✔
388
            request, fxa_profile_resp.json()
389
        )
390
        # Complete social login is called by callback, see
391
        # https://github.com/pennersr/django-allauth/blob/77368a84903d32283f07a260819893ec15df78fb/allauth/socialaccount/providers/oauth/views.py#L118
392
        # for what we are mimicking to create new SocialAccount, User, and Profile for
393
        # the new Relay user from Firefox Since this is a Resource Provider/Server flow
394
        # and are NOT a Relying Party (RP) of FXA No social token information is stored
395
        # (no Social Token object created).
396
        try:
1✔
397
            complete_social_login(request, social_login)
1✔
398
            # complete_social_login writes ['account_verified_email', 'user_created',
399
            # '_auth_user_id', '_auth_user_backend', '_auth_user_hash'] on
400
            # request.session which sets the cookie because complete_social_login does
401
            # the "login" The user did not actually log in, logout to clear the session
402
            if request.user.is_authenticated:
1!
403
                get_account_adapter(request).logout(request)
1✔
404
        except NoReverseMatch as e:
1✔
405
            # TODO: use this logging to fix the underlying issue
406
            # https://mozilla-hub.atlassian.net/browse/MPP-3473
407
            if "socialaccount_signup" in e.args[0]:
1!
408
                logger.error(
1✔
409
                    "socialaccount_signup_error",
410
                    extra={
411
                        "exception": str(e),
412
                        "fxa_uid": fxa_uid,
413
                        "social_login_state": social_login.state,
414
                    },
415
                )
416
                return Response(status=500)
1✔
UNCOV
417
            raise e
×
418
        sa = SocialAccount.objects.get(uid=fxa_uid, provider="fxa")
1✔
419
        # Indicate profile was created from the resource flow
420
        profile = sa.user.profile
1✔
421
        profile.created_by = "firefox_resource"
1✔
422
        profile.save()
1✔
423
    info_logger.info(
1✔
424
        "terms_accepted_user",
425
        extra={"social_account": sa.uid, "status_code": status_code},
426
    )
427
    return Response(status=status_code)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc