Snapshot of D:\Ecw Process a11c9ff (2026-10-30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
r"""
|
|
auth/twofactor.py `handle_2fa()` — an approval the login page never picks up.
|
|
|
|
Live 2026-10-29, three logins in a row: the push mail came 6 to 8.5 minutes
|
|
late, the bot opened its link and clicked "Yes, it's me", and the login page
|
|
stayed on "eClinicalWorks sent a notification to your email" with Resend OTP
|
|
still there. The bot waited out LOGIN_TIMEOUT and gave up ("Login not
|
|
confirmed"). Now it presses Resend, waits for the new mail and approves that
|
|
one, up to OTP_PICKUP_RESENDS times (operator, 2026-10-30: no manual step).
|
|
|
|
python tests\test_twofa_late_approval.py
|
|
|
|
Local headless chromium, `page.set_content()` only. The mailbox and the
|
|
approval tab are stand-ins: no eCW, no mail.
|
|
"""
|
|
|
|
import sys
|
|
import pathlib
|
|
import tempfile
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
from ecw_bot import settings as cfg # noqa: E402
|
|
from ecw_bot.auth import twofactor # noqa: E402
|
|
from ecw_bot.auth.mailbox import Challenge # noqa: E402
|
|
|
|
CASES = []
|
|
|
|
|
|
def case(fn):
|
|
CASES.append(fn)
|
|
return fn
|
|
|
|
|
|
# The push screen as seen live: the notice in #dispMessage, Resend enabled.
|
|
_PAGE = """
|
|
<div id="dispMessage">eClinicalWorks sent a notification to your email.
|
|
Please confirm to login.</div>
|
|
<input type="button" id="resendOtpBtn" value="Resend OTP"
|
|
onclick="window.resends = (window.resends || 0) + 1">
|
|
<a id="jellybean-panelLink4" style="display:none">Home</a>
|
|
<script>window.resends = 0</script>
|
|
"""
|
|
|
|
|
|
def _go_home(page):
|
|
page.evaluate("document.getElementById('jellybean-panelLink4')"
|
|
".style.display = ''; document.getElementById('dispMessage')"
|
|
".style.display = 'none'")
|
|
|
|
|
|
def _fakes(page, picked_up_on):
|
|
"""Mailbox hands out links in order; approval number `picked_up_on`
|
|
(1-based) is the one the page takes."""
|
|
seen = {"approvals": 0, "mails": 0}
|
|
|
|
def get_challenge(**kw):
|
|
seen["mails"] += 1
|
|
return Challenge("link", f"https://example.test/approve/{seen['mails']}")
|
|
|
|
def approve(context, url):
|
|
seen["approvals"] += 1
|
|
if seen["approvals"] == picked_up_on:
|
|
_go_home(page)
|
|
return True
|
|
|
|
twofactor.mailbox.get_challenge = get_challenge
|
|
twofactor._approve_in_new_tab = approve
|
|
return seen
|
|
|
|
|
|
@case
|
|
def an_approval_not_picked_up_is_resent_and_approved_again(page):
|
|
page.set_content(_PAGE)
|
|
seen = _fakes(page, picked_up_on=2)
|
|
twofactor.handle_2fa(page, None, "Test", None)
|
|
assert seen["approvals"] == 2, seen
|
|
assert page.evaluate("window.resends") == 1
|
|
assert page.locator("#jellybean-panelLink4").is_visible()
|
|
|
|
|
|
@case
|
|
def an_approval_picked_up_at_once_needs_no_resend(page):
|
|
page.set_content(_PAGE)
|
|
seen = _fakes(page, picked_up_on=1)
|
|
twofactor.handle_2fa(page, None, "Test", None)
|
|
assert seen["approvals"] == 1, seen
|
|
assert page.evaluate("window.resends || 0") == 0
|
|
|
|
|
|
@case
|
|
def resends_stop_at_the_limit(page):
|
|
page.set_content(_PAGE)
|
|
seen = _fakes(page, picked_up_on=99)
|
|
twofactor.handle_2fa(page, None, "Test", None)
|
|
assert page.evaluate("window.resends") == cfg.OTP_PICKUP_RESENDS
|
|
assert seen["approvals"] == cfg.OTP_PICKUP_RESENDS + 1, seen
|
|
|
|
|
|
def main() -> int:
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
failures = 0
|
|
real = (twofactor.mailbox.get_challenge, twofactor._approve_in_new_tab)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cfg.SCREENSHOTS = pathlib.Path(tmp) # never the real folder
|
|
cfg.ECW_SECRET_KEY = ""
|
|
cfg.OTP_PICKUP_SECONDS = 2
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch(headless=True)
|
|
page = browser.new_page()
|
|
for fn in CASES:
|
|
name = fn.__name__.replace("_", " ")
|
|
try:
|
|
fn(page)
|
|
print(f" ok {name}")
|
|
except AssertionError as exc:
|
|
print(f" FAIL {name}: {exc}")
|
|
failures += 1
|
|
except Exception as exc:
|
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
|
failures += 1
|
|
finally:
|
|
(twofactor.mailbox.get_challenge,
|
|
twofactor._approve_in_new_tab) = real
|
|
browser.close()
|
|
|
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|