Portal sign-in with the Ecare office login
Ecare staff sign in with their office username and password, checked by the Ecare sign-in service from the backend (AUTH_TOKEN_API_URL); a good Ecare login still needs a Portal User. Service down = "try again shortly", never a wrong password. /api/auth/login, me, logout; sign-in tries limited per address. Users page: Ecare office login or Portal password per user. Context.md: 27/10 runs, the login retry bug. 71 test files, 1239 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83a0cf65ae
commit
64efb68ea1
10 files changed
+556
-48
No files matched your search
+118
-4
@@ -234,17 +234,18 @@ def an_admin_adds_a_user_who_then_must_change():
|
||||
c, _ = _sign_in(app, "asha")
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="Ravi", display_name="Ravi K", role="Viewer",
|
||||
password="temp-password-1"))
|
||||
password="temp-password-1", signin="portal"))
|
||||
assert r.status_code == 200 and "ravi added" in r.text, r.text[:300]
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="kim", display_name="", role="Admin",
|
||||
password="temp-password-1"))
|
||||
assert r.status_code == 400 and "An Admin can add or change Viewer only" in r.text and "kim" not in store.users
|
||||
password="temp-password-1", signin="portal"))
|
||||
assert r.status_code == 400 and "kim" not in store.users
|
||||
assert "An Admin can add or change Viewer only" in r.text
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert ("ravi", "user_created", "asha") in store.events
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="x", display_name="", role="Viewer",
|
||||
password="temp-password-1"))
|
||||
password="temp-password-1", signin="portal"))
|
||||
assert r.status_code == 400 and "Username" in r.text
|
||||
|
||||
|
||||
@@ -402,6 +403,119 @@ def the_container_keeps_india_time():
|
||||
assert "tzdata" in line, line
|
||||
|
||||
|
||||
# ── Ecare office sign-in (2026-10-27) ────────────────────────────────────────
|
||||
|
||||
def _ecare_portal(verdict="ok"):
|
||||
"""(app, store, ecare): sam (Super Admin, Portal password), ravi
|
||||
(Viewer, Ecare login); the Ecare service a fake giving `verdict`."""
|
||||
from test_portal_users import FakeEcare
|
||||
store, ecare = FakeUserStore(), FakeEcare(verdict)
|
||||
acc = portal_users.Accounts(store)
|
||||
acc.create_user("sam", "Sam", "Super Admin", PW)
|
||||
acc.create_user("ravi", "Ravi", "Viewer", None, by="sam", ecare=True)
|
||||
app = web_app.create_app(FakeRepo(), runner=FakeRunner(),
|
||||
users_store=store, ecare=ecare,
|
||||
logs_dir=tempfile.mkdtemp())
|
||||
return app, store, ecare
|
||||
|
||||
|
||||
def _api_login(app, client, username, password):
|
||||
return client.post("/api/auth/login", json={
|
||||
"username": username, "password": password},
|
||||
headers={**ORIGIN, "X-Run-Token": app.state.run_token})
|
||||
|
||||
|
||||
@case
|
||||
def the_sign_in_page_answers_each_ecare_outcome():
|
||||
for verdict, name, code, words in (
|
||||
("bad", "ravi", 401, "Wrong username or password"),
|
||||
("down", "ravi", 503, "try again shortly"),
|
||||
("ok", "stranger", 403, "no access to this Portal")):
|
||||
app, _, _ = _ecare_portal(verdict)
|
||||
_, r = _sign_in(app, name, "office-pass")
|
||||
assert r.status_code == code and words in r.text, (verdict, code,
|
||||
r.status_code)
|
||||
app, store, ecare = _ecare_portal("ok")
|
||||
c, r = _sign_in(app, "ravi", "office-pass", next_url="/days")
|
||||
assert r.status_code == 303 and r.headers["location"] == "/days"
|
||||
assert "office-pass" not in str(store.events)
|
||||
assert c.get("/days").status_code == 200
|
||||
|
||||
|
||||
@case
|
||||
def the_auth_api_signs_in_says_who_and_signs_out():
|
||||
app, _, _ = _ecare_portal("ok")
|
||||
c = TestClient(app, base_url=BASE)
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
r = _api_login(app, c, " ravi ", "office-pass")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"username": "ravi", "display_name": "Ravi",
|
||||
"role": "Viewer", "must_change": False}, r.json()
|
||||
assert "httponly" in r.headers["set-cookie"].lower()
|
||||
assert "token" not in r.text.lower(), "the Ecare token stays out"
|
||||
assert c.get("/api/auth/me").json()["role"] == "Viewer"
|
||||
r = c.post("/api/auth/logout",
|
||||
headers={**ORIGIN, "X-Run-Token": app.state.run_token})
|
||||
assert r.status_code == 200
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
@case
|
||||
def the_auth_api_gives_400_401_403_503_and_needs_the_token():
|
||||
for verdict, name, password, code in (("ok", " ", "x", 400),
|
||||
("ok", "ravi", " ", 400),
|
||||
("bad", "ravi", "nope", 401),
|
||||
("ok", "stranger", "pw", 403),
|
||||
("down", "ravi", "pw", 503)):
|
||||
app, _, _ = _ecare_portal(verdict)
|
||||
r = _api_login(app, TestClient(app, base_url=BASE), name, password)
|
||||
assert r.status_code == code, (verdict, name, r.status_code)
|
||||
app, _, ecare = _ecare_portal("ok")
|
||||
r = TestClient(app, base_url=BASE).post(
|
||||
"/api/auth/login", json={"username": "ravi", "password": "pw"},
|
||||
headers=ORIGIN)
|
||||
assert r.status_code == 403 and ecare.calls == [], "no X-Run-Token"
|
||||
|
||||
|
||||
@case
|
||||
def sign_in_attempts_from_one_address_are_limited():
|
||||
app, _, _ = _ecare_portal("bad")
|
||||
c = TestClient(app, base_url=BASE)
|
||||
for i in range(web_app.SIGN_IN_LIMIT):
|
||||
assert _api_login(app, c, f"user{i}", "nope").status_code == 401, i
|
||||
assert _api_login(app, c, "ravi", "nope").status_code == 429
|
||||
_, r = _sign_in(app, "ravi", "nope")
|
||||
assert r.status_code == 429, "the form shares the limit"
|
||||
|
||||
|
||||
@case
|
||||
def the_users_page_adds_an_ecare_user_without_a_password():
|
||||
app, store, _ = _ecare_portal("ok")
|
||||
c, _ = _sign_in(app, "sam")
|
||||
page = c.get("/admin/users").text
|
||||
assert 'name="signin"' in page and "Ecare office login" in page
|
||||
assert 'action="/admin/users/ravi/reset"' not in page, \
|
||||
"an Ecare user's password is not reset here"
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="mani", display_name="Mani", role="Admin",
|
||||
signin="ecare", password=""))
|
||||
assert r.status_code == 200 and "mani added" in r.text, r.text[:300]
|
||||
assert portal_users.is_ecare(store.users["mani"])
|
||||
assert store.users["mani"]["must_change"] is False
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="client1", display_name="Client", role="Viewer",
|
||||
signin="portal", password="short"))
|
||||
assert r.status_code == 400, "a Portal password still has the rules"
|
||||
|
||||
|
||||
@case
|
||||
def an_ecare_user_is_told_to_change_the_password_in_ecare():
|
||||
app, _, _ = _ecare_portal("ok")
|
||||
c, _ = _sign_in(app, "ravi", "office-pass")
|
||||
page = c.get("/account/password").text
|
||||
assert "Ecare office password" in page and 'name="current"' not in page
|
||||
|
||||
|
||||
@case
|
||||
def no_signed_in_page_carries_an_inline_style():
|
||||
app, _, _ = _portal()
|
||||
|
||||
@@ -273,6 +273,171 @@ def the_sql_table_allows_the_same_roles_and_moves_old_ones():
|
||||
assert first < sql.index("SET role = 'Admin' WHERE role = 'Operator'"), "Admins move up before Operators take their place"
|
||||
|
||||
|
||||
# ── Ecare office sign-in (2026-10-27) ────────────────────────────────────────
|
||||
|
||||
class FakeEcare:
|
||||
"""EcareSignIn.check, canned: "ok", "bad" or "down"."""
|
||||
|
||||
def __init__(self, verdict="ok"):
|
||||
self.verdict = verdict
|
||||
self.calls = []
|
||||
|
||||
def check(self, username, password):
|
||||
self.calls.append(username)
|
||||
return self.verdict
|
||||
|
||||
|
||||
def _ecare_accounts(verdict="ok"):
|
||||
store, clock, ecare = FakeUserStore(), Clock(), FakeEcare(verdict)
|
||||
acc = users.Accounts(store, now=clock, ecare=ecare)
|
||||
acc.create_user("Asha", "Asha R", "Super Admin", "first-password-1")
|
||||
acc.create_user("ravi", "Ravi", "Viewer", None, by="asha", ecare=True)
|
||||
return acc, store, clock, ecare
|
||||
|
||||
|
||||
class _Reply:
|
||||
def __init__(self, body: bytes, status=200):
|
||||
self._body, self.status = body, status
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def _service(answer):
|
||||
"""(EcareSignIn, the requests it sent) with urlopen giving `answer`:
|
||||
bytes for a 200 body, an int for an HTTP error, or an exception."""
|
||||
import urllib.error
|
||||
sent = []
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
sent.append((request, timeout))
|
||||
if isinstance(answer, BaseException):
|
||||
raise answer
|
||||
if isinstance(answer, int):
|
||||
raise urllib.error.HTTPError(request.full_url, answer, "x", {},
|
||||
None)
|
||||
return _Reply(answer)
|
||||
return users.EcareSignIn("https://auth.example/api/login/token",
|
||||
urlopen=urlopen), sent
|
||||
|
||||
|
||||
@case
|
||||
def the_ecare_service_is_asked_from_the_backend_with_a_15_second_limit():
|
||||
import json
|
||||
svc, sent = _service(b'{"success": true, "token": "abc"}')
|
||||
assert svc.check(" Ravi ", "pw-123") == "ok"
|
||||
request, timeout = sent[0]
|
||||
assert timeout == 15 and request.get_method() == "POST"
|
||||
assert request.full_url == "https://auth.example/api/login/token"
|
||||
assert request.get_header("Content-type") == "application/json"
|
||||
assert json.loads(request.data) == {"username": "Ravi",
|
||||
"password": "pw-123"}
|
||||
|
||||
|
||||
@case
|
||||
def the_ecare_answer_reads_as_ok_bad_or_down():
|
||||
import socket
|
||||
import urllib.error
|
||||
for answer, want in ((b'{"token": "abc"}', "ok"),
|
||||
(b'{"token": ""}', "bad"),
|
||||
(b'{"success": false, "token": "abc"}', "bad"),
|
||||
(b'{"success": true}', "bad"),
|
||||
(b"not json", "down"),
|
||||
(401, "bad"), (403, "bad"), (400, "bad"),
|
||||
# A wrong AUTH_TOKEN_API_URL, not a wrong password:
|
||||
# else every user would lock themselves out.
|
||||
(404, "down"), (405, "down"),
|
||||
(500, "down"), (503, "down"),
|
||||
(socket.timeout("slow"), "down"),
|
||||
(urllib.error.URLError("no route"), "down"),
|
||||
(ConnectionResetError(), "down")):
|
||||
svc, _ = _service(answer)
|
||||
got = svc.check("ravi", "pw-123")
|
||||
assert got == want, (answer, got, want)
|
||||
|
||||
|
||||
@case
|
||||
def an_ecare_user_signs_in_with_the_office_password_only():
|
||||
acc, store, _, ecare = _ecare_accounts("ok")
|
||||
u = store.users["ravi"]
|
||||
assert u["pw_iterations"] == 0 and u["must_change"] is False, u
|
||||
user, why = acc.sign_in(" RAVI ", "office-pass")
|
||||
assert why == "ok" and user["username"] == "ravi", why
|
||||
assert ecare.calls == ["RAVI"], "the name as typed, trimmed"
|
||||
assert ("ravi", "sign_in", "") in store.events
|
||||
assert users.is_ecare(user) and not users.is_ecare(store.get("asha"))
|
||||
|
||||
|
||||
@case
|
||||
def a_local_user_never_reaches_the_ecare_service():
|
||||
acc, _, _, ecare = _ecare_accounts("ok")
|
||||
assert acc.sign_in("asha", "first-password-1")[1] == "ok"
|
||||
assert acc.sign_in("asha", "office-pass")[1] == "bad"
|
||||
assert ecare.calls == []
|
||||
|
||||
|
||||
@case
|
||||
def a_good_ecare_login_without_a_portal_user_is_no_access():
|
||||
acc, store, _, _ = _ecare_accounts("ok")
|
||||
assert acc.sign_in("stranger", "office-pass") == (None, "no_access")
|
||||
assert ("stranger", "no_access", "") in store.events
|
||||
assert "stranger" not in store.users, "never added by itself"
|
||||
|
||||
|
||||
@case
|
||||
def a_wrong_ecare_password_counts_toward_the_lock():
|
||||
acc, store, clock, ecare = _ecare_accounts("bad")
|
||||
for _ in range(4):
|
||||
assert acc.sign_in("ravi", "nope")[1] == "bad"
|
||||
assert acc.sign_in("ravi", "nope")[1] == "locked"
|
||||
ecare.verdict = "ok"
|
||||
n = len(ecare.calls)
|
||||
assert acc.sign_in("ravi", "office-pass")[1] == "locked"
|
||||
assert len(ecare.calls) == n, "a locked user is not sent to Ecare"
|
||||
clock.t += timedelta(minutes=15, seconds=1)
|
||||
assert acc.sign_in("ravi", "office-pass")[1] == "ok"
|
||||
|
||||
|
||||
@case
|
||||
def ecare_down_is_never_a_wrong_password():
|
||||
acc, store, _, _ = _ecare_accounts("down")
|
||||
assert acc.sign_in("ravi", "office-pass") == (None, "down")
|
||||
assert store.users["ravi"]["failed_count"] == 0
|
||||
assert ("ravi", "service_down", "") in store.events
|
||||
acc.ecare = None # AUTH_TOKEN_API_URL not set
|
||||
assert acc.sign_in("ravi", "office-pass") == (None, "down")
|
||||
|
||||
|
||||
@case
|
||||
def a_disabled_ecare_user_stays_out_and_empty_input_never_leaves():
|
||||
acc, _, _, ecare = _ecare_accounts("ok")
|
||||
acc.set_disabled("ravi", True, by="asha")
|
||||
assert acc.sign_in("ravi", "office-pass") == (None, "disabled")
|
||||
n = len(ecare.calls)
|
||||
assert acc.sign_in("ravi", "")[1] == "bad"
|
||||
assert acc.sign_in("", "pw")[1] == "bad"
|
||||
assert len(ecare.calls) == n, "nothing empty is sent to Ecare"
|
||||
|
||||
|
||||
@case
|
||||
def an_ecare_users_password_is_changed_in_ecare_not_here():
|
||||
acc, _, _, _ = _ecare_accounts()
|
||||
for fn in (lambda: acc.reset_password("ravi", "temp-password-9",
|
||||
by="asha"),
|
||||
lambda: acc.change_password("ravi", "a", "new-password-1")):
|
||||
try:
|
||||
fn()
|
||||
raise AssertionError("allowed")
|
||||
except users.UserError as exc:
|
||||
assert "Ecare" in str(exc), exc
|
||||
|
||||
|
||||
@case
|
||||
def sessions_time_out_after_thirty_idle_minutes():
|
||||
clock = Clock()
|
||||
|
||||
Reference in new issue
Block a user