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

mozilla / fx-private-relay / 84353759-c057-4f20-b282-724c34504dc9

26 Nov 2025 04:22PM UTC coverage: 89.192% (+0.4%) from 88.772%
84353759-c057-4f20-b282-724c34504dc9

Pull #6049

circleci

jwhitlock
Update TermsAcceptedUserViewTest for new errors
Pull Request #6049: fix(relay): Create alternate bearer token auth for FxA (MPP-3505)

3016 of 4041 branches covered (74.63%)

Branch coverage included in aggregate %.

1334 of 1349 new or added lines in 9 files covered. (98.89%)

6 existing lines in 2 files now uncovered.

19067 of 20718 relevant lines covered (92.03%)

11.09 hits per line

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

99.5
/api/tests/authentication_tests.py
1
import re
1✔
2
from collections.abc import Iterator
1✔
3
from datetime import UTC, datetime, timedelta
1✔
4
from typing import Any
1✔
5
from unittest.mock import Mock, patch
1✔
6

7
from django.conf import settings
1✔
8
from django.contrib.auth.models import AnonymousUser, User
1✔
9
from django.core.cache import BaseCache
1✔
10

11
import pytest
1✔
12
import responses
1✔
13
from allauth.socialaccount.models import SocialAccount, SocialApp
1✔
14
from pytest_django.fixtures import SettingsWrapper
1✔
15
from requests import ReadTimeout
1✔
16
from rest_framework.exceptions import AuthenticationFailed
1✔
17
from rest_framework.test import APIRequestFactory
1✔
18

19
from ..authentication import (
1✔
20
    INTROSPECT_ERROR,
21
    INTROSPECT_TOKEN_URL,
22
    FxaIntrospectData,
23
    FxaTokenAuthentication,
24
    IntrospectAuthenticationFailed,
25
    IntrospectionError,
26
    IntrospectionResponse,
27
    IntrospectUnavailable,
28
    as_b64,
29
    get_cache_key,
30
    introspect_and_cache_token,
31
    introspect_token,
32
    load_introspection_result_from_cache,
33
)
34

35

36
def _create_fxa_introspect_response(
1✔
37
    active: bool = True,
38
    uid: str | None = "an-fxa-id",
39
    scope: str | None = None,
40
    expiration: bool = True,
41
    error: str | None = None,
42
) -> FxaIntrospectData:
43
    """Create a Mozilla Accounts introspection response"""
44
    if error:
1✔
45
        return {"error": error}
1✔
46
    data: FxaIntrospectData = {"active": active}
1✔
47
    if uid:
1✔
48
        data["sub"] = uid
1✔
49
    data["scope"] = f"profile {settings.RELAY_SCOPE}" if scope is None else scope
1✔
50
    if expiration:
1✔
51
        now_time = int(datetime.now().timestamp())
1✔
52
        exp_ms = (now_time + 60 * 60) * 1000  # Time in milliseconds
1✔
53
        data["exp"] = exp_ms
1✔
54
    return data
1✔
55

56

57
def _mock_fxa_introspect_response(
1✔
58
    status_code: int = 200,
59
    data: FxaIntrospectData | str | None = None,
60
    timeout: bool = False,
61
    exception: Exception | None = None,
62
) -> responses.BaseResponse:
63
    """Mock the response to a Mozilla Accounts introspection request"""
64
    if timeout:
1✔
65
        return responses.add(
1✔
66
            responses.POST, INTROSPECT_TOKEN_URL, body=ReadTimeout("FxA is slow today")
67
        )
68
    if exception:
1✔
69
        return responses.add(responses.POST, INTROSPECT_TOKEN_URL, body=exception)
1✔
70
    return responses.add(
1✔
71
        responses.POST,
72
        INTROSPECT_TOKEN_URL,
73
        status=status_code,
74
        json=data,
75
    )
76

77

78
class MockTimer:
1✔
79
    """Mocked version of codetiming.Timer."""
80

81
    def __init__(self, logger: Any) -> None:
1✔
82
        assert logger is None
1✔
83

84
    def __enter__(self) -> "MockTimer":
1✔
85
        return self
1✔
86

87
    def __exit__(self, *exc_info: Any) -> None:
1✔
88
        self.last = 0.5
1✔
89

90

91
@pytest.fixture(autouse=True)
1✔
92
def mock_timer() -> Iterator[Mock]:
1✔
93
    with patch("api.authentication.Timer", side_effect=MockTimer) as MockedTimer:
1✔
94
        yield MockedTimer
1✔
95

96

97
@pytest.fixture(autouse=True)
1✔
98
def auth_2025_settings(settings: SettingsWrapper) -> SettingsWrapper:
1✔
99
    settings.FXA_TOKEN_AUTH_VERSION = "2025"
1✔
100
    return settings
1✔
101

102

