avios-cli 0.1.3__tar.gz → 0.1.5__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.3 → avios_cli-0.1.5}/CHANGELOG.md +27 -1
  2. {avios_cli-0.1.3 → avios_cli-0.1.5}/PKG-INFO +14 -1
  3. {avios_cli-0.1.3 → avios_cli-0.1.5}/README.md +13 -0
  4. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/__init__.py +1 -1
  5. avios_cli-0.1.5/src/avios/auth.py +308 -0
  6. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/cli.py +17 -2
  7. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_auth.py +62 -84
  8. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_cli.py +18 -3
  9. avios_cli-0.1.3/src/avios/auth.py +0 -165
  10. {avios_cli-0.1.3 → avios_cli-0.1.5}/.editorconfig +0 -0
  11. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/dependabot.yml +0 -0
  15. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/workflows/ci.yml +0 -0
  16. {avios_cli-0.1.3 → avios_cli-0.1.5}/.github/workflows/publish.yml +0 -0
  17. {avios_cli-0.1.3 → avios_cli-0.1.5}/.gitignore +0 -0
  18. {avios_cli-0.1.3 → avios_cli-0.1.5}/.pre-commit-config.yaml +0 -0
  19. {avios_cli-0.1.3 → avios_cli-0.1.5}/.python-version +0 -0
  20. {avios_cli-0.1.3 → avios_cli-0.1.5}/CONTRIBUTING.md +0 -0
  21. {avios_cli-0.1.3 → avios_cli-0.1.5}/LICENSE +0 -0
  22. {avios_cli-0.1.3 → avios_cli-0.1.5}/SECURITY.md +0 -0
  23. {avios_cli-0.1.3 → avios_cli-0.1.5}/docs/dashboard.svg +0 -0
  24. {avios_cli-0.1.3 → avios_cli-0.1.5}/pyproject.toml +0 -0
  25. {avios_cli-0.1.3 → avios_cli-0.1.5}/scripts/screenshot.py +0 -0
  26. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/client.py +0 -0
  27. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/config.py +0 -0
  28. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/endpoints.py +0 -0
  29. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/models.py +0 -0
  30. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/session.py +0 -0
  31. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/__init__.py +0 -0
  32. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/app.py +0 -0
  33. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/art.py +0 -0
  34. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/styles.tcss +0 -0
  35. {avios_cli-0.1.3 → avios_cli-0.1.5}/src/avios/tui/widgets.py +0 -0
  36. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/conftest.py +0 -0
  37. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/accounts.json +0 -0
  38. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/balance.json +0 -0
  39. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/fixtures/transactions.json +0 -0
  40. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_client.py +0 -0
  41. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_models.py +0 -0
  42. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_session.py +0 -0
  43. {avios_cli-0.1.3 → avios_cli-0.1.5}/tests/test_tui.py +0 -0
  44. {avios_cli-0.1.3 → avios_cli-0.1.5}/uv.lock +0 -0
@@ -6,6 +6,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-07-24
10
+
11
+ ### Fixed
12
+ - **`--from-browser` only read Chrome's `Default` profile.** If you were logged
13
+ into avios.com in another profile (Profile 1, a work profile, ...), it grabbed
14
+ the wrong profile's cookies and every call failed with "Session expired". It now
15
+ **scans all Chrome profiles, verifies which one actually authenticates**
16
+ (against `/auth-gateway/user`), and uses that — and reports which profile it used.
17
+
18
+ ### Added
19
+ - `avios login --from-browser --profile "Profile 1"` to target a specific browser
20
+ profile, and a clear warning when the imported cookies aren't a logged-in session.
21
+
22
+ ## [0.1.4] - 2026-07-24
23
+
24
+ ### Fixed
25
+ - **Endless hCaptcha loop during `avios login`.** The login browser opened a fresh,
26
+ empty context with automation flags on, which hCaptcha/Akamai flag as a bot and
27
+ answer with infinite challenges. Login now opens a **persistent Chrome profile**
28
+ (cookies/reputation persist between attempts) with the automation fingerprint
29
+ disabled (`--disable-blink-features=AutomationControlled`, no `--enable-automation`).
30
+ If you still hit captchas, use `avios login --from-browser` to import the cookie
31
+ from your normal Chrome instead.
32
+
9
33
  ## [0.1.3] - 2026-07-24
10
34
 
11
35
  ### Fixed
@@ -63,7 +87,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
63
87
  - Reward-flight **availability** search is not yet implemented (needs a British
64
88
  Airways capture).
65
89
 
