|
| 1 | +import asyncio |
| 2 | +from datetime import datetime, timedelta, timezone |
| 3 | + |
1 | 4 | import pytest |
2 | 5 |
|
3 | 6 | import session_string_generator |
4 | 7 |
|
5 | 8 |
|
6 | | -def test_qr_login_uses_hidden_password_prompt_for_2fa(monkeypatch): |
7 | | - class _Loop: |
8 | | - def run_until_complete(self, awaitable): |
9 | | - awaitable.close() |
10 | | - raise session_string_generator.errors.SessionPasswordNeededError(request=None) |
| 9 | +class _NS: |
| 10 | + def __init__(self, expires): |
| 11 | + self.expires = expires |
11 | 12 |
|
12 | | - class _Client: |
13 | | - def __init__(self): |
14 | | - self.loop = _Loop() |
15 | | - self.sign_in_calls = [] |
16 | 13 |
|
17 | | - def qr_login(self): |
18 | | - class _Qr: |
19 | | - url = "https://example.test/qr" |
| 14 | +class _FakeQR: |
| 15 | + def __init__(self, wait_outcomes): |
| 16 | + # Each entry is either an exception to raise on wait(), or None to succeed. |
| 17 | + self._wait_outcomes = list(wait_outcomes) |
| 18 | + self.url = "tg://login?token=fake" |
| 19 | + self.expires = datetime.now(timezone.utc) + timedelta(seconds=30) |
| 20 | + self.recreate_calls = 0 |
20 | 21 |
|
21 | | - class _Expiry: |
22 | | - def strftime(self, _fmt): |
23 | | - return "00:00:00" |
| 22 | + def wait(self, timeout=None): |
| 23 | + return ("wait", self._wait_outcomes.pop(0)) |
24 | 24 |
|
25 | | - expires = _Expiry() |
| 25 | + def recreate(self): |
| 26 | + self.recreate_calls += 1 |
| 27 | + self.expires = datetime.now(timezone.utc) + timedelta(seconds=30) |
| 28 | + return ("recreate", None) |
26 | 29 |
|
27 | | - async def wait(self, timeout=120): |
28 | | - return None |
29 | 30 |
|
30 | | - return _Qr() |
| 31 | +class _FakeLoop: |
| 32 | + def run_until_complete(self, marker): |
| 33 | + kind, outcome = marker |
| 34 | + if kind == "wait" and outcome is not None: |
| 35 | + raise outcome |
| 36 | + return None |
31 | 37 |
|
32 | | - def sign_in(self, password=None): |
33 | | - self.sign_in_calls.append(password) |
34 | 38 |
|
35 | | - class _FakeQrCode: |
36 | | - def __init__(self, border=1): |
37 | | - self.border = border |
| 39 | +class _FakeClient: |
| 40 | + def __init__(self, qr): |
| 41 | + self._qr = qr |
| 42 | + self.loop = _FakeLoop() |
| 43 | + self.disconnected = False |
| 44 | + self.sign_in_calls = [] |
38 | 45 |
|
39 | | - def add_data(self, _data): |
40 | | - return None |
| 46 | + def qr_login(self): |
| 47 | + return self._qr |
41 | 48 |
|
42 | | - def make(self, fit=True): |
43 | | - return None |
| 49 | + def sign_in(self, password=None): |
| 50 | + self.sign_in_calls.append(password) |
44 | 51 |
|
45 | | - def print_ascii(self, out, invert=True): |
46 | | - out.write("qr") |
| 52 | + def disconnect(self): |
| 53 | + self.disconnected = True |
47 | 54 |
|
48 | | - client = _Client() |
| 55 | + |
| 56 | +def test_seconds_until_expiry_handles_naive_and_aware(): |
| 57 | + naive = datetime(2999, 1, 1) |
| 58 | + aware = datetime.now(timezone.utc) + timedelta(seconds=30) |
| 59 | + |
| 60 | + assert session_string_generator._seconds_until_expiry(_NS(naive)) > 1.0 |
| 61 | + assert session_string_generator._seconds_until_expiry(_NS(aware)) > 1.0 |
| 62 | + |
| 63 | + |
| 64 | +def test_seconds_until_expiry_never_below_one(): |
| 65 | + past = datetime.now(timezone.utc) - timedelta(seconds=10) |
| 66 | + |
| 67 | + assert session_string_generator._seconds_until_expiry(_NS(past)) == 1.0 |
| 68 | + |
| 69 | + |
| 70 | +def test_qr_login_returns_on_first_successful_scan(): |
| 71 | + qr = _FakeQR([None]) |
| 72 | + client = _FakeClient(qr) |
| 73 | + |
| 74 | + session_string_generator._qr_login(client) |
| 75 | + |
| 76 | + assert qr.recreate_calls == 0 |
| 77 | + assert client.disconnected is False |
| 78 | + |
| 79 | + |
| 80 | +def test_qr_login_refreshes_after_expiry_then_succeeds(): |
| 81 | + qr = _FakeQR([asyncio.TimeoutError(), None]) |
| 82 | + client = _FakeClient(qr) |
| 83 | + |
| 84 | + session_string_generator._qr_login(client) |
| 85 | + |
| 86 | + assert qr.recreate_calls == 1 |
| 87 | + assert client.disconnected is False |
| 88 | + |
| 89 | + |
| 90 | +def test_qr_login_gives_up_after_max_refreshes(monkeypatch): |
| 91 | + monkeypatch.setattr(session_string_generator, "_QR_MAX_REFRESHES", 3) |
| 92 | + qr = _FakeQR([asyncio.TimeoutError()] * 3) |
| 93 | + client = _FakeClient(qr) |
| 94 | + |
| 95 | + with pytest.raises(SystemExit): |
| 96 | + session_string_generator._qr_login(client) |
| 97 | + |
| 98 | + assert qr.recreate_calls == 3 |
| 99 | + assert client.disconnected is True |
| 100 | + |
| 101 | + |
| 102 | +def test_qr_login_uses_hidden_password_prompt_for_2fa(monkeypatch): |
| 103 | + qr = _FakeQR([session_string_generator.errors.SessionPasswordNeededError(request=None)]) |
| 104 | + client = _FakeClient(qr) |
49 | 105 | prompted = [] |
50 | 106 |
|
51 | 107 | monkeypatch.setattr("getpass.getpass", lambda prompt: prompted.append(prompt) or "secret") |
52 | | - monkeypatch.setattr("qrcode.QRCode", _FakeQrCode) |
53 | 108 |
|
54 | 109 | session_string_generator._qr_login(client) |
55 | 110 |
|
|
0 commit comments