The login cache is shared by every mdrs process, but the refresh was guarded by a lock that only reaches inside one. Concurrent runs each sent the same refresh token, and a provider that rotates them accepts the first and refuses the rest. - hold a lock that spans processes across the whole read-refresh- write, checking cheaply first so ordinary requests never take it - write the cache through a temporary file: opening it for writing truncates it, and a reader landing in that window found it empty and threw the session away - take the lock for every write, not just the refresh, so a login running beside one cannot be silently reverted - wait for a busy lock on Windows rather than giving up after the ten attempts msvcrt allows, and retry the rename it refuses while a reader still holds the file open - bound the refresh request on its own, so a provider that goes quiet cannot hold the lock indefinitely - accept bare hostnames such as localhost, store URLs without the trailing slash, and join download paths through one helper
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import jwt
|
|
|
|
from mdrsclient.api.utils import token_check
|
|
from mdrsclient.cache import CacheFile
|
|
from mdrsclient.config import normalize_url
|
|
from mdrsclient.connection import MDRSConnection
|
|
from mdrsclient.exceptions import IllegalArgumentException
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|