103
def setup_fxa_introspect(
1✔
104
    status_code: int = 200,
105
    no_body: bool = False,
106
    text_body: str | None = None,
107
    active: bool = True,
108
    uid: str | None = "an-fxa-id",
109
    scope: str | None = None,
110
    expiration: bool = True,
111
    error: str | None = None,
112
    timeout: bool = False,
113
    exception: Exception | None = None,
114
) -> tuple[responses.BaseResponse, FxaIntrospectData | None]:
115
    """
116
    Mock a Mozilla Accounts introspection response. Return both the
117
    request-level response mock (to check how often the request was made)
118
    and the mocked response body.
119
    """
120
    data: FxaIntrospectData | None = None
1✔
121
    if no_body:
1✔
122
        mock_response = _mock_fxa_introspect_response(status_code)
1✔
123
    elif text_body:
1✔
124
        mock_response = _mock_fxa_introspect_response(status_code, text_body)
1✔
125
    elif timeout:
1✔
126
        mock_response = _mock_fxa_introspect_response(timeout=True)
1✔
127
    elif exception:
1✔
128
        mock_response = _mock_fxa_introspect_response(exception=exception)
1✔
129
    else:
130
        data = _create_fxa_introspect_response(
1✔
131
            active=active, uid=uid, scope=scope, expiration=expiration, error=error
132
        )
133
        mock_response = _mock_fxa_introspect_response(status_code, data)
1✔
134
    return mock_response, data
1✔
135

136

137
def future_exp() -> int:
1✔
138
    """Create an exp value (timestamp in ms) in the future"""
NEW
139
    future = datetime.now() + timedelta(days=1)
×
NEW
140
    return int(future.timestamp()) * 1000
×
141

142

143
_INTROSPECTION_RESPONSE_VALUEERROR_TEST_CASES = {
1✔
144
    "active_missing": ({}, "active should be true"),
145
    "active_false": ({"active": False}, "active should be true"),
146
    "sub_missing": ({"active": True}, "sub (FxA ID) should be set"),
147
    "sub_not_str": ({"active": True, "sub": 1}, "sub (FxA ID) should be set"),
148
    "sub_empty": ({"active": True, "sub": ""}, "sub (FxA ID) should be set"),
149
    "exp_not_int": (
150
        {"active": True, "sub": "s", "exp": "123"},
151
        "exp (Expiration timestamp in milliseconds) should be int",
152
    ),
153
    "missing_scope": (
154
        {"active": True, "sub": "s", "exp": 123},
155
        f"scope should include {settings.RELAY_SCOPE!r}",
156
    ),
157
    "wrong_scope": (
158
        {"active": True, "sub": "s", "exp": 123, "scope": "foo"},
159
        f"scope should include {settings.RELAY_SCOPE!r}",
160
    ),
161
}
162

163

164
@pytest.mark.parametrize(
1✔
165
    "data,message",
166
    _INTROSPECTION_RESPONSE_VALUEERROR_TEST_CASES.values(),
167
    ids=_INTROSPECTION_RESPONSE_VALUEERROR_TEST_CASES.keys(),
168
)
169
def test_introspection_response_init_bad_data_raises_value_error(
1✔
170
    data: FxaIntrospectData, message: str
171
) -> None:
172
    with pytest.raises(ValueError, match=re.escape(message)):
1✔
173
        IntrospectionResponse("token", data)
1✔
174

175

176
def test_introspection_response_with_expiration() -> None:
1✔
177
    data = _create_fxa_introspect_response()
1✔
178
    response = IntrospectionResponse("token", data)
1✔
179
    assert repr(response) == (
1✔
180
        "IntrospectionResponse(token='token',"
181
        " data={'active': True,"
182
        " 'sub': 'an-fxa-id',"
183
        f" 'scope': 'profile {settings.RELAY_SCOPE}',"
184
        f" 'exp': {data['exp']}"
185
        "},"
186
        " from_cache=False,"
187
        " request_s=None)"
188
    )
189
    assert 3530 < response.cache_timeout <= 3600  # about 60 minutes
1✔
190
    assert not response.is_expired
1✔
191

192

193
def test_introspection_response_repr_from_cache() -> None:
1✔
194
    data = _create_fxa_introspect_response(uid="other-fxa-id")
1✔
195
    response = IntrospectionResponse("token", data, from_cache=True)
1✔
196
    assert repr(response) == (
1✔
197
        "IntrospectionResponse(token='token',"
198
        " data={'active': True,"
199
        " 'sub': 'other-fxa-id',"
200
        f" 'scope': 'profile {settings.RELAY_SCOPE}',"
201
        f" 'exp': {data['exp']}"
202
        "},"
203
        " from_cache=True,"
204
        " request_s=None)"
205
    )
206
    assert 3530 < response.cache_timeout <= 3600  # about 60 minutes
1✔
207
    assert not response.is_expired
