A request can wait to be served for longer than the access token it was sent with lives, and comes back refused for a token that was valid when it left. Uploads that take minutes make that wait ordinary. - tell an expired token apart from any other refusal by the code the API returns, and retry only that one, once - prefer a token another process left behind over minting a second: it saves a round trip, and a rotating provider would retire one that is still in use. Assign it rather than read it, since the setter is what rewrites the session header the retry will carry - settle the token before the request so the one compared afterwards is the one that was actually sent - report an overloaded server when the second attempt is refused too, and let API errors out of the upload path with their own type
199 lines
7.8 KiB
Python
199 lines
7.8 KiB
Python
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import jwt
|
|
|
|
from mdrsclient.api.utils import retry_on_expired_token, token_check, token_recover
|
|
from mdrsclient.cache import CacheFile
|
|
from mdrsclient.config import normalize_url
|
|
from mdrsclient.connection import MDRSConnection
|
|
from mdrsclient.exceptions import IllegalArgumentException, ServerBusyException, TokenExpiredException
|
|
from mdrsclient.models import Token
|
|
|
|
REMOTE = "unittest"
|
|
|
|
|
|
def make_token(access_offset: int, refresh_offset: int, label: str) -> Token:
|
|
now = int(time.time())
|
|
|
|
def encode(token_type: str, offset: int) -> str:
|
|
return jwt.encode(
|
|
{
|
|
"token_type": token_type,
|
|
"exp": now + offset,
|
|
"iat": now,
|
|
"jti": f"{label}-{token_type}",
|
|
"user_id": 1,
|
|
},
|
|
"unittest-signing-key-not-verified-anywhere",
|
|
)
|
|
|
|
return Token(access=encode("access", access_offset), refresh=encode("refresh", refresh_offset))
|
|
|
|
|
|
class TestTokenCache(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
patcher = patch("mdrsclient.cache.CONFIG_DIRNAME", self.tmp.name)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.addCleanup(self.tmp.cleanup)
|
|
|
|
def test_reload_picks_up_a_write_from_another_holder(self) -> None:
|
|
"""The cache is shared, so a value read earlier can already be out of date."""
|
|
reader = CacheFile(REMOTE)
|
|
writer = CacheFile(REMOTE)
|
|
|
|
first = make_token(3600, 86400, "first")
|
|
writer.token = first
|
|
self.assertEqual(reader.token, first)
|
|
|
|
second = make_token(3600, 86400, "second")
|
|
writer.token = second
|
|
|
|
reader.reload()
|
|
self.assertEqual(reader.token, second)
|
|
|
|
def test_token_check_uses_the_token_another_holder_just_wrote(self) -> None:
|
|
"""A rotating provider drops the token it replaces, so the refresh must not
|
|
be sent again once someone else has already made the round trip."""
|
|
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
|
connection.token = make_token(-60, 86400, "stale")
|
|
|
|
# Another process refreshes while this one is between requests.
|
|
other = CacheFile(REMOTE)
|
|
rotated = make_token(3600, 86400, "rotated")
|
|
other.token = rotated
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
token_check(connection)
|
|
|
|
users_api.assert_not_called()
|
|
self.assertEqual(connection.token, rotated)
|
|
|
|
def test_token_check_refreshes_when_nothing_else_has(self) -> None:
|
|
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
|
connection.token = make_token(-60, 86400, "stale")
|
|
rotated = make_token(3600, 86400, "rotated")
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
users_api.return_value.tokenRefresh.return_value = rotated
|
|
token_check(connection)
|
|
|
|
users_api.return_value.tokenRefresh.assert_called_once()
|
|
self.assertEqual(connection.token, rotated)
|
|
|
|
def test_token_check_logs_out_once_the_refresh_token_expires(self) -> None:
|
|
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
|
connection.token = make_token(-3600, -60, "dead")
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
token_check(connection)
|
|
|
|
users_api.assert_not_called()
|
|
self.assertIsNone(connection.token)
|
|
|
|
|
|
class TestUrlNormalization(unittest.TestCase):
|
|
"""Both clients share config.ini, so they have to agree on what a remote URL is."""
|
|
|
|
def test_trailing_slash_is_dropped(self) -> None:
|
|
self.assertEqual(normalize_url("http://127.0.0.1:8000/api/"), "http://127.0.0.1:8000/api")
|
|
self.assertEqual(normalize_url("https://neurodata.riken.jp/api/"), "https://neurodata.riken.jp/api")
|
|
|
|
def test_bare_hostname_is_accepted(self) -> None:
|
|
self.assertEqual(normalize_url("http://localhost:8000/api"), "http://localhost:8000/api")
|
|
|
|
def test_only_http_schemes_are_accepted(self) -> None:
|
|
for url in ("ftp://x.example.com/", "file:///etc/passwd", "not-a-url", "http://"):
|
|
with self.subTest(url=url), self.assertRaises(IllegalArgumentException):
|
|
normalize_url(url)
|
|
|
|
|
|
class TestExpiredTokenRetry(unittest.TestCase):
|
|
"""A request can wait in the server's queue for longer than its access token lives."""
|
|
|
|
def setUp(self) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
patcher = patch("mdrsclient.cache.CONFIG_DIRNAME", self.tmp.name)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.connection = MDRSConnection(REMOTE, "http://localhost:8000/api")
|
|
self.connection.token = make_token(3600, 86400, "sent")
|
|
|
|
def test_a_token_someone_else_refreshed_is_reused_rather_than_replaced(self) -> None:
|
|
used = self.connection.token
|
|
rotated = make_token(3600, 86400, "rotated")
|
|
CacheFile(REMOTE).token = rotated # another process, while the request waited
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
recovered = token_recover(self.connection, used)
|
|
|
|
self.assertTrue(recovered)
|
|
users_api.assert_not_called()
|
|
self.assertEqual(self.connection.token, rotated)
|
|
# The retry carries whatever the session header says, not what the cache holds,
|
|
# so reading the new token through is not enough on its own.
|
|
self.assertEqual(self.connection.session.headers["Authorization"], f"Bearer {rotated.access}")
|
|
|
|
def test_the_token_is_refreshed_when_nobody_else_has(self) -> None:
|
|
used = self.connection.token
|
|
rotated = make_token(3600, 86400, "rotated")
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
users_api.return_value.tokenRefresh.return_value = rotated
|
|
recovered = token_recover(self.connection, used)
|
|
|
|
self.assertTrue(recovered)
|
|
users_api.return_value.tokenRefresh.assert_called_once()
|
|
self.assertEqual(self.connection.token, rotated)
|
|
|
|
def test_the_request_is_sent_once_more_and_only_once(self) -> None:
|
|
calls: list[str] = []
|
|
|
|
class Api:
|
|
def __init__(self, connection: MDRSConnection) -> None:
|
|
self.connection = connection
|
|
|
|
@retry_on_expired_token
|
|
def send(self) -> str:
|
|
calls.append("sent")
|
|
if len(calls) == 1:
|
|
raise TokenExpiredException("expired on arrival")
|
|
return "ok"
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
users_api.return_value.tokenRefresh.return_value = make_token(3600, 86400, "rotated")
|
|
self.assertEqual(Api(self.connection).send(), "ok")
|
|
|
|
self.assertEqual(len(calls), 2)
|
|
|
|
def test_a_second_refusal_is_reported_as_an_overloaded_server(self) -> None:
|
|
calls: list[str] = []
|
|
|
|
class Api:
|
|
def __init__(self, connection: MDRSConnection) -> None:
|
|
self.connection = connection
|
|
|
|
@retry_on_expired_token
|
|
def send(self) -> str:
|
|
calls.append("sent")
|
|
raise TokenExpiredException("expired on arrival")
|
|
|
|
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
|
users_api.return_value.tokenRefresh.return_value = make_token(3600, 86400, "rotated")
|
|
with self.assertRaises(ServerBusyException) as caught:
|
|
Api(self.connection).send()
|
|
|
|
# Sent twice and no more, and the message names the cause the user can act on
|
|
# rather than an authentication failure that would send them to the login form.
|
|
self.assertEqual(len(calls), 2)
|
|
self.assertIn("overloaded", str(caught.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|