66
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...HEAD
90
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...HEAD
91
+ [0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
92
+ [0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
67
93
  [0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
68
94
  [0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
69
95
  [0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -97,9 +97,22 @@ into avios.com with:
97
97
  uvx --from 'avios-cli[login]' avios login --from-browser
98
98
  ```
99
99
 
100
+ This scans **all your Chrome profiles** and uses whichever one is actually logged
101
+ into avios.com (it tells you which). If you have several profiles, you can target
102
+ one directly:
103
+
104
+ ```bash
105
+ uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
106
+ ```
107
+
100
108
  Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
101
109
  the `login` extra.
102
110
 
111
+ **Stuck in an endless captcha loop?** That's bot detection on the automated
112
+ browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
113
+ (you'll get one normal captcha), then run
114
+ `uvx --from 'avios-cli[login]' avios login --from-browser`.
115
+
103
116
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
104
117
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
105
118
 
@@ -62,9 +62,22 @@ into avios.com with:
62
62
  uvx --from 'avios-cli[login]' avios login --from-browser
63
63
  ```
64
64
 
65
+ This scans **all your Chrome profiles** and uses whichever one is actually logged
66
+ into avios.com (it tells you which). If you have several profiles, you can target
67
+ one directly:
68
+
69
+ ```bash
70
+ uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
71
+ ```
72
+
65
73
  Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
66
74
  the `login` extra.
67
75
 
76
+ **Stuck in an endless captcha loop?** That's bot detection on the automated
77
+ browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
78
+ (you'll get one normal captcha), then run
79
+ `uvx --from 'avios-cli[login]' avios login --from-browser`.
80
+
68
81
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
69
82
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
70
83
 
@@ -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.3"
6
+ __version__ = "0.1.5"
@@ -0,0 +1,308 @@
1
+ """Browser-assisted login for avios.
2
+
3
+ avios.com has no plain credential API: login is Auth0 "Universal Login" guarded by
4
+ hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
5
+ username/password over HTTP and obtain a session. Instead we let a real browser
6
+ handle the login once and capture the resulting session cookie.
7
+
8
+ Two strategies, both requiring the optional ``login`` extra
9
+ (``pip install "avios-cli[login]"``):
10
+
11
+ - :func:`login_via_browser` — open a Playwright browser, the user logs in
12
+ (password + captcha + MFA), and we grab the cookies. Also needs
13
+ ``playwright install chromium``.
14
+ - :func:`import_from_browser` — read the avios.com cookie straight out of a
15
+ running browser via ``browser_cookie3`` (no popup) if already logged in there.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ from collections.abc import Callable, Iterable
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Protocol
25
+
26
+ import httpx
27
+
28
+ from avios import endpoints
29
+ from avios.config import Settings
30
+ from avios.session import Session, cookie_header_from
31
+
32
+ DASHBOARD_PATH = "/manage-avios/dashboard"
33
+ LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
34
+ LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
35
+ SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
36
+
37
+ # Chromium-family user-data roots per browser (relative to home), used to discover
38
+ # per-profile cookie databases (Default, "Profile 1", ...).
39
+ _CHROMIUM_ROOTS = {
40
+ "chrome": [
41
+ "Library/Application Support/Google/Chrome",
42
+ ".config/google-chrome",
43
+ "AppData/Local/Google/Chrome/User Data",
44
+ ],
45
+ "brave": [
46
+ "Library/Application Support/BraveSoftware/Brave-Browser",
47
+ ".config/BraveSoftware/Brave-Browser",
48
+ "AppData/Local/BraveSoftware/Brave-Browser/User Data",
49
+ ],
50
+ "edge": [
51
+ "Library/Application Support/Microsoft Edge",
52
+ ".config/microsoft-edge",
53
+ "AppData/Local/Microsoft/Edge/User Data",
54
+ ],
55
+ "chromium": [
56
+ "Library/Application Support/Chromium",
57
+ ".config/chromium",
58
+ "AppData/Local/Chromium/User Data",
59
+ ],
60
+ }
61
+
62
+
63
+ class LoginError(RuntimeError):
64
+ """Login could not be completed."""
65
+
66
+
67
+ @dataclass
68
+ class ImportResult:
69
+ """Outcome of importing cookies from a browser."""
70
+
71
+ count: int
72
+ profile: str | None
73
+ authenticated: bool
74
+
75
+
76
+ class _RawCookie(Protocol):
77
+ name: str
78
+ value: str
79
+ domain: str
80
+
81
+
82
+ def _to_cookie_dicts(raw: Iterable[_RawCookie]) -> list[dict[str, Any]]:
83
+ """Convert browser_cookie3 / cookiejar entries to our stored cookie format."""
84
+ return [{"name": c.name, "value": c.value, "domain": c.domain} for c in raw]
85
+
86
+
87
+ def _only_avios(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
88
+ return [c for c in cookies if "avios.com" in c.get("domain", "")]
89
+
90
+
91
+ def login_via_browser(
92
+ session: Session | None = None,
93
+ *,
94
+ headless: bool = False,
95
+ timeout_ms: int = LOGIN_TIMEOUT_MS,
96
+ playwright_factory: Any = None,
97
+ ) -> int:
98
+ """Open a browser, wait until the user is actually logged in, save the cookie.
99
+
100
+ Returns the number of avios.com cookies captured. ``playwright_factory`` is an
101
+ injection point for tests; production uses the real ``sync_playwright``.
102
+ """
103
+ session = session or Session()
104
+ factory = playwright_factory or _import_sync_playwright()
105
+ base_url = session.settings.base_url
106
+ profile_dir = str(session.settings.config_dir / "chrome-profile")
107
+
108
+ with factory() as pw:
109
+ ctx = _open_login_context(pw, headless=headless, user_data_dir=profile_dir)
110
+ page = ctx.new_page()
111
+ page.goto(f"{base_url}{DASHBOARD_PATH}")
112
+ authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
113
+ cookies = list(ctx.cookies())
114
+ ctx.close()
115
+
116
+ if not authed:
117
+ raise LoginError("Timed out waiting for login. Run `avios login` again.")
118
+ session.save_cookies(cookies)
119
+ return len(_only_avios(cookies))
120
+
121
+
122
+ def _import_sync_playwright() -> Any:
123
+ try:
124
+ from playwright.sync_api import sync_playwright
125
+ except ImportError as exc:
126
+ raise LoginError(
127
+ "Playwright isn't installed. Log in using the 'login' extra:\n"
128
+ " uvx --from 'avios-cli[login]' avios login\n"
129
+ " (first time only: uvx --from playwright playwright install chromium)\n"
130
+ "Or import the cookie from Chrome instead (no browser download):\n"
131
+ " uvx --from 'avios-cli[login]' avios login --from-browser"
132
+ ) from exc
133
+ return sync_playwright
134
+
135
+
136
+ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
137
+ """Poll the auth endpoint until the session is authenticated (or time out).
138
+
139
+ The dashboard is a client-side SPA, so its URL is NOT a reliable signal — it
140
+ matches the moment we navigate, before login. ``/auth-gateway/user`` returns
141
+ 401 until logged in, then 200.
142
+ """
143
+ for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
144
+ if _is_authenticated(ctx, base_url):
145
+ return True
146
+ page.wait_for_timeout(LOGIN_POLL_MS)
147
+ return False
148
+
149
+
150
+ def _is_authenticated(ctx: Any, base_url: str) -> bool:
151
+ try:
152
+ return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
153
+ except Exception:
154
+ return False
155
+
156
+
157
+ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
158
+ """Open a browser context for login, tuned to avoid the hCaptcha bot loop.
159
+
160
+ hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
161
+ - prefer the user's real Chrome (``channel="chrome"``) over bundled Chromium;
162
+ - disable the automation fingerprint (``navigator.webdriver`` /
163
+ ``--enable-automation``);
164
+ - use a **persistent profile** so cookies and captcha reputation carry over
165
+ between attempts (a brand-new, empty profile looks high-risk).
166
+ """
167
+ args = ["--disable-blink-features=AutomationControlled"]
168
+ ignore_default_args = ["--enable-automation"]
169
+ for extra in ({"channel": "chrome"}, {}):
170
+ try:
171
+ return pw.chromium.launch_persistent_context(
172
+ user_data_dir,
173
+ headless=headless,
174
+ args=args,
175
+ ignore_default_args=ignore_default_args,
176
+ **extra,
177
+ )
178
+ except Exception:
179
+ continue
180
+ raise LoginError(
181
+ "Couldn't launch a browser. Install Chromium once with:\n"
182
+ " uvx --from playwright playwright install chromium"
183
+ )
184
+
185
+
186
+ def _bc3() -> Any:
187
+ try:
188
+ import browser_cookie3 as bc3
189
+ except ImportError as exc:
190
+ raise LoginError(
191
+ "browser-cookie3 isn't installed. Run with the 'login' extra:\n"
192
+ " uvx --from 'avios-cli[login]' avios login --from-browser"
193
+ ) from exc
194
+ return bc3
195
+
196
+
197
+ def _browser_fn(bc3: Any, browser: str) -> Any:
198
+ fn = getattr(bc3, browser, None)
199
+ if fn is None:
200
+ raise LoginError(
201
+ f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
202
+ )
203
+ return fn
204
+
205
+
206
+ def _profile_cookie_files(browser: str, profile: str | None) -> list[tuple[str, Path]]:
207
+ """Discover (profile_name, Cookies-db) pairs for a chromium-family browser."""
208
+ found: list[tuple[str, Path]] = []
209
+ for rel in _CHROMIUM_ROOTS.get(browser, []):
210
+ root = Path.home() / rel
211
+ if not root.exists():
212
+ continue
213
+ for prof in sorted(p for p in root.iterdir() if p.is_dir()):
214
+ if profile and prof.name != profile:
215
+ continue
216
+ for db in (prof / "Cookies", prof / "Network" / "Cookies"):
217
+ if db.exists():
218
+ found.append((prof.name, db))
219
+ break
220
+ return found
221
+
222
+
223
+ def _candidate_cookie_sets(
224
+ browser: str, profile: str | None
225
+ ) -> list[tuple[str, list[dict[str, Any]]]]:
226
+ """Return [(profile_name, avios_cookies)] to try, most specific first."""
227
+ bc3 = _bc3()
228
+ fn = _browser_fn(bc3, browser)
229
+ candidates: list[tuple[str, list[dict[str, Any]]]] = []
230
+ for name, db in _profile_cookie_files(browser, profile):
231
+ try:
232
+ raw = fn(domain_name="avios.com", cookie_file=str(db))
233
+ except Exception:
234
+ continue
235
+ candidates.append((name, _only_avios(_to_cookie_dicts(raw))))
236
+ if not profile:
237
+ # Fallback to browser_cookie3's own default lookup.
238
+ with contextlib.suppress(Exception):
239
+ raw = fn(domain_name="avios.com")
240
+ candidates.append(("default", _only_avios(_to_cookie_dicts(raw))))
241
+ return candidates
242
+
243
+
244
+ def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) -> bool:
245
+ """True if these cookies yield a 200 from the auth endpoint."""
246
+ header = cookie_header_from(cookies)
247
+ if not header:
248
+ return False
249
+ try:
250
+ with httpx.Client(
251
+ base_url=settings.base_url,
252
+ timeout=15.0,
253
+ follow_redirects=False,
254
+ headers={
255
+ "accept": "application/json",
256
+ "user-agent": settings.user_agent,
257
+ "cookie": header,
258
+ },
259
+ ) as client:
260
+ return client.get(endpoints.AUTH_USER).status_code == 200
261
+ except httpx.HTTPError:
262
+ return False
263
+
264
+
265
+ def import_from_browser(
266
+ session: Session | None = None,
267
+ browser: str = "chrome",
268
+ *,
269
+ profile: str | None = None,
270
+ loader: Callable[[], Iterable[_RawCookie]] | None = None,
271
+ authenticator: Callable[[list[dict[str, Any]]], bool] | None = None,
272
+ ) -> ImportResult:
273
+ """Import the avios.com session cookie from a browser profile.
274
+
275
+ Scans the browser's profiles, prefers the one whose cookies actually
276
+ authenticate, and saves them. ``loader``/``authenticator`` are test seams.
277
+ """
278
+ session = session or Session()
279
+ authenticate = authenticator or (lambda c: _cookies_authenticate(session.settings, c))
280
+
281
+ if loader is not None:
282
+ candidates: list[tuple[str, list[dict[str, Any]]]] = [
283
+ ("browser", _only_avios(_to_cookie_dicts(loader())))
284
+ ]
285
+ else:
286
+ candidates = _candidate_cookie_sets(browser, profile)
287
+
288
+ working: tuple[str, list[dict[str, Any]]] | None = None
289
+ fallback: tuple[str, list[dict[str, Any]]] | None = None
290
+ for name, cookies in candidates:
291
+ if not cookies:
292
+ continue
293
+ if fallback is None:
294
+ fallback = (name, cookies)
295
+ if authenticate(cookies):
296
+ working = (name, cookies)
297
+ break
298
+
299
+ chosen = working or fallback
300
+ if chosen is None:
301
+ where = f"profile '{profile}'" if profile else browser
302
+ raise LoginError(
303
+ f"No avios.com cookies found in {where}. Log into avios.com in that "
304
+ "browser first (and check the profile with --profile)."
305
+ )
306
+ name, cookies = chosen
307
+ session.save_cookies(cookies)
308
+ return ImportResult(count=len(cookies), profile=name, authenticated=working is not None)
@@ -98,6 +98,9 @@ def login(
98
98
  False, "--from-browser", help="Import the cookie from a running browser (no popup)."
99
99
  ),
100
100
  browser: str = typer.Option("chrome", help="Browser to import from (with --from-browser)."),
101
+ profile: str | None = typer.Option(
102
+ None, help="Browser profile name for --from-browser (e.g. 'Default', 'Profile 1')."
103
+ ),
101
104
  headless: bool = typer.Option(
102
105
  False, help="Run the login browser headless (only works without captcha/MFA)."
103
106
  ),
@@ -106,7 +109,19 @@ def login(
106
109
  session = Session()
107
110
  try:
108
111
  if from_browser:
109
- count = import_from_browser(session, browser)
112
+ result = import_from_browser(session, browser, profile=profile)
113
+ where = f" from {browser} profile '{result.profile}'" if result.profile else ""
114
+ if not result.authenticated:
115
+ console.print(
116
+ f"[yellow]Imported {result.count} cookie(s){where}, but they're not a "
117
+ "logged-in avios session.[/]"
118
+ )
119
+ console.print(
120
+ "Log into avios.com in that profile, pick another with "
121
+ "[bold]--profile[/], or use [bold]avios login[/] (browser)."
122
+ )
123
+ raise typer.Exit(2)
124
+ console.print(f"[green]Logged in{where}.[/] Saved {result.count} cookie(s).")
110
125
  else:
111
126
  console.print(
112
127
  "Opening a browser — log in normally (password, captcha, SMS code). "
@@ -114,11 +129,11 @@ def login(
114
129
  "captures your session automatically."
115
130
  )
116
131
  count = login_via_browser(session, headless=headless)
132
+ console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
117
133
  except LoginError as exc:
118
134
  console.print(f"[red]{escape(str(exc))}[/]")
119
135
  raise typer.Exit(1) from exc
120
136
 
121
- console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
122
137
  try:
123
138
  balance = AviosClient(session).get_balance()
124
139
  console.print(
@@ -1,8 +1,8 @@
1
1
  """Tests for the login helpers.
2
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
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
6
  the optional ``login`` extra or a real browser profile.
7
7
  """
8
8
 
@@ -14,9 +14,11 @@ import types
14
14
  import pytest
15
15
 
16
16
  from avios.auth import (
17
+ ImportResult,
17
18
  LoginError,
18
- _default_loader,
19
+ _browser_fn,
19
20
  _only_avios,
21
+ _open_login_context,
20
22
  _to_cookie_dicts,
21
23
  import_from_browser,
22
24
  login_via_browser,
@@ -51,52 +53,49 @@ def test_only_avios_filters_foreign_domains() -> None:
51
53
  assert _only_avios(cookies) == [cookies[0]]
52
54
 
53
55
 
54
- def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
56
+ def test_import_from_browser_uses_authenticated_cookies(settings: Settings) -> None:
55
57
  session = _session(settings)
56
- count = import_from_browser(
58
+ result = import_from_browser(
57
59
  session,
58
60
  loader=lambda: [
59
61
  FakeCookie("appSession", "abc", "www.avios.com"),
60
62
  FakeCookie("_ga", "junk", ".google.com"),
61
63
  ],
64
+ authenticator=lambda cookies: True,
62
65
  )
63
- assert count == 1
64
- assert session.is_authenticated()
66
+ assert isinstance(result, ImportResult)
67
+ assert result.count == 1 # only the avios cookie
68
+ assert result.authenticated is True
65
69
  assert session.load_cookies() == [
66
70
  {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
67
71
  ]
68
72
 
69
73
 
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] = {}
74
+ def test_import_from_browser_reports_unauthenticated(settings: Settings) -> None:
75
+ session = _session(settings)
76
+ result = import_from_browser(
77
+ session,
78
+ loader=lambda: [FakeCookie("appSession", "stale", "www.avios.com")],
79
+ authenticator=lambda cookies: False, # cookies exist but don't authenticate
80
+ )
81
+ assert result.count == 1
82
+ assert result.authenticated is False # saved as best-effort, flagged as not working
84
83
 
85
- def chrome(**kwargs: str) -> list[FakeCookie]:
86
- seen.update(kwargs)
87
- return [FakeCookie("appSession", "x", "www.avios.com")]
88
84
 
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"
85
+ def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
86
+ with pytest.raises(LoginError, match="No avios.com cookies"):
87
+ import_from_browser(
88
+ _session(settings),
89
+ loader=lambda: [FakeCookie("_ga", "j", ".x.com")],
90
+ authenticator=lambda cookies: True,
91
+ )
94
92
 
95
93
 
96
- def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
97
- _fake_bc3(monkeypatch, chrome=lambda **k: [])
94
+ def test_browser_fn_unknown_browser() -> None:
95
+ bc3 = types.ModuleType("browser_cookie3")
96
+ bc3.chrome = lambda **k: [] # type: ignore[attr-defined]
98
97
  with pytest.raises(LoginError, match="Unknown browser"):
99
- _default_loader("nope")
98
+ _browser_fn(bc3, "nope")
100
99
 
101
100
 
102
101
  def test_login_via_browser_without_playwright(
@@ -108,7 +107,7 @@ def test_login_via_browser_without_playwright(
108
107
  login_via_browser(_session(settings))
109
108
 
110
109
 
111
- # --- fake Playwright, to exercise the poll-until-authenticated flow --------------
110
+ # --- fake Playwright (persistent context) to exercise the login flow ------------
112
111
  class _FakeResponse:
113
112
  def __init__(self, ok: bool) -> None:
114
113
  self.ok = ok
@@ -138,6 +137,7 @@ class _FakeContext:
138
137
  def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
139
138
  self.request = _FakeRequest(ok_after)
140
139
  self._cookies = cookies
140
+ self.closed = False
141
141
 
142
142
  def new_page(self) -> _FakePage:
143
143
  return _FakePage()
@@ -145,39 +145,34 @@ class _FakeContext:
145
145
  def cookies(self) -> list[dict[str, str]]:
146
146
  return self._cookies
147
147
 
148
-
149
- class _FakeBrowser:
150
- def __init__(self, ctx: _FakeContext) -> None:
151
- self._ctx = ctx
152
- self.closed = False
153
-
154
- def new_context(self, **kwargs: object) -> _FakeContext:
155
- return self._ctx
156
-
157
148
  def close(self) -> None:
158
149
  self.closed = True
159
150
 
160
151
 
161
- class _FakePlaywright:
162
- def __init__(self, browser: _FakeBrowser) -> None:
163
- self.chromium = _FakeChromium(browser)
164
-
165
-
166
152
  class _FakeChromium:
167
- def __init__(self, browser: _FakeBrowser) -> None:
168
- self._browser = browser
169
- self.launches: list[str | None] = []
153
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
154
+ self._ctx = ctx
155
+ self._fail_channel = fail_channel
156
+ self.channels: list[str | None] = []
157
+
158
+ def launch_persistent_context(self, user_data_dir: str, **kwargs: object) -> _FakeContext:
159
+ channel = kwargs.get("channel")
160
+ self.channels.append(channel) # type: ignore[arg-type]
161
+ if channel == self._fail_channel:
162
+ raise RuntimeError("channel unavailable")
163
+ return self._ctx
164
+
170
165
 
171
- def launch(self, **kwargs: object) -> _FakeBrowser:
172
- self.launches.append(kwargs.get("channel")) # type: ignore[arg-type]
173
- return self._browser
166
+ class _FakePlaywright:
167
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
168
+ self.chromium = _FakeChromium(ctx, fail_channel)
174
169
 
175
170
 
176
171
  class _FakeFactory:
177
- """Callable that returns a context manager yielding a fake Playwright."""
172
+ """Callable returning a context manager that yields the fake Playwright."""
178
173
 
179
- def __init__(self, browser: _FakeBrowser) -> None:
180
- self._pw = _FakePlaywright(browser)
174
+ def __init__(self, ctx: _FakeContext, fail_channel: str | None = None) -> None:
175
+ self._pw = _FakePlaywright(ctx, fail_channel)
181
176
 
182
177
  def __call__(self) -> _FakeFactory:
183
178
  return self
@@ -195,14 +190,13 @@ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
195
190
  {"name": "_ga", "value": "junk", "domain": ".google.com"},
196
191
  ]
197
192
  ctx = _FakeContext(ok_after=3, cookies=cookies)
198
- browser = _FakeBrowser(ctx)
199
193
  session = _session(settings)
200
194
 
201
- count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(browser))
195
+ count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(ctx))
202
196
 
203
197
  assert count == 1 # only the avios cookie
204
198
  assert session.load_cookies() == cookies
205
- assert browser.closed
199
+ assert ctx.closed
206
200
  assert ctx.request.calls >= 3 # polled until authenticated, not immediately
207
201
 
208
202
 
@@ -210,34 +204,18 @@ def test_login_times_out_without_saving(settings: Settings) -> None:
210
204
  ctx = _FakeContext(
211
205
  ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
212
206
  )
213
- browser = _FakeBrowser(ctx)
214
207
  session = _session(settings)
215
208
 
216
209
  with pytest.raises(LoginError, match="Timed out"):
217
- login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(browser))
210
+ login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(ctx))
218
211
 
219
212
  assert session.is_authenticated() is False # nothing saved on timeout
220
- assert browser.closed
221
-
222
-
223
- def test_launch_browser_prefers_chrome_then_falls_back() -> None:
224
- from avios.auth import _launch_browser
225
-
226
- class Chromium:
227
- def __init__(self) -> None:
228
- self.channels: list[str | None] = []
229
-
230
- def launch(self, **kwargs: object) -> str:
231
- channel = kwargs.get("channel")
232
- self.channels.append(channel) # type: ignore[arg-type]
233
- if channel == "chrome":
234
- raise RuntimeError("no system chrome")
235
- return "browser"
213
+ assert ctx.closed
236
214
 
237
- class PW:
238
- def __init__(self) -> None:
239
- self.chromium = Chromium()
240
215
 
241
- pw = PW()
242
- assert _launch_browser(pw, headless=True) == "browser"
243
- assert pw.chromium.channels == ["chrome", None]
216
+ def test_open_login_context_prefers_chrome_then_falls_back(tmp_path: object) -> None:
217
+ ctx = _FakeContext(ok_after=1, cookies=[])
218
+ pw = _FakePlaywright(ctx, fail_channel="chrome")
219
+ result = _open_login_context(pw, headless=True, user_data_dir=str(tmp_path))
220
+ assert result is ctx
221
+ assert pw.chromium.channels == ["chrome", None] # tried Chrome, fell back to Chromium
@@ -13,7 +13,7 @@ import pytest
13
13
  from typer.testing import CliRunner
14
14
 
15
15
  from avios import __version__
16
- from avios.auth import LoginError
16
+ from avios.auth import ImportResult, LoginError
17
17
  from avios.cli import app
18
18
  from avios.models import Account, Balance, Overview, Profile, Transaction
19
19
  from avios.session import NotAuthenticated, SessionExpired
@@ -134,16 +134,31 @@ def test_session_expired(monkeypatch: pytest.MonkeyPatch) -> None:
134
134
 
135
135
  # -- login / logout ----------------------------------------------------------
136
136
  def test_login_from_browser(monkeypatch: pytest.MonkeyPatch) -> None:
137
- monkeypatch.setattr("avios.cli.import_from_browser", lambda session, browser: 2)
137
+ monkeypatch.setattr(
138
+ "avios.cli.import_from_browser",
139
+ lambda session, browser, profile=None: ImportResult(2, "Default", True),
140
+ )
138
141
  monkeypatch.setattr("avios.cli.AviosClient", FakeClient)
139
142
  result = runner.invoke(app, ["login", "--from-browser"])
140
143
  assert result.exit_code == 0
141
144
  assert "Logged in" in result.stdout
145
+ assert "Default" in result.stdout
142
146
  assert "Balance" in result.stdout
143
147
 
144
148
 
149
+ def test_login_from_browser_unauthenticated(monkeypatch: pytest.MonkeyPatch) -> None:
150
+ monkeypatch.setattr(
151
+ "avios.cli.import_from_browser",
152
+ lambda session, browser, profile=None: ImportResult(20, "Default", False),
153
+ )
154
+ result = runner.invoke(app, ["login", "--from-browser"])
155
+ assert result.exit_code == 2
156
+ normalized = " ".join(result.stdout.split()) # Rich wraps lines
157
+ assert "not a logged-in avios session" in normalized
158
+
159
+
145
160
  def test_login_error(monkeypatch: pytest.MonkeyPatch) -> None:
146
- def boom(session: Any, browser: str) -> int:
161
+ def boom(session: Any, browser: str, profile: str | None = None) -> ImportResult:
147
162
  raise LoginError("nope")
148
163
 
149
164
  monkeypatch.setattr("avios.cli.import_from_browser", boom)
@@ -1,165 +0,0 @@
1
- """Browser-assisted login for avios.
2
-
3
- avios.com has no plain credential API: login is Auth0 "Universal Login" guarded by
4
- hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
5
- username/password over HTTP and obtain a session. Instead we let a real browser
6
- handle the login once and capture the resulting session cookie.
7
-
8
- Two strategies, both requiring the optional ``login`` extra
9
- (``pip install "avios-cli[login]"``):
10
-
11
- - :func:`login_via_browser` — open a Playwright browser, the user logs in
12
- (password + captcha + MFA), and we grab the cookies. Also needs
13
- ``playwright install chromium``.
14
- - :func:`import_from_browser` — read the avios.com cookie straight out of a
15
- running browser via ``browser_cookie3`` (no popup) if already logged in there.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- from collections.abc import Callable, Iterable
21
- from typing import Any, Protocol
22
-
23
- from avios import endpoints
24
- from avios.session import Session
25
-
26
- DASHBOARD_PATH = "/manage-avios/dashboard"
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
29
- SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
30
-
31
-
32
- class LoginError(RuntimeError):
33
- """Login could not be completed."""
34
-
35
-
36
- class _RawCookie(Protocol):
37
- name: str
38
- value: str
39
- domain: str
40
-
41
-
42
- def _to_cookie_dicts(raw: Iterable[_RawCookie]) -> list[dict[str, Any]]:
43
- """Convert browser_cookie3 / cookiejar entries to our stored cookie format."""
44
- return [{"name": c.name, "value": c.value, "domain": c.domain} for c in raw]
45
-
46
-
47
- def _only_avios(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
48
- return [c for c in cookies if "avios.com" in c.get("domain", "")]
49
-
50
-
51
- def login_via_browser(
52
- session: Session | None = None,
53
- *,
54
- headless: bool = False,
55
- timeout_ms: int = LOGIN_TIMEOUT_MS,
56
- playwright_factory: Any = None,
57
- ) -> int:
58
- """Open a browser, wait until the user is actually logged in, save the cookie.
59
-
60
- Returns the number of avios.com cookies captured. ``playwright_factory`` is an
61
- injection point for tests; production uses the real ``sync_playwright``.
62
- """
63
- session = session or Session()
64
- factory = playwright_factory or _import_sync_playwright()
65
- base_url = session.settings.base_url
66
-
67
- with factory() as pw:
68
- browser = _launch_browser(pw, headless=headless)
69
- ctx = browser.new_context(user_agent=session.settings.user_agent)
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
- browser.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:
83
- try:
84
- from playwright.sync_api import sync_playwright
85
- except ImportError as exc:
86
- raise LoginError(
87
- "Playwright isn't installed. Log in using the 'login' extra:\n"
88
- " uvx --from 'avios-cli[login]' avios login\n"
89
- " (first time only: uvx --from playwright playwright install chromium)\n"
90
- "Or import the cookie from Chrome instead (no browser download):\n"
91
- " uvx --from 'avios-cli[login]' avios login --from-browser"
92
- ) from exc
93
- return sync_playwright
94
-
95
-
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 _launch_browser(pw: Any, *, headless: bool) -> Any:
118
- """Launch the user's system Chrome if available, else bundled Chromium."""
119
- for kwargs in ({"channel": "chrome"}, {}):
120
- try:
121
- return pw.chromium.launch(headless=headless, **kwargs)
122
- except Exception:
123
- continue
124
- raise LoginError(
125
- "Couldn't launch a browser. Install Chromium once with:\n"
126
- " uvx --from playwright playwright install chromium"
127
- )
128
-
129
-
130
- def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
131
- """Return a callable that reads avios.com cookies from ``browser``."""
132
- try:
133
- import browser_cookie3 as bc3
134
- except ImportError as exc:
135
- raise LoginError(
136
- "browser-cookie3 isn't installed. Run with the 'login' extra:\n"
137
- " uvx --from 'avios-cli[login]' avios login --from-browser"
138
- ) from exc
139
- fn = getattr(bc3, browser, None)
140
- if fn is None:
141
- raise LoginError(
142
- f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
143
- )
144
- return lambda: fn(domain_name="avios.com")
145
-
146
-
147
- def import_from_browser(
148
- session: Session | None = None,
149
- browser: str = "chrome",
150
- *,
151
- loader: Callable[[], Iterable[_RawCookie]] | None = None,
152
- ) -> int:
153
- """Import the avios.com session cookie from a running browser.
154
-
155
- Returns the number of avios.com cookies imported.
156
- """
157
- session = session or Session()
158
- load = loader or _default_loader(browser)
159
- cookies = _only_avios(_to_cookie_dicts(load()))
160
- if not cookies:
161
- raise LoginError(
162
- f"No avios.com cookies found in {browser}. Log into avios.com there first."
163
- )
164
- session.save_cookies(cookies)
165
- return len(cookies)
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