1✔
208

209

210
def test_introspection_response_repr_with_request_s() -> None:
1✔
211
    data = _create_fxa_introspect_response()
1✔
212
    response = IntrospectionResponse("token", data, request_s=0.23)
1✔
213
    assert repr(response) == (
1✔
214
        "IntrospectionResponse(token='token',"
215
        " data={'active': True,"
216
        " 'sub': 'an-fxa-id',"
217
        f" 'scope': 'profile {settings.RELAY_SCOPE}',"
218
        f" 'exp': {data['exp']}"
219
        "},"
220
        " from_cache=False,"
221
        " request_s=0.23)"
222
    )
223

224

225
def test_introspection_response_fxa_id() -> None:
1✔
226
    data = _create_fxa_introspect_response(uid="the-fxa-id")
1✔
227
    response = IntrospectionResponse("token", data)
1✔
228
    assert response.fxa_id == "the-fxa-id"
1✔
229

230

231
def test_introspection_response_equality() -> None:
1✔
232
    data = _create_fxa_introspect_response()
1✔
233
    response = IntrospectionResponse("token", data)
1✔
234
    assert response == IntrospectionResponse("token", data)
1✔
235
    assert response != IntrospectionError("token", "NotJson", data=data)
1✔
236
    assert response != IntrospectionResponse("token", data, from_cache=True)
1✔
237
    other_sub = data.copy()
1✔
238
    other_sub["sub"] = "other-fxa-id"
1✔
239
    assert response != IntrospectionResponse("token", other_sub)
1✔
240

241

242
@pytest.mark.parametrize("from_cache", (True, False))
1✔
243
@pytest.mark.parametrize("request_s", (1.0, None))
1✔
244
def test_introspection_response_as_cache_value(
1✔
245
    from_cache: bool, request_s: None | float
246
) -> None:
247
    data = _create_fxa_introspect_response()
1✔
248
    response = IntrospectionResponse(
1✔
249
        "token", data, from_cache=from_cache, request_s=request_s
250
    )
251
    # from_cache and request_s is not in cached value
252
    assert response.as_cache_value() == {"data": data}
1✔
253

254

255
@pytest.mark.parametrize("from_cache", (True, False))
1✔
256
def test_introspection_response_save_to_cache(from_cache: bool) -> None:
1✔
257
    data = _create_fxa_introspect_response()
1✔
258
    response = IntrospectionResponse("token", data, from_cache=from_cache)
1✔
259
    mock_cache = Mock(spec_set=["set"])
1✔
260
    response.save_to_cache(mock_cache, "the-key", 60)
1✔
261
    mock_cache.set.assert_called_once_with(get_cache_key("the-key"), {"data": data}, 60)
1✔
262

263

264
_INTROSPECTION_ERROR_REPR_TEST_CASES: dict[str, tuple[IntrospectionError, str]] = {
1✔
265
    "Timeout": (
266
        IntrospectionError("token", "Timeout"),
267
        "IntrospectionError(token='token', error='Timeout', error_args=[],"
268
        " status_code=None, data=None, from_cache=False, request_s=None)",
269
    ),
270
    "FailedRequest": (
271
        IntrospectionError(
272
            "token2",
273
            "FailedRequest",
274
            error_args=["requests.ConnectionError", "Accounts Rebooting"],
275
        ),
276
        "IntrospectionError(token='token2', error='FailedRequest',"
277
        " error_args=['requests.ConnectionError', 'Accounts Rebooting'],"
278
        " status_code=None, data=None, from_cache=False, request_s=None)",
279
    ),
280
    "NotJson": (
281
        IntrospectionError("token3", "NotJson", error_args=[""], status_code=200),
282
        "IntrospectionError(token='token3', error='NotJson', error_args=[''],"
283
        " status_code=200, data=None, from_cache=False, request_s=None)",
284
    ),
285
    "NotAuthorized": (
286
        IntrospectionError("token4", "NotAuthorized", status_code=401),
287
        "IntrospectionError(token='token4', error='NotAuthorized', error_args=[],"
288
        " status_code=401, data=None, from_cache=False, request_s=None)",
289
    ),
290
    "NotActive": (
291
        IntrospectionError(
292
            "token5",
293
            "NotActive",
294
            status_code=200,
295
            data={"active": False},
296
            from_cache=True,
297
        ),
298
        "IntrospectionError(token='token5', error='NotActive', error_args=[],"
299
        " status_code=200, data={'active': False}, from_cache=True, request_s=None)",
300
    ),
301
    "NoSubject": (
302
        IntrospectionError(
303
            "token6", "NoSubject", status_code=200, data={"active": True}, request_s=0.3
304
        ),
305
        "IntrospectionError(token='token6', error='NoSubject', error_args=[],"
306
        " status_code=200, data={'active': True}, from_cache=False, request_s=0.3)",
307
    ),
308
}
309

