avios-cli 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {avios_cli-0.1.2 → avios_cli-0.1.4}/CHANGELOG.md +28 -1
  2. {avios_cli-0.1.2 → avios_cli-0.1.4}/PKG-INFO +20 -10
  3. {avios_cli-0.1.2 → avios_cli-0.1.4}/README.md +19 -9
  4. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/auth.py +75 -25
  6. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/cli.py +2 -1
  7. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/endpoints.py +4 -0
  8. avios_cli-0.1.4/tests/test_auth.py +223 -0
  9. avios_cli-0.1.2/tests/test_auth.py +0 -108
  10. {avios_cli-0.1.2 → avios_cli-0.1.4}/.editorconfig +0 -0
  11. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/dependabot.yml +0 -0
  15. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/workflows/ci.yml +0 -0
  16. {avios_cli-0.1.2 → avios_cli-0.1.4}/.github/workflows/publish.yml +0 -0
  17. {avios_cli-0.1.2 → avios_cli-0.1.4}/.gitignore +0 -0
  18. {avios_cli-0.1.2 → avios_cli-0.1.4}/.pre-commit-config.yaml +0 -0
  19. {avios_cli-0.1.2 → avios_cli-0.1.4}/.python-version +0 -0
  20. {avios_cli-0.1.2 → avios_cli-0.1.4}/CONTRIBUTING.md +0 -0
  21. {avios_cli-0.1.2 → avios_cli-0.1.4}/LICENSE +0 -0
  22. {avios_cli-0.1.2 → avios_cli-0.1.4}/SECURITY.md +0 -0
  23. {avios_cli-0.1.2 → avios_cli-0.1.4}/docs/dashboard.svg +0 -0
  24. {avios_cli-0.1.2 → avios_cli-0.1.4}/pyproject.toml +0 -0
  25. {avios_cli-0.1.2 → avios_cli-0.1.4}/scripts/screenshot.py +0 -0
  26. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/client.py +0 -0
  27. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/config.py +0 -0
  28. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/models.py +0 -0
  29. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/session.py +0 -0
  30. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/tui/__init__.py +0 -0
  31. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/tui/app.py +0 -0
  32. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/tui/art.py +0 -0
  33. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/tui/styles.tcss +0 -0
  34. {avios_cli-0.1.2 → avios_cli-0.1.4}/src/avios/tui/widgets.py +0 -0
  35. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/conftest.py +0 -0
  36. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/fixtures/accounts.json +0 -0
  37. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/fixtures/balance.json +0 -0
  38. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/fixtures/transactions.json +0 -0
  39. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/test_cli.py +0 -0
  40. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/test_client.py +0 -0
  41. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/test_models.py +0 -0
  42. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/test_session.py +0 -0
  43. {avios_cli-0.1.2 → avios_cli-0.1.4}/tests/test_tui.py +0 -0
  44. {avios_cli-0.1.2 → avios_cli-0.1.4}/uv.lock +0 -0
