Files
mdrs-client-python/tests/test_token_cache.py
T
orrisroot 0cac30ccf8 fix(auth): serialise the token refresh across processes
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
2026-08-14 16:36:50 +09:00

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()