310

311
@pytest.mark.parametrize(
1✔
312
    "err,expected",
313
    _INTROSPECTION_ERROR_REPR_TEST_CASES.values(),
314
    ids=_INTROSPECTION_ERROR_REPR_TEST_CASES.keys(),
315
)
316
def test_introspection_error_repr(err: IntrospectionError, expected: str) -> None:
1✔
317
    assert repr(err) == expected
1✔
318

319

320
@pytest.mark.parametrize("from_cache", (True, False))
1✔
321
def test_introspection_error_as_cache_value_no_optional_params(
1✔
322
    from_cache: bool,
323
) -> None:
324
    error = IntrospectionError("token", "Timeout", from_cache=from_cache)
1✔
325
    # from_cache is not in cached value
326
    assert error.as_cache_value() == {"error": "Timeout"}
1✔
327

328

329
def test_introspection_error_save_to_cache_no_optional_params() -> None:
1✔
330
    error = IntrospectionError("token", "Timeout")
1✔
331
    mock_cache = Mock(spec_set=["set"])
1✔
332
    error.save_to_cache(mock_cache, "cache-key", 60)
1✔
333
    mock_cache.set.assert_called_once_with(
1✔
334
        get_cache_key("cache-key"), error.as_cache_value(), 60
335
    )
336

337

338
def test_introspection_error_as_cache_value_all_optional_params() -> None:
1✔
339
    error = IntrospectionError(
1✔
340
        "token",
341
        "NotOK",
342
        error_args=["something"],
343
        status_code=401,
344
        data={"error": "crazy stuff"},
345
        from_cache=True,
346
    )
347
    assert error.as_cache_value() == {
1✔
348
        "error": "NotOK",
349
        "status_code": 401,
350
        "data": {"error": "crazy stuff"},
351
        "error_args": ["something"],
352
    }
353

354

355
def test_introspection_error_save_to_cache_all_optional_params() -> None:
1✔
356
    error = IntrospectionError(
1✔
357
        "token",
358
        "NotOK",
359
        error_args=["something"],
360
        status_code=401,
361
        data={"error": "crazy stuff"},
362
        from_cache=True,
363
    )
364
    mock_cache = Mock(spec_set=["set"])
1✔
365
    error.save_to_cache(mock_cache, "cache-key", 60)
1✔
366
    mock_cache.set.assert_called_once_with(
1✔
367
        get_cache_key("cache-key"), error.as_cache_value(), 60
368
    )
369

370

371
def test_introspection_error_eq() -> None:
1✔
372
    err = IntrospectionError("token", "NotActive")
1✔
373
    assert err == IntrospectionError("token", "NotActive")
1✔
374
    assert err != IntrospectionError("other_token", "NotActive")
1✔
375
    assert err != IntrospectionError("token", "NotActive", status_code=200)
1✔
376
    assert err != IntrospectionError("token", "NotActive", data={})
1✔
377
    assert err != IntrospectionError("token", "NotActive", error_args=["an arg"])
1✔
378
    assert err != IntrospectionError("token", "NotActive", from_cache=True)
1✔
379
    assert err != IntrospectAuthenticationFailed(err)
1✔
380

381

382
def test_introspection_error_raises_exception_401() -> None:
1✔
383
    error = IntrospectionError(
1✔
384
        "token", "NotActive", status_code=401, data={"active": False}
385
    )
386
    with pytest.raises(IntrospectAuthenticationFailed) as exc_info:
1✔
387
        error.raise_exception("METHOD", "path")
1✔
388
    exception = exc_info.value
1✔
389
    assert exception.status_code == 401
1✔
390
    assert str(exception.detail) == "Incorrect authentication credentials."
1✔
391
    assert exception.args == (error,)
1✔
392

393

394
def test_introspection_error_raises_exception_503() -> None:
1✔
395
    error = IntrospectionError("token", "Timeout")
1✔
396
    with pytest.raises(IntrospectUnavailable) as exc_info:
1✔
397
        error.raise_exception("METHOD", "path")
1✔
398
    exception = exc_info.value
1✔
399
    assert exception.status_code == 503
1✔
400
    expected_detail = "Introspection temporarily unavailable, try again later."
1✔
401
    assert str(exception.detail) == expected_detail
1✔
402
    assert exception.args == (error,)
1✔
403

404

405
@responses.activate
1✔
406
def test_introspect_token_success_returns_introspection_response() -> None:
1✔
407
    mock_response, fxa_data = setup_fxa_introspect()
1✔
408
    assert fxa_data is not None
1✔
409

410
    fxa_resp = introspect_token("the-token")
1✔
411
    assert fxa_resp == IntrospectionResponse("the-token", fxa_data, request_s=0.5)
1✔
412
    assert mock_response.call_count == 1
1✔
413

414