@@ -6,6 +6,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.4] - 2026-07-24
10
+
11
+ ### Fixed
12
+ - **Endless hCaptcha loop during `avios login`.** The login browser opened a fresh,
13
+ empty context with automation flags on, which hCaptcha/Akamai flag as a bot and
14
+ answer with infinite challenges. Login now opens a **persistent Chrome profile**
15
+ (cookies/reputation persist between attempts) with the automation fingerprint
16
+ disabled (`--disable-blink-features=AutomationControlled`, no `--enable-automation`).
17
+ If you still hit captchas, use `avios login --from-browser` to import the cookie
18
+ from your normal Chrome instead.
19
+
20
+ ## [0.1.3] - 2026-07-24
21
+
22
+ ### Fixed
23
+ - **Login never actually captured an authenticated session.** The dashboard is a
24
+ client-side SPA, so `wait_for_url("**/manage-avios/**")` matched the initial
25
+ navigation immediately and the browser closed before login — saving anonymous
26
+ cookies, so every call then failed with "Session expired". Login now polls
27
+ `/auth-gateway/user` until it returns 200 (i.e. you're actually logged in) and
28
+ only then captures the cookies.
29
+ - Login launches your **system Chrome** (`channel="chrome"`) when available, so no
30
+ 150 MB Chromium download is needed; falls back to bundled Chromium.
31
+ - Corrected the Chromium install command to `uvx --from playwright playwright
32
+ install chromium`.
33
+
9
34
  ## [0.1.2] - 2026-07-24
10
35
 
11
36
  ### Fixed
@@ -49,7 +74,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
49
74
  - Reward-flight **availability** search is not yet implemented (needs a British
50
75
  Airways capture).
51
76
 
52
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...HEAD
77
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...HEAD
78
+ [0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
79
+ [0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
53
80
  [0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
54
81
  [0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
55
82
  [0.1.0]: https://github.com/alexechoi/avios-cli/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A CLI and TUI for avios.com — view your Avios balance and transactions from the terminal.
5
5
  Project-URL: Homepage, https://github.com/alexechoi/avios-cli
6
6
  Project-URL: Repository, https://github.com/alexechoi/avios-cli
@@ -78,22 +78,32 @@ uv run avios --help
78
78
  ## Log in
79
79
 
80
80
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
81
- SMS/passkey MFA — so `avios login` rides a real browser session (like `gh`/`aws`
82
- login). Login needs the **`login` extra**, so run it with `avios-cli[login]`:
81
+ SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
82
+ logging in** (password, captcha, SMS code), and captures the session once you land
83
+ on the dashboard. It needs the **`login` extra**:
83
84
 
84
85
  ```bash
85
- # open a browser and log in once (first run also downloads Chromium):
86
- uvx --from 'avios-cli[login]' playwright install chromium
87
86
  uvx --from 'avios-cli[login]' avios login
87
+ ```
88
+
89
+ It launches your installed **Google Chrome** (no extra download). If you don't have
90
+ Chrome, install Playwright's browser once with
91
+ `uvx --from playwright playwright install chromium`.
92
+
93
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
94
+ into avios.com with:
88
95
 
89
- # ...or skip the browser download and import the cookie from Chrome you're
90
- # already logged into avios.com with:
96
+ ```bash
91
97
  uvx --from 'avios-cli[login]' avios login --from-browser
92
98
  ```
93
99
 
94
- If you installed with `pip install "avios-cli[login]"`, just run `avios login`
95
- (and `playwright install chromium` once). Plain `uvx avios-cli login` won't work —
96
- the isolated environment doesn't include the `login` extra.
100
+ Plain `uvx avios-cli login` won't work the isolated environment doesn't include
101
+ the `login` extra.
102
+
103
+ **Stuck in an endless captcha loop?** That's bot detection on the automated
104
+ browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
105
+ (you'll get one normal captcha), then run
106
+ `uvx --from 'avios-cli[login]' avios login --from-browser`.
97
107
 
98
108
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
99
109
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
@@ -43,22 +43,32 @@ uv run avios --help
43
43
  ## Log in
44
44
 
45
45
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
46
- SMS/passkey MFA — so `avios login` rides a real browser session (like `gh`/`aws`
47
- login). Login needs the **`login` extra**, so run it with `avios-cli[login]`:
46
+ SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
47
+ logging in** (password, captcha, SMS code), and captures the session once you land
48
+ on the dashboard. It needs the **`login` extra**:
48
49
 
49
50
  ```bash
50
- # open a browser and log in once (first run also downloads Chromium):
51
- uvx --from 'avios-cli[login]' playwright install chromium
52
51
  uvx --from 'avios-cli[login]' avios login
52
+ ```
53
+
54
+ It launches your installed **Google Chrome** (no extra download). If you don't have
55
+ Chrome, install Playwright's browser once with
56
+ `uvx --from playwright playwright install chromium`.
57
+
58
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
59
+ into avios.com with:
53
60
 
54
- # ...or skip the browser download and import the cookie from Chrome you're
55
- # already logged into avios.com with:
61
+ ```bash
56
62
  uvx --from 'avios-cli[login]' avios login --from-browser
57
63
  ```
58
64
 
59
- If you installed with `pip install "avios-cli[login]"`, just run `avios login`
60
- (and `playwright install chromium` once). Plain `uvx avios-cli login` won't work —
61
- the isolated environment doesn't include the `login` extra.
65
+ Plain `uvx avios-cli login` won't work the isolated environment doesn't include
66
+ the `login` extra.
67
+
68
+ **Stuck in an endless captcha loop?** That's bot detection on the automated
69
+ browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
70
+ (you'll get one normal captcha), then run
71
+ `uvx --from 'avios-cli[login]' avios login --from-browser`.
62
72
 
63
73
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
64
74
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
@@ -3,4 +3,4 @@
3
3
  View your Avios balance, transactions and account details from the terminal.
4
4
  """
5
5
 
6
- __version__ = "0.1.2"
6
+ __version__ = "0.1.4"
@@ -20,10 +20,12 @@ from __future__ import annotations
20
20
  from collections.abc import Callable, Iterable
21
21
  from typing import Any, Protocol
22
22
 
23
+ from avios import endpoints
23
24
  from avios.session import Session
24
25
 
25
26
  DASHBOARD_PATH = "/manage-avios/dashboard"
26
27
  LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
28
+ LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
27
29
  SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
28
30
 
29
31
 
@@ -51,46 +53,94 @@ def login_via_browser(
51
53
  *,
52
54
  headless: bool = False,
53
55
  timeout_ms: int = LOGIN_TIMEOUT_MS,
56
+ playwright_factory: Any = None,
54
57
  ) -> int:
55
- """Open a browser, wait for the user to log in, and save the session cookie.
58
+ """Open a browser, wait until the user is actually logged in, save the cookie.
56
59
 
57
- Returns the number of avios.com cookies captured.
60
+ Returns the number of avios.com cookies captured. ``playwright_factory`` is an
61
+ injection point for tests; production uses the real ``sync_playwright``.
58
62
  """
59
63
  session = session or Session()
64
+ factory = playwright_factory or _import_sync_playwright()
65
+ base_url = session.settings.base_url
66
+ profile_dir = str(session.settings.config_dir / "chrome-profile")
67
+
68
+ with factory() as pw:
69
+ ctx = _open_login_context(pw, headless=headless, user_data_dir=profile_dir)
70
+ page = ctx.new_page()
71
+ page.goto(f"{base_url}{DASHBOARD_PATH}")
72
+ authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
73
+ cookies = list(ctx.cookies())
74
+ ctx.close()
75
+
76
+ if not authed:
77
+ raise LoginError("Timed out waiting for login. Run `avios login` again.")
78
+ session.save_cookies(cookies)
79
+ return len(_only_avios(cookies))
80
+
81
+
82
+ def _import_sync_playwright() -> Any:
60
83
  try:
61
84
  from playwright.sync_api import sync_playwright
62
85
  except ImportError as exc:
63
86
  raise LoginError(
64
87
  "Playwright isn't installed. Log in using the 'login' extra:\n"
65
88
  " uvx --from 'avios-cli[login]' avios login\n"
66
- " (first time only: uvx --from 'avios-cli[login]' playwright install chromium)\n"
89
+ " (first time only: uvx --from playwright playwright install chromium)\n"
67
90
  "Or import the cookie from Chrome instead (no browser download):\n"
68
91
  " uvx --from 'avios-cli[login]' avios login --from-browser"
69
92
  ) from exc
93
+ return sync_playwright
70
94
 
71
- base_url = session.settings.base_url
72
- with sync_playwright() as pw: # pragma: no cover - requires a real browser
73
- try:
74
- browser = pw.chromium.launch(headless=headless)
75
- except Exception as exc:
76
- raise LoginError(
77
- "Couldn't launch Chromium. Install the browser once with:\n"
78
- " uvx --from 'avios-cli[login]' playwright install chromium"
79
- ) from exc
80
- ctx = browser.new_context(user_agent=session.settings.user_agent)
81
- page = ctx.new_page()
82
- page.goto(f"{base_url}{DASHBOARD_PATH}")
83
- try:
84
- page.wait_for_url("**/manage-avios/**", timeout=timeout_ms)
85
- page.wait_for_timeout(1500) # let the session cookie settle
86
- except Exception as exc:
87
- browser.close()
88
- raise LoginError("Did not reach the dashboard in time. Please try again.") from exc
89
- cookies = list(ctx.cookies())
90
- browser.close()
91
95
 
92
- session.save_cookies(cookies)
93
- return len(_only_avios(cookies))
96
+ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
97
+ """Poll the auth endpoint until the session is authenticated (or time out).
98
+
99
+ The dashboard is a client-side SPA, so its URL is NOT a reliable signal — it
100
+ matches the moment we navigate, before login. ``/auth-gateway/user`` returns
101
+ 401 until logged in, then 200.
102
+ """
103
+ for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
104
+ if _is_authenticated(ctx, base_url):
105
+ return True
106
+ page.wait_for_timeout(LOGIN_POLL_MS)
107
+ return False
108
+
109
+
110
+ def _is_authenticated(ctx: Any, base_url: str) -> bool:
111
+ try:
112
+ return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
118
+ """Open a browser context for login, tuned to avoid the hCaptcha bot loop.
119
+
120
+ hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
121
+ - prefer the user's real Chrome (``channel="chrome"``) over bundled Chromium;
122
+ - disable the automation fingerprint (``navigator.webdriver`` /
123
+ ``--enable-automation``);
124
+ - use a **persistent profile** so cookies and captcha reputation carry over
125
+ between attempts (a brand-new, empty profile looks high-risk).
126
+ """
127
+ args = ["--disable-blink-features=AutomationControlled"]
128
+ ignore_default_args = ["--enable-automation"]
129
+ for extra in ({"channel": "chrome"}, {}):
130
+ try:
131
+ return pw.chromium.launch_persistent_context(
132
+ user_data_dir,
133
+ headless=headless,
134
+ args=args,
135
+ ignore_default_args=ignore_default_args,
136
+ **extra,
137
+ )
138
+ except Exception:
139
+ continue
140
+ raise LoginError(
141
+ "Couldn't launch a browser. Install Chromium once with:\n"
142
+ " uvx --from playwright playwright install chromium"
143
+ )
94
144
 
95
145
 
96
146
  def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
@@ -110,7 +110,8 @@ def login(
110
110
  else:
111
111
  console.print(
112
112
  "Opening a browser — log in normally (password, captcha, SMS code). "
113
- "I'll capture the session once you reach the dashboard."
113
+ "Keep the window open; it waits until you reach the dashboard, then "
114
+ "captures your session automatically."
114
115
  )
115
116
  count = login_via_browser(session, headless=headless)
116
117
  except LoginError as exc:
@@ -11,6 +11,10 @@ BALANCE = "/en-GB/spend-avios/api/avios-balance"
11
11
  # Balance under the manage-avios API — kept as a fallback.
12
12
  BALANCE_ALT = "/manage-avios/api/user/current/balance"
13
13
 
14
+ # Auth-gateway endpoint: 401 when unauthenticated, 200 once logged in. Used as the
15
+ # "is the browser session authenticated yet?" signal during login.
16
+ AUTH_USER = "/auth-gateway/user"
17
+
14
18
  PROFILE = "/manage-avios/api/user/current"
15
19
  OVERVIEW = "/manage-avios/api/user/current/dashboard-overview"
16
20
  TRANSACTIONS = "/manage-avios/api/user/current/transactions"
@@ -0,0 +1,223 @@
1
+ """Tests for the login helpers.
2
+
3
+ The interactive browser flow can't be automated (Auth0 + hCaptcha + MFA need a
4
+ human), so we inject a fake Playwright to exercise the poll-until-authenticated
5
+ logic, and fake ``browser_cookie3`` via ``sys.modules`` so tests don't depend on
6
+ the optional ``login`` extra or a real browser profile.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import types
13
+
14
+ import pytest
15
+
16
+ from avios.auth import (
17
+ LoginError,
18
+ _default_loader,
19
+ _only_avios,
20
+ _open_login_context,
21
+ _to_cookie_dicts,
22
+ import_from_browser,
23
+ login_via_browser,
24
+ )
25
+ from avios.config import Settings
26
+ from avios.session import Session
27
+
28
+
29
+ class FakeCookie:
30
+ def __init__(self, name: str, value: str, domain: str) -> None:
31
+ self.name = name
32
+ self.value = value
33
+ self.domain = domain
34
+
35
+
36
+ def _session(settings: Settings) -> Session:
37
+ return Session(settings)
38
+
39
+
40
+ def test_to_cookie_dicts() -> None:
41
+ raw = [FakeCookie("appSession", "abc", "www.avios.com")]
42
+ assert _to_cookie_dicts(raw) == [
43
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
44
+ ]
45
+
46
+
47
+ def test_only_avios_filters_foreign_domains() -> None:
48
+ cookies = [
49
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"},
50
+ {"name": "_ga", "value": "junk", "domain": ".google.com"},
51
+ ]
52
+ assert _only_avios(cookies) == [cookies[0]]
53
+
54
+
55
+ def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
56
+ session = _session(settings)
57
+ count = import_from_browser(
58
+ session,
59
+ loader=lambda: [
60
+ FakeCookie("appSession", "abc", "www.avios.com"),
61
+ FakeCookie("_ga", "junk", ".google.com"),
62
+ ],
63
+ )
64
+ assert count == 1
65
+ assert session.is_authenticated()
66
+ assert session.load_cookies() == [
67
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
68
+ ]
69
+
70
+
71
+ def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
72
+ with pytest.raises(LoginError, match="No avios.com cookies"):
73
+ import_from_browser(_session(settings), loader=lambda: [FakeCookie("_ga", "j", ".x.com")])
74
+
75
+
76
+ def _fake_bc3(monkeypatch: pytest.MonkeyPatch, **funcs: object) -> None:
77
+ module = types.ModuleType("browser_cookie3")
78
+ for name, fn in funcs.items():
79
+ setattr(module, name, fn)
80
+ monkeypatch.setitem(sys.modules, "browser_cookie3", module)
81
+
82
+
83
+ def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
84
+ seen: dict[str, str] = {}
85
+
86
+ def chrome(**kwargs: str) -> list[FakeCookie]:
87
+ seen.update(kwargs)
88
+ return [FakeCookie("appSession", "x", "www.avios.com")]
89
+
90
+ _fake_bc3(monkeypatch, chrome=chrome)
91
+ loader = _default_loader("chrome")
92
+ cookies = list(loader())
93
+ assert seen["domain_name"] == "avios.com"
94
+ assert cookies[0].name == "appSession"
95
+
96
+
97
+ def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
98
+ _fake_bc3(monkeypatch, chrome=lambda **k: [])
99
+ with pytest.raises(LoginError, match="Unknown browser"):
100
+ _default_loader("nope")
101
+
102
+
103
+ def test_login_via_browser_without_playwright(
104
+ settings: Settings, monkeypatch: pytest.MonkeyPatch
105
+ ) -> None:
106
+ monkeypatch.setitem(sys.modules, "playwright", None)
107
+ monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
108
+ with pytest.raises(LoginError, match="Playwright isn't installed"):
109
+ login_via_browser(_session(settings))
110
+
111
+
112
+ # --- fake Playwright (persistent context) to exercise the login flow ------------
113
+ class _FakeResponse:
114
+ def __init__(self, ok: bool) -> None:
115
+ self.ok = ok
116
+
117
+
118
+ class _FakeRequest:
119
+ """Auth endpoint returns 401 until `ok_after` calls, then 200."""
120
+
121
+ def __init__(self, ok_after: int) -> None:
122
+ self.ok_after = ok_after
123
+ self.calls = 0
124
+
125
+ def get(self, url: str) -> _FakeResponse:
126
+ self.calls += 1
127
+ return _FakeResponse(self.calls >= self.ok_after)
128
+
129
+
130
+ class _FakePage:
131
+ def goto(self, url: str) -> None:
132
+ pass
133
+
134
+ def wait_for_timeout(self, ms: int) -> None:
135
+ pass
136
+
137
+
138
+ class _FakeContext:
139
+ def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
140
+ self.request = _FakeRequest(ok_after)
141
+ self._cookies = cookies
142
+ self.closed = False
143
+
144
+ def new_page(self) -> _FakePage:
145
+ return _FakePage()
146
+
147
+ def cookies(self) -> list[dict[str, str]]:
148
+ return self._cookies
149
+
150
+ def close(self) -> None:
151
+ self.closed = True
152
+
153
+
154
+ class _FakeChromium:
155
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
156
+ self._ctx = ctx
157
+ self._fail_channel = fail_channel
158
+ self.channels: list[str | None] = []
159
+
160
+ def launch_persistent_context(self, user_data_dir: str, **kwargs: object) -> _FakeContext:
161
+ channel = kwargs.get("channel")
162
+ self.channels.append(channel) # type: ignore[arg-type]
163
+ if channel == self._fail_channel:
164
+ raise RuntimeError("channel unavailable")
165
+ return self._ctx
166
+
167
+
168
+ class _FakePlaywright:
169
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
170
+ self.chromium = _FakeChromium(ctx, fail_channel)
171
+
172
+
173
+ class _FakeFactory:
174
+ """Callable returning a context manager that yields the fake Playwright."""
175
+
176
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
177
+ self._pw = _FakePlaywright(ctx, fail_channel)
178
+
179
+ def __call__(self) -> _FakeFactory:
180
+ return self
181
+
182
+ def __enter__(self) -> _FakePlaywright:
183
+ return self._pw
184
+
185
+ def __exit__(self, *args: object) -> bool:
186
+ return False
187
+
188
+
189
+ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
190
+ cookies = [
191
+ {"name": "appSession", "value": "x", "domain": "www.avios.com"},
192
+ {"name": "_ga", "value": "junk", "domain": ".google.com"},
193
+ ]
194
+ ctx = _FakeContext(ok_after=3, cookies=cookies)
195
+ session = _session(settings)
196
+
197
+ count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(ctx))
198
+
199
+ assert count == 1 # only the avios cookie
200
+ assert session.load_cookies() == cookies
201
+ assert ctx.closed
202
+ assert ctx.request.calls >= 3 # polled until authenticated, not immediately
203
+
204
+
205
+ def test_login_times_out_without_saving(settings: Settings) -> None:
206
+ ctx = _FakeContext(
207
+ ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
208
+ )
209
+ session = _session(settings)
210
+
211
+ with pytest.raises(LoginError, match="Timed out"):
212
+ login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(ctx))
213
+
214
+ assert session.is_authenticated() is False # nothing saved on timeout
215
+ assert ctx.closed
216
+
217
+
218
+ def test_open_login_context_prefers_chrome_then_falls_back(tmp_path: object) -> None:
219
+ ctx = _FakeContext(ok_after=1, cookies=[])
220
+ pw = _FakePlaywright(ctx, fail_channel="chrome")
221
+ result = _open_login_context(pw, headless=True, user_data_dir=str(tmp_path))
222
+ assert result is ctx
223
+ assert pw.chromium.channels == ["chrome", None] # tried Chrome, fell back to Chromium
@@ -1,108 +0,0 @@
1
- """Tests for the login helpers.
2
-
3
- The Playwright browser flow can't be automated (Auth0 + hCaptcha + MFA need a
4
- human), so we test the cookie-conversion/import helpers and the missing-dependency
5
- paths. ``browser_cookie3`` is faked via ``sys.modules`` so tests don't depend on
6
- the optional ``login`` extra or a real browser profile.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import sys
12
- import types
13
-
14
- import pytest
15
-
16
- from avios.auth import (
17
- LoginError,
18
- _default_loader,
19
- _only_avios,
20
- _to_cookie_dicts,
21
- import_from_browser,
22
- login_via_browser,
23
- )
24
- from avios.config import Settings
25
- from avios.session import Session
26
-
27
-
28
- class FakeCookie:
29
- def __init__(self, name: str, value: str, domain: str) -> None:
30
- self.name = name
31
- self.value = value
32
- self.domain = domain
33
-
34
-
35
- def _session(settings: Settings) -> Session:
36
- return Session(settings)
37
-
38
-
39
- def test_to_cookie_dicts() -> None:
40
- raw = [FakeCookie("appSession", "abc", "www.avios.com")]
41
- assert _to_cookie_dicts(raw) == [
42
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
43
- ]
44
-
45
-
46
- def test_only_avios_filters_foreign_domains() -> None:
47
- cookies = [
48
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"},
49
- {"name": "_ga", "value": "junk", "domain": ".google.com"},
50
- ]
51
- assert _only_avios(cookies) == [cookies[0]]
52
-
53
-
54
- def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
55
- session = _session(settings)
56
- count = import_from_browser(
57
- session,
58
- loader=lambda: [
59
- FakeCookie("appSession", "abc", "www.avios.com"),
60
- FakeCookie("_ga", "junk", ".google.com"),
61
- ],
62
- )
63
- assert count == 1
64
- assert session.is_authenticated()
65
- assert session.load_cookies() == [
66
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
67
- ]
68
-
69
-
70
- def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
71
- with pytest.raises(LoginError, match="No avios.com cookies"):
72
- import_from_browser(_session(settings), loader=lambda: [FakeCookie("_ga", "j", ".x.com")])
73
-
74
-
75
- def _fake_bc3(monkeypatch: pytest.MonkeyPatch, **funcs: object) -> None:
76
- module = types.ModuleType("browser_cookie3")
77
- for name, fn in funcs.items():
78
- setattr(module, name, fn)
79
- monkeypatch.setitem(sys.modules, "browser_cookie3", module)
80
-
81
-
82
- def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
83
- seen: dict[str, str] = {}
84
-
85
- def chrome(**kwargs: str) -> list[FakeCookie]:
86
- seen.update(kwargs)
87
- return [FakeCookie("appSession", "x", "www.avios.com")]
88
-
89
- _fake_bc3(monkeypatch, chrome=chrome)
90
- loader = _default_loader("chrome")
91
- cookies = list(loader())
92
- assert seen["domain_name"] == "avios.com"
93
- assert cookies[0].name == "appSession"
94
-
95
-
96
- def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
97
- _fake_bc3(monkeypatch, chrome=lambda **k: [])
98
- with pytest.raises(LoginError, match="Unknown browser"):
99
- _default_loader("nope")
100
-
101
-
102
- def test_login_via_browser_without_playwright(
103
- settings: Settings, monkeypatch: pytest.MonkeyPatch
104
- ) -> None:
105
- monkeypatch.setitem(sys.modules, "playwright", None)
106
- monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
107
- with pytest.raises(LoginError, match="Playwright isn't installed"):
108
- login_via_browser(_session(settings))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes