avios-cli 0.1.2__tar.gz → 0.1.3__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.
- {avios_cli-0.1.2 → avios_cli-0.1.3}/CHANGELOG.md +16 -1
- {avios_cli-0.1.2 → avios_cli-0.1.3}/PKG-INFO +15 -10
- {avios_cli-0.1.2 → avios_cli-0.1.3}/README.md +14 -9
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/__init__.py +1 -1
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/auth.py +59 -25
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/cli.py +2 -1
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/endpoints.py +4 -0
- avios_cli-0.1.3/tests/test_auth.py +243 -0
- avios_cli-0.1.2/tests/test_auth.py +0 -108
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.editorconfig +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/dependabot.yml +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.gitignore +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/.python-version +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/CONTRIBUTING.md +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/LICENSE +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/SECURITY.md +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/docs/dashboard.svg +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/pyproject.toml +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/scripts/screenshot.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/client.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/config.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/models.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/session.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/app.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/art.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/widgets.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/conftest.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/accounts.json +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/balance.json +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_cli.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_client.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_models.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_session.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_tui.py +0 -0
- {avios_cli-0.1.2 → avios_cli-0.1.3}/uv.lock +0 -0
|
@@ -6,6 +6,20 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Login never actually captured an authenticated session.** The dashboard is a
|
|
13
|
+
client-side SPA, so `wait_for_url("**/manage-avios/**")` matched the initial
|
|
14
|
+
navigation immediately and the browser closed before login — saving anonymous
|
|
15
|
+
cookies, so every call then failed with "Session expired". Login now polls
|
|
16
|
+
`/auth-gateway/user` until it returns 200 (i.e. you're actually logged in) and
|
|
17
|
+
only then captures the cookies.
|
|
18
|
+
- Login launches your **system Chrome** (`channel="chrome"`) when available, so no
|
|
19
|
+
150 MB Chromium download is needed; falls back to bundled Chromium.
|
|
20
|
+
- Corrected the Chromium install command to `uvx --from playwright playwright
|
|
21
|
+
install chromium`.
|
|
22
|
+
|
|
9
23
|
## [0.1.2] - 2026-07-24
|
|
10
24
|
|
|
11
25
|
### Fixed
|
|
@@ -49,7 +63,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
49
63
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
50
64
|
Airways capture).
|
|
51
65
|
|
|
52
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.
|
|
66
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...HEAD
|
|
67
|
+
[0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
|
|
53
68
|
[0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
|
|
54
69
|
[0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
|
|
55
70
|
[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.
|
|
3
|
+
Version: 0.1.3
|
|
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,27 @@ 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`
|
|
82
|
-
|
|
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`.
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
Prefer not to open a browser? Import the cookie from a Chrome you're already logged
|
|
94
|
+
into avios.com with:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
91
97
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
92
98
|
```
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
97
102
|
|
|
98
103
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
99
104
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
@@ -43,22 +43,27 @@ 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`
|
|
47
|
-
|
|
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`.
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
Prefer not to open a browser? Import the cookie from a Chrome you're already logged
|
|
59
|
+
into avios.com with:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
56
62
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
57
63
|
```
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
62
67
|
|
|
63
68
|
Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
|
|
64
69
|
expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
|
|
@@ -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,78 @@ 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
|
|
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
|
+
|
|
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:
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
+
)
|
|
94
128
|
|
|
95
129
|
|
|
96
130
|
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
|
-
"
|
|
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,243 @@
|
|
|
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))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- fake Playwright, to exercise the poll-until-authenticated flow --------------
|
|
112
|
+
class _FakeResponse:
|
|
113
|
+
def __init__(self, ok: bool) -> None:
|
|
114
|
+
self.ok = ok
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _FakeRequest:
|
|
118
|
+
"""Auth endpoint returns 401 until `ok_after` calls, then 200."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, ok_after: int) -> None:
|
|
121
|
+
self.ok_after = ok_after
|
|
122
|
+
self.calls = 0
|
|
123
|
+
|
|
124
|
+
def get(self, url: str) -> _FakeResponse:
|
|
125
|
+
self.calls += 1
|
|
126
|
+
return _FakeResponse(self.calls >= self.ok_after)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _FakePage:
|
|
130
|
+
def goto(self, url: str) -> None:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def wait_for_timeout(self, ms: int) -> None:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _FakeContext:
|
|
138
|
+
def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
|
|
139
|
+
self.request = _FakeRequest(ok_after)
|
|
140
|
+
self._cookies = cookies
|
|
141
|
+
|
|
142
|
+
def new_page(self) -> _FakePage:
|
|
143
|
+
return _FakePage()
|
|
144
|
+
|
|
145
|
+
def cookies(self) -> list[dict[str, str]]:
|
|
146
|
+
return self._cookies
|
|
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
|
+
def close(self) -> None:
|
|
158
|
+
self.closed = True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _FakePlaywright:
|
|
162
|
+
def __init__(self, browser: _FakeBrowser) -> None:
|
|
163
|
+
self.chromium = _FakeChromium(browser)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _FakeChromium:
|
|
167
|
+
def __init__(self, browser: _FakeBrowser) -> None:
|
|
168
|
+
self._browser = browser
|
|
169
|
+
self.launches: list[str | None] = []
|
|
170
|
+
|
|
171
|
+
def launch(self, **kwargs: object) -> _FakeBrowser:
|
|
172
|
+
self.launches.append(kwargs.get("channel")) # type: ignore[arg-type]
|
|
173
|
+
return self._browser
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _FakeFactory:
|
|
177
|
+
"""Callable that returns a context manager yielding a fake Playwright."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, browser: _FakeBrowser) -> None:
|
|
180
|
+
self._pw = _FakePlaywright(browser)
|
|
181
|
+
|
|
182
|
+
def __call__(self) -> _FakeFactory:
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __enter__(self) -> _FakePlaywright:
|
|
186
|
+
return self._pw
|
|
187
|
+
|
|
188
|
+
def __exit__(self, *args: object) -> bool:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_login_captures_only_after_authenticated(settings: Settings) -> None:
|
|
193
|
+
cookies = [
|
|
194
|
+
{"name": "appSession", "value": "x", "domain": "www.avios.com"},
|
|
195
|
+
{"name": "_ga", "value": "junk", "domain": ".google.com"},
|
|
196
|
+
]
|
|
197
|
+
ctx = _FakeContext(ok_after=3, cookies=cookies)
|
|
198
|
+
browser = _FakeBrowser(ctx)
|
|
199
|
+
session = _session(settings)
|
|
200
|
+
|
|
201
|
+
count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(browser))
|
|
202
|
+
|
|
203
|
+
assert count == 1 # only the avios cookie
|
|
204
|
+
assert session.load_cookies() == cookies
|
|
205
|
+
assert browser.closed
|
|
206
|
+
assert ctx.request.calls >= 3 # polled until authenticated, not immediately
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_login_times_out_without_saving(settings: Settings) -> None:
|
|
210
|
+
ctx = _FakeContext(
|
|
211
|
+
ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
212
|
+
)
|
|
213
|
+
browser = _FakeBrowser(ctx)
|
|
214
|
+
session = _session(settings)
|
|
215
|
+
|
|
216
|
+
with pytest.raises(LoginError, match="Timed out"):
|
|
217
|
+
login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(browser))
|
|
218
|
+
|
|
219
|
+
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"
|
|
236
|
+
|
|
237
|
+
class PW:
|
|
238
|
+
def __init__(self) -> None:
|
|
239
|
+
self.chromium = Chromium()
|
|
240
|
+
|
|
241
|
+
pw = PW()
|
|
242
|
+
assert _launch_browser(pw, headless=True) == "browser"
|
|
243
|
+
assert pw.chromium.channels == ["chrome", None]
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|