415
@responses.activate
1✔
416
def test_introspect_token_no_expiration_uses_grace_period() -> None:
1✔
417
    mock_response, fxa_data = setup_fxa_introspect(expiration=False)
1✔
418
    assert fxa_data is not None
1✔
419

420
    fxa_resp = introspect_token("the-token")
1✔
421
    assert isinstance(fxa_resp, IntrospectionResponse)
1✔
422
    assert fxa_resp.token == "the-token"
1✔
423
    assert "exp" in fxa_resp.data
1✔
424
    assert mock_response.call_count == 1
1✔
425

426

427
# Test cases for introspect_token() that return an IntrospectionError
428
# Tuple is:
429
# - arguments to setup_fxa_introspect
430
# - the IntrospectionError error
431
# - other keyword parameters to IntrospectionError.
432
#   The special keyword parameter {"data": _SETUP_FXA_DATA} means to use
433
#   the mocked FxA Introspect body.
434
_SETUP_FXA_DATA = object()
1✔
435
_SETUP_FXA_DATA_B64 = object()
1✔
436
_INTROSPECT_TOKEN_FAILURE_TEST_CASES: list[
1✔
437
    tuple[dict[str, Any], INTROSPECT_ERROR, dict[str, Any]]
438
] = [
439
    ({"timeout": True}, "Timeout", {}),
440
    (
441
        {"exception": Exception("An Exception")},
442
        "FailedRequest",
443
        {"error_args": ["Exception", "An Exception"]},
444
    ),
445
    ({"no_body": True}, "NotJson", {"status_code": 200, "error_args": [as_b64("")]}),
446
    (
447
        {"text_body": '[{"active": false}]'},
448
        "NotJsonDict",
449
        {
450
            "status_code": 200,
451
            "error_args": [as_b64('[{"active": false}]')],
452
        },
453
    ),
454
    (
455
        {"status_code": 401, "active": False},
456
        "NotAuthorized",
457
        {"status_code": 401, "error_args": [_SETUP_FXA_DATA_B64]},
458
    ),
459
    # Attempting to continue on non-200 error
460
    # ({"status_code": 500}, "NotOK", {"status_code": 500, "data": _SETUP_FXA_DATA}),
461
    ({"active": False}, "NotActive", {"status_code": 200, "data": _SETUP_FXA_DATA}),
462
    ({"uid": None}, "NoSubject", {"status_code": 200, "data": _SETUP_FXA_DATA}),
463
    ({"scope": "foo"}, "MissingScope", {"status_code": 200, "data": _SETUP_FXA_DATA}),
464
]
465

466

467
@pytest.mark.parametrize(
1✔
468
    "setup_args,error,error_params",
469
    _INTROSPECT_TOKEN_FAILURE_TEST_CASES,
470
    ids=[case[1] for case in _INTROSPECT_TOKEN_FAILURE_TEST_CASES],
471
)
472
@responses.activate
1✔
473
def test_introspect_token_error_returns_introspection_error(
1✔
474
    setup_args: dict[str, Any], error: INTROSPECT_ERROR, error_params: dict[str, Any]
475
) -> None:
476
    mock_response, fxa_data = setup_fxa_introspect(**setup_args)
1✔
477
    params = error_params.copy()
1✔
478
    if error_params.get("data") is _SETUP_FXA_DATA:
1✔
479
        assert fxa_data is not None
1✔
480
        params["data"] = fxa_data
1✔
481
    if error_params.get("error_args", [None])[0] is _SETUP_FXA_DATA_B64:
1✔
482
        assert fxa_data is not None
1✔
483
        params["error_args"] = [as_b64(fxa_data)]
1✔
484
    expected_resp = IntrospectionError("err-token", error, request_s=0.5, **params)
1✔
485

486
    fxa_resp = introspect_token("err-token")
1✔
487
    assert fxa_resp == expected_resp
1✔
488
    assert mock_response.call_count == 1
1✔
489

490

491
def test_load_introspection_result_from_cache_expired_token() -> None:
1✔
492
    fxa_data = _create_fxa_introspect_response()
1✔
493
    expired = datetime.now() - timedelta(days=1)
1✔
494
    fxa_data["exp"] = int(expired.timestamp()) * 1000
1✔
495
    cache = Mock(spec_set=["get"])
1✔
496
    cache.get.return_value = {"data": fxa_data}
1✔
497
    token = "cached_token"
1✔
498

499
    response = load_introspection_result_from_cache(cache, token)
1✔
500
    assert response == IntrospectionError(
1✔
501
        token,
502
        "TokenExpired",
503
        error_args=[response.error_args[0]],
504
        data=fxa_data,
505
        from_cache=True,
506
    )
507
    # Error arg is how many seconds it is expired
508
    one_day_ago = -1 * 24 * 60 * 60
1✔
509
    assert one_day_ago + 5 > int(response.error_args[0]) >= one_day_ago
