Microsoft changed the converged sign-in page to pre-render the password
view, so input#i0118 is present and visible while the email view is still
on screen. wait_for_selector() for the password field therefore returned
immediately, the fill landed mid view-switch and was wiped, and the Sign in
click submitted an empty box ("Please enter your password."). Planner never
loaded, no Graph request fired, and the run died with "Could not capture
Bearer token" on every machine from 2026-07-31 onwards.
Login flow:
- _fill_verified(): wait for the field to be interactive, fill, and read the
value back before submitting; retry when it does not stick.
- _submit_password()/_submit_totp(): confirm the page actually advanced, and
raise a specific error naming Microsoft's message when a credential is
rejected instead of timing out silently.
- _skip_login_interrupt(): dismiss the post-sign-in interstitials, including
"Let's keep your account secure", which was also never handled. The old
"Stay signed in?" handler only matched input#idBtn_Back and Microsoft now
renders that control as a <button>.
Token capture:
- Listen at context level so popups are observed.
- _TokenStore falls back to any graph.microsoft.com Bearer if no Planner-scoped
call appears, and records every Graph URL seen for diagnosis.
- MSAL localStorage fallback when no Graph request is intercepted at all.
- On failure, dump a screenshot, the page HTML and the request summary next to
the run log so the next regression does not need a code change to diagnose.
- Login timeout 120s -> 180s, with one retry of the whole login.
Also: force UTF-8 on stdout/stderr (the error banner crashed with
UnicodeEncodeError on a cp1252 console, hiding the real error), and only
prompt "Press Enter to close" when stdin is a TTY so scheduled runs do not
fail with EOFError.
Build: onedrive_planner_http.spec was gitignored and missing from the repo, so
build_exe.bat could not run; it is now tracked. requirements.txt was missing
httpx, playwright and python-dotenv.
Verified: scripts/verify_login.py completes login, token capture and read-only
Graph access (63 plans discovered). 103 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.7 KiB
Python
97 lines
3.7 KiB
Python
"""Unit tests for the Planner HTTP login helpers (token capture + MSAL cache)."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = ROOT / "src"
|
|
for _p in (str(ROOT), str(SRC)):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
from onedrive_planner_http import _pick_graph_token, _TokenStore # noqa: E402
|
|
|
|
GRAPH_PLANNER = "https://graph.microsoft.com/beta/planner/plans/TestPlanId?$expand=details"
|
|
GRAPH_ME = "https://graph.microsoft.com/v1.0/me/photo/$value"
|
|
NON_GRAPH = "https://planner.cloud.microsoft/webui/api/something"
|
|
|
|
|
|
class _Request:
|
|
def __init__(self, url: str, token: str | None = None) -> None:
|
|
self.url = url
|
|
self.headers = {"authorization": f"Bearer {token}"} if token else {}
|
|
|
|
|
|
class TestTokenStore:
|
|
def test_captures_planner_scoped_token(self):
|
|
store = _TokenStore()
|
|
store.on_request(_Request(GRAPH_PLANNER, "planner-tok"))
|
|
assert store.token == "planner-tok"
|
|
assert store.preferred is True
|
|
|
|
def test_ignores_narrow_graph_token_inside_wait_window(self):
|
|
store = _TokenStore(fallback_after_s=45)
|
|
store.on_request(_Request(GRAPH_ME, "narrow-tok"))
|
|
assert store.token is None
|
|
# ...and records the URL so a failure is diagnosable from the log
|
|
assert GRAPH_ME.split("?")[0] in store.graph_urls
|
|
|
|
def test_falls_back_to_any_graph_token_after_window(self):
|
|
store = _TokenStore(fallback_after_s=0)
|
|
store.on_request(_Request(GRAPH_ME, "narrow-tok"))
|
|
assert store.token == "narrow-tok"
|
|
assert store.preferred is False
|
|
|
|
def test_planner_token_overrides_earlier_fallback(self):
|
|
store = _TokenStore(fallback_after_s=0)
|
|
store.on_request(_Request(GRAPH_ME, "narrow-tok"))
|
|
store.on_request(_Request(GRAPH_PLANNER, "planner-tok"))
|
|
assert store.token == "planner-tok"
|
|
assert store.preferred is True
|
|
|
|
def test_fallback_does_not_override_planner_token(self):
|
|
store = _TokenStore(fallback_after_s=0)
|
|
store.on_request(_Request(GRAPH_PLANNER, "planner-tok"))
|
|
store.on_request(_Request(GRAPH_ME, "narrow-tok"))
|
|
assert store.token == "planner-tok"
|
|
|
|
def test_non_graph_bearer_is_recorded_but_not_used(self):
|
|
store = _TokenStore(fallback_after_s=0)
|
|
store.on_request(_Request(NON_GRAPH, "spa-tok"))
|
|
assert store.token is None
|
|
assert "planner.cloud.microsoft" in store.bearer_hosts
|
|
|
|
def test_requests_without_authorization_are_ignored(self):
|
|
store = _TokenStore(fallback_after_s=0)
|
|
store.on_request(_Request(GRAPH_PLANNER))
|
|
assert store.token is None
|
|
assert store.graph_urls == set()
|
|
|
|
|
|
class TestPickGraphToken:
|
|
def test_picks_entry_with_graph_scopes(self):
|
|
entries = [
|
|
{"target": "openid profile", "secret": "id-tok"},
|
|
{"target": "Tasks.Read Group.Read.All", "secret": "graph-tok"},
|
|
]
|
|
assert _pick_graph_token(entries) == "graph-tok"
|
|
|
|
def test_prefers_the_entry_with_more_wanted_scopes(self):
|
|
entries = [
|
|
{"target": "https://graph.microsoft.com/Tasks.Read", "secret": "one"},
|
|
{"target": "Tasks.Read Group.Read.All GroupMember.Read.All", "secret": "many"},
|
|
]
|
|
assert _pick_graph_token(entries) == "many"
|
|
|
|
def test_returns_none_without_a_graph_entry(self):
|
|
entries = [{"target": "openid profile offline_access", "secret": "id-tok"}]
|
|
assert _pick_graph_token(entries) is None
|
|
|
|
def test_skips_entries_with_no_secret(self):
|
|
entries = [{"target": "Tasks.Read", "secret": ""}]
|
|
assert _pick_graph_token(entries) is None
|
|
|
|
def test_tolerates_junk_entries(self):
|
|
assert _pick_graph_token([None, "nonsense", {}, 42]) is None
|