Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
"""
|
|
2FA dead-end detection.
|
|
|
|
python tests\\test_dead_end.py
|
|
|
|
The `#dispMessage` strings below are copied from the real page eCW served on
|
|
2026-09-07, not invented. That matters: all four states — the healthy one and
|
|
the three dead ends — are written into the **same element**, so the only thing
|
|
separating "still waiting for mail" from "this page is finished" is the text.
|
|
Get the matching wrong in either direction and the cost is real: a missed dead
|
|
end burns twenty minutes and an approval link, while a false positive throws
|
|
away a login that was about to succeed.
|
|
|
|
No browser is started. A stub with one method is all `reason()` touches, which
|
|
is the point of keeping it that small.
|
|
"""
|
|
|
|
import sys
|
|
import pathlib
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
|
|
|
from ecw_bot.auth import dead_end # noqa: E402
|
|
from ecw_bot.auth.dead_end import LoginTimedOut # noqa: E402
|
|
|
|
|
|
class FakePage:
|
|
"""Just enough Page for `reason()`: locator().first.inner_text()."""
|
|
|
|
def __init__(self, text, raises=False):
|
|
self._text = text
|
|
self._raises = raises
|
|
|
|
def locator(self, _selector):
|
|
return self
|
|
|
|
@property
|
|
def first(self):
|
|
return self
|
|
|
|
def inner_text(self, timeout=None):
|
|
if self._raises:
|
|
raise RuntimeError("locator not found")
|
|
return self._text
|
|
|
|
|
|
# (label, #dispMessage text, should it be treated as a dead end)
|
|
CASES = [
|
|
("healthy - notification sent",
|
|
"eClinicalWorks sent a notification to your email. "
|
|
"Please confirm it was you.", False),
|
|
("healthy - 6-digit code prompt",
|
|
"Enter the 6 digit code sent to your email", False),
|
|
("timed out",
|
|
"Your log in request has timed out. Please try again", True),
|
|
("answered not-me",
|
|
"You confirmed that you weren't trying to Log in. "
|
|
"If this was a mistake, please try again.", True),
|
|
("resends exhausted",
|
|
"A new notification has been sent to your email, No attempt left.", True),
|
|
# Case must not matter — eCW has shipped "Log in" and "log in" in the same
|
|
# sentence, and this is matched on a fragment.
|
|
("timed out, lowercased",
|
|
"your log in request has timed out. please try again", True),
|
|
# Not on the 2FA screen at all: the element is missing and that is normal.
|
|
("element missing", None, False),
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
failures = 0
|
|
for label, text, want_dead in CASES:
|
|
page = FakePage(text or "", raises=text is None)
|
|
why = dead_end.reason(page)
|
|
got_dead = bool(why)
|
|
|
|
raised = False
|
|
try:
|
|
dead_end.assert_alive(page, "a test")
|
|
except LoginTimedOut:
|
|
raised = True
|
|
except Exception as exc: # pragma: no cover
|
|
print(f" FAIL {label}: assert_alive raised {type(exc).__name__}")
|
|
failures += 1
|
|
continue
|
|
|
|
# reason() and assert_alive() must never disagree — one is the other's
|
|
# only input.
|
|
ok = (got_dead == want_dead) and (raised == want_dead)
|
|
failures += not ok
|
|
verdict = "dead end" if got_dead else "alive "
|
|
wanted = "" if ok else \
|
|
(" (wanted dead end)" if want_dead else " (wanted alive)")
|
|
# ASCII only. A test that crashes on its own output is worse than no
|
|
# test, and a cp1252 console cannot encode an arrow — see runlog.start().
|
|
detail = f" -> {why}" if why else ""
|
|
print(f" {'ok ' if ok else 'FAIL'} {label:28s} "
|
|
f"{verdict}{wanted}{detail}")
|
|
|
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|