1✔
510
    cache.get.assert_called_once_with(get_cache_key(token))
1✔
511

512

513
def test_load_introspection_result_from_cache_introspection_response() -> None:
1✔
514
    fxa_data = _create_fxa_introspect_response()
1✔
515
    cache = Mock(spec_set=["get"])
1✔
516
    cache.get.return_value = {"data": fxa_data}
1✔
517
    token = "cached_token"
1✔
518

519
    response = load_introspection_result_from_cache(cache, token)
1✔
520
    assert isinstance(response, IntrospectionResponse)
1✔
521
    assert response == IntrospectionResponse(token, fxa_data, from_cache=True)
1✔
522
    cache.get.assert_called_once_with(get_cache_key(token))
1✔
523

524

525
def test_load_introspection_result_from_cache_introspection_error_no_args() -> None:
1✔
526
    cache = Mock(spec_set=["get"])
1✔
527
    cache.get.return_value = {"error": "Timeout"}
1✔
528
    token = "cached_token"
1✔
529

530
    error = load_introspection_result_from_cache(cache, token)
1✔
531
    assert isinstance(error, IntrospectionError)
1✔
532
    assert error == IntrospectionError(token, "Timeout", from_cache=True)
1✔
533
    cache.get.assert_called_once_with(get_cache_key(token))
1✔
534

535

536
def test_load_introspection_result_from_cache_introspection_error_all_args() -> None:
1✔
537
    cache = Mock(spec_set=["get"])
1✔
538
    cache.get.return_value = {
1✔
539
        "error": "NotOK",
540
        "status_code": 401,
541
        "data": {"error": "crazy stuff"},
542
        "error_args": ["something"],
543
    }
544
    token = "cached_error_token"
1✔
545

546
    error = load_introspection_result_from_cache(cache, token)
1✔
547
    assert isinstance(error, IntrospectionError)
1✔
548
    assert error == IntrospectionError(
1✔
549
        token,
550
        "NotOK",
551
        error_args=["something"],
552
        status_code=401,
553
        data={"error": "crazy stuff"},
554
        from_cache=True,
555
    )
556
    cache.get.assert_called_once_with(get_cache_key(token))
1✔
557

558

559
def test_load_introspection_result_from_cache_introspection_bad_value() -> None:
1✔
560
    cache = Mock(spec_set=["get"])
1✔
561
    cache.get.return_value = "Not a dictionary"
1✔
562
    token = "uncached_token"
1✔
563

564
    assert load_introspection_result_from_cache(cache, token) is None
1✔
565
    cache.get.assert_called_once_with(get_cache_key(token))
1✔
566

567

568
@responses.activate
1✔
569
def test_introspect_and_cache_token_mocked_success_is_cached(cache: BaseCache) -> None:
1✔
570
    user_token = "user-123"
1✔
571
    cache_key = get_cache_key(user_token)
1✔
572
    fxa_id = "fxa-id-for-user-123"
1✔
573
    mock_response, fxa_data = setup_fxa_introspect(uid=fxa_id)
1✔
574
    assert fxa_data is not None
1✔
575
    assert cache.get(cache_key) is None
1✔
576

577
    # get FxA ID for the first time
578
    fxa_resp = introspect_and_cache_token(user_token)
1✔
579
    assert isinstance(fxa_resp, IntrospectionResponse)
1✔
580
    assert fxa_resp.fxa_id == fxa_id
1✔
581
    assert not fxa_resp.from_cache
1✔
582
    assert mock_response.call_count == 1
1✔
583
    assert cache.get(cache_key) == fxa_resp.as_cache_value()
1✔
584

585
    # now check that the 2nd call did NOT make another fxa request
586
    fxa_resp2 = introspect_and_cache_token(user_token)
1✔
587
    assert isinstance(fxa_resp, IntrospectionResponse)
1✔
588
    assert fxa_resp2.from_cache
1✔
589
    assert mock_response.call_count == 1
1✔
590

591

592
@responses.activate
1✔
593
def test_introspect_and_cache_token_mocked_success_with_use_cache_false(
1✔
594
    cache: BaseCache,
595
) -> None:
596
    user_token = "user-123"
1✔
597
    cache_key = get_cache_key(user_token)
1✔
598
    fxa_id = "fxa-id-for-user-123"
1✔
599
    mock_response, fxa_data = setup_fxa_introspect(uid=fxa_id)
1✔
600
    assert fxa_data is not None
1✔
601
    cache.set(cache_key, "An invalid cache value that is not read")
1✔
602

603
    # skip cache, call introspect API, set cache to new data
604
    fxa_resp = introspect_and_cache_token(user_token, read_from_cache=False)
1✔
605
    assert isinstance(fxa_resp, IntrospectionResponse)
1✔
606
    assert fxa_resp.fxa_id == fxa_id
1✔
607
    assert not fxa_resp.from_cache
1✔
608
    assert mock_response.call_count == 1
1✔
609
    assert cache.get(cache_key) == fxa_resp.as_cache_value()
1✔
610

611

612
@responses.activate
1✔
613
def test_introspect_and_cache_token_mocked_error_is_cached(cache: BaseCache) -> None:
1✔
614
    user_token = "user-123"
1✔
615
    cache_key = get_cache_key(user_token)
1✔
616
    mock_response, fxa_data = setup_fxa_introspect(timeout=True)
1✔
617
    assert fxa_data is None
1✔
618
    assert cache.get(cache_key) is None
1✔
619

620
    # Timeout for the first time
621
    expected_error = IntrospectionError(user_token, "Timeout", request_s=0.5)
1✔
622
    result = introspect_and_cache_token(user_token)
1✔
623
    assert result == expected_error
1✔
624
    assert mock_response.call_count == 1
1✔
625
    assert cache.get(cache_key) == expected_error.as_cache_value()
1✔
626

627
    # now check that the 2nd call did NOT make another fxa request
628
    result = introspect_and_cache_token(user_token)
1✔
629
    assert result == IntrospectionError(user_token, "Timeout", from_cache=True)
1✔
630
    assert mock_response.call_count == 1
1✔
631

632

633
def test_fxa_token_authentication_no_auth_header_skips() -> None:
1✔
634
    req = APIRequestFactory().get("/api/endpoint")
1✔
635
    assert FxaTokenAuthentication().authenticate(req) is None
1✔
636

637

638
def test_fxa_token_authentication_not_bearer_token_auth_header_skips() -> None:
1✔
639
    headers = {"Authorization": "unexpected 123"}
1✔
640
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
641
    assert FxaTokenAuthentication().authenticate(req) is None
1✔
642

643

644
def test_fxa_token_authentication_incomplete_bearer_token_raises_auth_fail() -> None:
1✔
645
    headers = {"Authorization": "Bearer "}
1✔
646
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
647
    with pytest.raises(
1✔
648
        AuthenticationFailed, match=r"Invalid token header\. No credentials provided\."
649
    ):
650
        FxaTokenAuthentication().authenticate(req)
1✔
651

652

653
@responses.activate
1✔
654
def test_fxa_token_authentication_known_relay_user_returns_user(
1✔
655
    free_user: User,
656
    fxa_social_app: SocialApp,
657
    cache: BaseCache,
658
) -> None:
659
    fxa_id = "some-fxa-id"
1✔
660
    token = "bearer-token"
1✔
661
    SocialAccount.objects.create(provider="fxa", uid=fxa_id, user=free_user)
1✔
662
    mock_response, fxa_data = setup_fxa_introspect(uid=fxa_id)
1✔
663
    assert fxa_data is not None
1✔
664
    headers = {"Authorization": f"Bearer {token}"}
1✔
665
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
666
    expected_resp = IntrospectionResponse(token, fxa_data, request_s=0.5)
1✔
667

668
    assert FxaTokenAuthentication().authenticate(req) == (free_user, expected_resp)
1✔
669
    assert cache.get(get_cache_key(token)) == expected_resp.as_cache_value()
1✔
670

671

672
@responses.activate
1✔
673
def test_fxa_token_authentication_unknown_token_raises_auth_fail(
1✔
674
    cache: BaseCache,
675
) -> None:
676
    mock_response, fxa_data = setup_fxa_introspect(status_code=401, active=False)
1✔
677
    assert fxa_data is not None
1✔
678
    token = "bearer-token"
1✔
679
    headers = {"Authorization": f"Bearer {token}"}
1✔
680
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
681
    expected_error = IntrospectionError(
1✔
682
        token,
683
        "NotAuthorized",
684
        status_code=401,
685
        error_args=[as_b64(fxa_data)],
686
        request_s=0.5,
687
    )
688

689
    with pytest.raises(
1✔
690
        AuthenticationFailed, match=r"Incorrect authentication credentials\."
691
    ) as exc_info:
692
        FxaTokenAuthentication().authenticate(req)
1✔
693
    assert exc_info.value.args[0] == expected_error
1✔
694
    assert cache.get(get_cache_key(token)) == expected_error.as_cache_value()
1✔
695

696

697
@responses.activate
1✔
698
@pytest.mark.django_db
1✔
699
def test_fxa_token_authentication_not_yet_relay_user_returns_anon_user(
1✔
700
    cache: BaseCache,
701
) -> None:
702
    mock_response, fxa_data = setup_fxa_introspect()
1✔
703
    assert fxa_data is not None
1✔
704
    token = "bearer-token"
1✔
705
    headers = {"Authorization": f"Bearer {token}"}
1✔
706
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
707
    introspect_response = IntrospectionResponse(token, fxa_data, request_s=0.5)
1✔
708
    user_and_auth = FxaTokenAuthentication().authenticate(req)
1✔
709
    assert user_and_auth is not None
1✔
710
    user, auth = user_and_auth
1✔
711
    assert user == AnonymousUser()
1✔
712
    assert not user.is_authenticated
1✔
713
    assert auth == introspect_response
1✔
714

715

716
@responses.activate
1✔
717
def test_fxa_token_authentication_inactive_relay_user(
1✔
718
    free_user: User,
719
    fxa_social_app: SocialApp,
720
    cache: BaseCache,
721
) -> None:
722
    """TODO: Should this be an IsActive or other permission check?"""
723
    free_user.is_active = False
1✔
724
    free_user.save()
1✔
725
    fxa_id = "some-fxa-id"
1✔
726
    token = "bearer-token"
1✔
727
    SocialAccount.objects.create(provider="fxa", uid=fxa_id, user=free_user)
1✔
728
    mock_response, fxa_data = setup_fxa_introspect(uid=fxa_id)
1✔
729
    assert fxa_data is not None
1✔
730
    headers = {"Authorization": f"Bearer {token}"}
1✔
731
    req = APIRequestFactory().get("/api/endpoint", headers=headers)
1✔
732
    expected_resp = IntrospectionResponse(token, fxa_data, request_s=0.5)
1✔
733
    user_and_auth = FxaTokenAuthentication().authenticate(req)
1✔
734
    assert user_and_auth is not None
1✔
735
    user, auth = user_and_auth
1✔
736
    assert user == free_user
1✔
737
    assert user.is_active is False
1✔
738
    assert auth == expected_resp
1✔
739
    assert cache.get(get_cache_key(token)) == expected_resp.as_cache_value()
1✔
740

741

742
@pytest.mark.parametrize(
1✔
743
    "method,path",
744
    [
745
        ("POST", "/api/some_endpoint"),
746
        ("DELETE", "/api/some_endpoint"),
747
        ("PUT", "/api/some_endpoint"),
748
        ("DELETE", "/api/v1/relayaddresses/1234"),
749
        ("PUT", "/api/v1/relayaddresses/1234"),
750
    ],
751
)
752
def test_fxa_token_authentication_skip_cache(
1✔
753
    method: str, path: str, free_user: User, fxa_social_app: SocialApp
754
) -> None:
755
    """FxA introspect is always called (use_cache=False) for some methods."""
756
    fxa_id = "non-cached-id"
1✔
757
    SocialAccount.objects.create(provider="fxa", uid=fxa_id, user=free_user)
1✔
758
    token = "bearer-token"
1✔
759
    headers = {"Authorization": f"Bearer {token}"}
1✔
760
    req = getattr(APIRequestFactory(), method.lower())(path=path, headers=headers)
1✔
761
    auth = FxaTokenAuthentication()
1✔
762
    introspect_response = IntrospectionResponse(
1✔
763
        token, _create_fxa_introspect_response(uid=fxa_id)
764
    )
765

766
    with patch(
1✔
767
        "api.authentication.introspect_and_cache_token",
768
        return_value=introspect_response,
769
    ) as introspect:
770
        user_and_token = auth.authenticate(req)
1✔
771
    introspect.assert_called_once_with(token, False)
1✔
772
    assert user_and_token == (free_user, introspect_response)
1✔
773

774

775
@pytest.mark.parametrize(
1✔
776
    "method,path",
777
    [
778
        ("GET", "/api/some_endpoint"),
779
        ("HEAD", "/api/some_endpoint"),
780
        ("OPTIONS", "/api/some_endpoint"),
781
        ("POST", "/api/v1/relayaddresses/"),
782
    ],
783
)
784
def test_fxa_token_authentication_read_from_cache(
1✔
785
    method: str, path: str, free_user: User, fxa_social_app: SocialApp
786
) -> None:
787
    """Cached FxA introspect results are used (use_cache=True) for some methods."""
788
    fxa_id = "non-cached-id"
1✔
789
    SocialAccount.objects.create(provider="fxa", uid=fxa_id, user=free_user)
1✔
790
    token = "bearer-token"
1✔
791
    headers = {"Authorization": f"Bearer {token}"}
1✔
792
    req = getattr(APIRequestFactory(), method.lower())(path=path, headers=headers)
1✔
793
    auth = FxaTokenAuthentication()
1✔
794
    introspect_response = IntrospectionResponse(
1✔
795
        token, _create_fxa_introspect_response(uid=fxa_id), from_cache=True
796
    )
797

798
    with patch(
1✔
799
        "api.authentication.introspect_and_cache_token",
800
        return_value=introspect_response,
801
    ) as introspect:
802
        user_and_token = auth.authenticate(req)
1✔
803
    introspect.assert_called_once_with(token, True)
1✔
804
    assert user_and_token == (free_user, introspect_response)
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