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>
547 lines
22 KiB
Python
547 lines
22 KiB
Python
r"""
|
|
The Portal's Sign-in, roles and Admin pages — `web/app.py` with
|
|
`web/users.py` (spec 2026-10-22 Stage 2, EEWA-32), against the in-memory
|
|
user store and the fake repository / runner (no SQL Server).
|
|
|
|
python tests\test_portal_signin.py
|
|
|
|
* Nothing but /signin without a Sign-in: a page goes to /signin (and
|
|
back after), /api answers 401.
|
|
* The Sign-in form needs this page's token and Origin; the cookie is
|
|
HttpOnly + SameSite=Strict; `next` never leaves the site.
|
|
* Wrong password / lock / disabled read as such; 30 minutes idle signs
|
|
out (and is recorded).
|
|
* A user an Admin made must change the password before anything else.
|
|
* Roles (2026-10-27): a Viewer sees no Start / Stop and is refused them;
|
|
only a Super Admin or an Admin reaches Portal Users and the Sign-in
|
|
history; an Admin adds and changes Viewers only, a Super Admin Admins
|
|
and Viewers, and nobody changes a Super Admin from the Portal.
|
|
* Started By / Stopped By reach the runner as the signed-in username.
|
|
* Admin changes take effect on the user's open session at once.
|
|
"""
|
|
|
|
import sys
|
|
import pathlib
|
|
import tempfile
|
|
from datetime import timedelta
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
sys.path.insert(0, str(ROOT / "tests"))
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from web import app as web_app # noqa: E402
|
|
from web import users as portal_users # noqa: E402
|
|
from test_portal_users import FakeUserStore, Clock # noqa: E402
|
|
from test_web_app import FakeRepo, FakeRunner # noqa: E402
|
|
|
|
CASES = []
|
|
BASE = "http://127.0.0.1:8000"
|
|
ORIGIN = {"Origin": BASE}
|
|
PW = "first-password-1"
|
|
|
|
|
|
def case(fn):
|
|
CASES.append(fn)
|
|
return fn
|
|
|
|
|
|
def _portal(runner=None, clock=None):
|
|
"""(app, store, runner) with users sam (Super Admin), asha (Admin) and
|
|
vee (Viewer), all able to sign in straight away."""
|
|
store = FakeUserStore()
|
|
acc = portal_users.Accounts(store)
|
|
for name, role in (("sam", "Super Admin"), ("asha", "Admin"),
|
|
("vee", "Viewer")):
|
|
acc.create_user(name, name.title(), role, PW)
|
|
runner = runner or FakeRunner()
|
|
sessions = portal_users.Sessions(now=clock) if clock else None
|
|
app = web_app.create_app(FakeRepo(), runner=runner, users_store=store,
|
|
sessions=sessions, logs_dir=tempfile.mkdtemp())
|
|
return app, store, runner
|
|
|
|
|
|
def _sign_in(app, name, password=PW, next_url="/days"):
|
|
client = TestClient(app, base_url=BASE)
|
|
r = client.post("/signin", headers=ORIGIN, data={
|
|
"username": name, "password": password,
|
|
"token": app.state.run_token, "next": next_url},
|
|
follow_redirects=False)
|
|
return client, r
|
|
|
|
|
|
def _form(app, **data):
|
|
return {"token": app.state.run_token, **data}
|
|
|
|
|
|
@case
|
|
def nothing_opens_without_a_sign_in():
|
|
app, _, _ = _portal()
|
|
c = TestClient(app, base_url=BASE)
|
|
r = c.get("/day/2026-10-16?category=Fail", follow_redirects=False)
|
|
assert r.status_code == 303, r.status_code
|
|
assert r.headers["location"] == ("/signin?next=%2Fday%2F2026-10-16%3F"
|
|
"category%3DFail"), r.headers
|
|
for path in ("/api/runs", "/api/results", "/api/bot/status"):
|
|
assert c.get(path).status_code == 401, path
|
|
assert c.post("/api/bot/start", headers={**ORIGIN, "X-Run-Token":
|
|
app.state.run_token}).status_code == 401
|
|
assert c.get("/signin").status_code == 200
|
|
|
|
|
|
@case
|
|
def a_sign_in_goes_back_and_sets_a_strict_httponly_cookie():
|
|
app, store, _ = _portal()
|
|
c, r = _sign_in(app, "ASHA", next_url="/day/2026-10-16")
|
|
assert r.status_code == 303 and r.headers["location"] == "/day/2026-10-16"
|
|
cookie = r.headers["set-cookie"].lower()
|
|
assert "httponly" in cookie and "samesite=strict" in cookie, cookie
|
|
assert c.get("/days").status_code == 200
|
|
assert ("asha", "sign_in", "") in store.events
|
|
|
|
|
|
@case
|
|
def next_never_leaves_the_site():
|
|
app, _, _ = _portal()
|
|
for bad in ("//evil.example/x", "https://evil.example", "/\\evil",
|
|
"javascript:alert(1)"):
|
|
_, r = _sign_in(app, "asha", next_url=bad)
|
|
assert r.headers["location"] == "/", (bad, r.headers["location"])
|
|
|
|
|
|
@case
|
|
def the_sign_in_form_needs_the_token_and_origin():
|
|
app, _, _ = _portal()
|
|
c = TestClient(app, base_url=BASE)
|
|
data = {"username": "asha", "password": PW}
|
|
assert c.post("/signin", headers=ORIGIN, data=data).status_code == 403
|
|
assert c.post("/signin", headers={"Origin": "http://evil.example"},
|
|
data=_form(app, **data)).status_code == 403
|
|
|
|
|
|
@case
|
|
def a_wrong_password_says_so_and_five_lock():
|
|
app, store, _ = _portal()
|
|
_, r = _sign_in(app, "asha", "wrong-password-x")
|
|
assert r.status_code == 401 and "Wrong username or password" in r.text
|
|
_, r = _sign_in(app, "nobody", "wrong-password-x")
|
|
assert "Wrong username or password" in r.text, "same words, unknown user"
|
|
for _ in range(4):
|
|
_, r = _sign_in(app, "asha", "wrong-password-x")
|
|
assert "locked for 15 minutes" in r.text, r.text
|
|
_, r = _sign_in(app, "asha")
|
|
assert r.status_code == 401 and "locked" in r.text
|
|
|
|
|
|
@case
|
|
def sign_out_ends_the_session():
|
|
app, store, _ = _portal()
|
|
c, _ = _sign_in(app, "asha")
|
|
assert c.post("/signout", headers=ORIGIN,
|
|
data={"token": "wrong"}).status_code == 403
|
|
r = c.post("/signout", headers=ORIGIN, data=_form(app),
|
|
follow_redirects=False)
|
|
assert r.status_code == 303 and r.headers["location"] == "/signin"
|
|
c.cookies.clear()
|
|
assert c.get("/days", follow_redirects=False).status_code == 303
|
|
assert ("asha", "sign_out", "") in store.events
|
|
|
|
|
|
@case
|
|
def thirty_idle_minutes_sign_out_and_are_recorded():
|
|
clock = Clock()
|
|
app, store, _ = _portal(clock=clock)
|
|
c, _ = _sign_in(app, "vee")
|
|
clock.t += timedelta(minutes=29)
|
|
assert c.get("/days").status_code == 200
|
|
clock.t += timedelta(minutes=31)
|
|
assert c.get("/days", follow_redirects=False).status_code == 303
|
|
assert ("vee", "timeout", "") in store.events
|
|
|
|
|
|
@case
|
|
def an_admin_made_user_must_change_the_password_first():
|
|
app, store, _ = _portal()
|
|
portal_users.Accounts(store).create_user("newbie", "New", "Viewer",
|
|
"temp-password-1", by="asha")
|
|
c, r = _sign_in(app, "newbie", "temp-password-1")
|
|
assert r.headers["location"] == "/account/password"
|
|
assert c.get("/days", follow_redirects=False).headers["location"] \
|
|
== "/account/password"
|
|
assert c.get("/api/runs").status_code == 403
|
|
r = c.post("/account/password", headers=ORIGIN, data=_form(
|
|
app, current="temp-password-1", new="my-own-password",
|
|
confirm="my-own-password"), follow_redirects=False)
|
|
assert r.status_code == 303, r.text
|
|
assert c.get("/days").status_code == 200
|
|
|
|
|
|
@case
|
|
def a_password_change_with_a_mismatch_is_refused():
|
|
app, _, _ = _portal()
|
|
c, _ = _sign_in(app, "vee")
|
|
r = c.post("/account/password", headers=ORIGIN, data=_form(
|
|
app, current=PW, new="new-password-1", confirm="new-password-2"))
|
|
assert r.status_code == 400 and "not the same" in r.text
|
|
|
|
|
|
@case
|
|
def a_viewer_sees_no_start_and_is_refused_it():
|
|
app, _, runner = _portal()
|
|
c, _ = _sign_in(app, "vee")
|
|
page = c.get("/").text
|
|
assert '<button type="button" id="br-start" hidden>' in page
|
|
assert "const CAN_RUN = false;" in page
|
|
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
|
assert c.post("/api/bot/start", headers=h).status_code == 403
|
|
assert c.post("/api/bot/stop", headers=h).status_code == 403
|
|
assert runner.calls == []
|
|
for path in ("/admin/users", "/admin/signins"):
|
|
assert c.get(path).status_code == 403, path
|
|
|
|
|
|
@case
|
|
def started_by_and_stopped_by_are_the_signed_in_user():
|
|
app, _, runner = _portal()
|
|
c, _ = _sign_in(app, "asha")
|
|
assert "const CAN_RUN = true;" in c.get("/").text
|
|
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
|
assert c.post("/api/bot/start", headers=h).status_code == 200
|
|
assert runner.started_by == "asha"
|
|
assert c.post("/api/bot/stop", headers=h).status_code == 200
|
|
assert runner.stopped_by == "asha"
|
|
|
|
|
|
@case
|
|
def the_header_names_the_user_and_admin_links_are_admin_only():
|
|
app, _, _ = _portal()
|
|
for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
|
|
a, _ = _sign_in(app, name)
|
|
page = a.get("/runs").text
|
|
assert f"<b>{name.title()}</b> · {role}" in page, name
|
|
assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page
|
|
v, _ = _sign_in(app, "vee")
|
|
page = v.get("/runs").text
|
|
assert 'href="/admin/users"' not in page
|
|
assert 'href="/admin/signins"' not in page
|
|
|
|
|
|
@case
|
|
def an_admin_adds_a_user_who_then_must_change():
|
|
app, store, _ = _portal()
|
|
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", 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", 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", signin="portal"))
|
|
assert r.status_code == 400 and "Username" in r.text
|
|
|
|
|
|
@case
|
|
def admin_changes_reach_an_open_session_at_once():
|
|
app, store, _ = _portal()
|
|
admin, _ = _sign_in(app, "sam")
|
|
olu, _ = _sign_in(app, "asha")
|
|
vee, _ = _sign_in(app, "vee")
|
|
admin.post("/admin/users/asha/role", headers=ORIGIN,
|
|
data=_form(app, role="Viewer"))
|
|
assert "const CAN_RUN = false;" in olu.get("/").text, "role now Viewer"
|
|
admin.post("/admin/users/vee/disable", headers=ORIGIN, data=_form(app))
|
|
assert vee.get("/days", follow_redirects=False).status_code == 303
|
|
_, r = _sign_in(app, "vee")
|
|
assert r.status_code == 401 and "disabled" in r.text
|
|
admin.post("/admin/users/asha/reset", headers=ORIGIN,
|
|
data=_form(app, password="temp-password-2"))
|
|
assert olu.get("/days", follow_redirects=False).status_code == 303
|
|
assert store.users["asha"]["must_change"] is True
|
|
|
|
|
|
@case
|
|
def the_users_page_offers_only_what_each_role_may_change():
|
|
"""An Admin's page: add a Viewer, change Viewer rows only. A Super
|
|
Admin's: Admin or Viewer, never Super Admin; no row changes a Super
|
|
Admin."""
|
|
app, store, _ = _portal()
|
|
a, _ = _sign_in(app, "asha")
|
|
page = a.get("/admin/users").text
|
|
assert "<option selected>Viewer</option>" in page
|
|
assert "<option>Admin</option>" not in page, "an Admin adds Viewers only"
|
|
assert 'action="/admin/users/vee/reset"' in page
|
|
for name in ("sam", "asha"):
|
|
assert f'action="/admin/users/{name}/' not in page, name
|
|
s, _ = _sign_in(app, "sam")
|
|
page = s.get("/admin/users").text
|
|
assert "<option>Admin</option>" in page and "Super Admin</option>" not in page
|
|
assert 'action="/admin/users/asha/role"' in page
|
|
assert 'action="/admin/users/sam/' not in page, "nobody changes sam here"
|
|
r = a.post("/admin/users/sam/disable", headers=ORIGIN, data=_form(app))
|
|
assert r.status_code == 400 and not store.users["sam"]["disabled"]
|
|
|
|
|
|
@case
|
|
def admin_forms_need_the_token():
|
|
app, store, _ = _portal()
|
|
c, _ = _sign_in(app, "asha")
|
|
r = c.post("/admin/users/vee/disable", headers=ORIGIN,
|
|
data={"token": "nope"})
|
|
assert r.status_code == 403 and not store.users["vee"]["disabled"]
|
|
|
|
|
|
@case
|
|
def the_sign_in_history_lists_events_for_an_admin():
|
|
app, _, _ = _portal()
|
|
_sign_in(app, "vee", "wrong-password-x")
|
|
for name in ("sam", "asha"):
|
|
c, _ = _sign_in(app, name)
|
|
page = c.get("/admin/signins").text
|
|
assert "wrong password" in page and "vee" in page, name
|
|
|
|
|
|
@case
|
|
def with_no_users_the_sign_in_page_says_how_to_add_one():
|
|
app = web_app.create_app(FakeRepo(), runner=FakeRunner(),
|
|
users_store=FakeUserStore(),
|
|
logs_dir=tempfile.mkdtemp())
|
|
page = TestClient(app, base_url=BASE).get("/signin").text
|
|
assert "add_portal_user.py" in page
|
|
|
|
|
|
@case
|
|
def the_sign_in_page_says_what_the_portal_is_and_helps_with_the_password():
|
|
"""Operator, 2026-10-26: the Sign-in page was plain. It now introduces
|
|
the Portal, can show the typed password, warns about Caps Lock, and
|
|
says the idle and lockout rules."""
|
|
app, _, _ = _portal()
|
|
page = TestClient(app, base_url=BASE).get("/signin").text
|
|
assert 'class="auth-brand"' in page and "Charge Validation" in page
|
|
assert 'id="pw-toggle"' in page and 'aria-controls="pw"' in page
|
|
assert 'id="caps"' in page, "a Caps Lock warning"
|
|
assert "30 minutes" in page and "15 minutes" in page
|
|
assert 'name="username"' in page and 'id="pw" name="password"' in page
|
|
assert '<link rel="icon"' in page, "a tab icon, no /favicon.ico 404"
|
|
|
|
|
|
@case
|
|
def a_server_portal_never_starts_the_bot():
|
|
"""Dokploy, 2026-10-27: the bot runs only on the Windows PC, so the
|
|
Portal on the server shows results and never offers Start / Stop."""
|
|
store = FakeUserStore()
|
|
portal_users.Accounts(store).create_user("sam", "Sam", "Super Admin", PW)
|
|
app = web_app.create_app(FakeRepo(), users_store=store, can_start=False,
|
|
logs_dir=tempfile.mkdtemp())
|
|
c, _ = _sign_in(app, "sam")
|
|
assert "const CAN_RUN = false;" in c.get("/").text
|
|
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
|
assert c.post("/api/bot/start", headers=h).status_code == 403
|
|
assert c.post("/api/bot/stop", headers=h).status_code == 403
|
|
st = c.get("/api/bot/status").json()
|
|
assert st["running"] is False, st
|
|
assert c.get("/runs").status_code == 200
|
|
|
|
|
|
@case
|
|
def the_portal_url_gives_the_hosts_origins_and_a_secure_cookie():
|
|
env = web_app.portal_settings("https://cv.example.com, "
|
|
"http://10.0.0.5:8080/", server=True)
|
|
assert env == {"allowed_hosts": ["cv.example.com", "10.0.0.5"],
|
|
"allowed_origins": ["https://cv.example.com",
|
|
"http://10.0.0.5:8080"],
|
|
"secure_cookie": False, "can_start": False}, env
|
|
assert web_app.portal_settings("https://cv.example.com",
|
|
server=True)["secure_cookie"] is True
|
|
local = web_app.portal_settings("", server=False)
|
|
assert local == {"allowed_hosts": list(web_app.ALLOWED_HOSTS),
|
|
"allowed_origins": list(web_app.ALLOWED_ORIGINS),
|
|
"secure_cookie": False, "can_start": True}, local
|
|
base = "https://cv.example.com"
|
|
store = FakeUserStore()
|
|
portal_users.Accounts(store).create_user("sam", "Sam", "Viewer", PW)
|
|
app = web_app.create_app(FakeRepo(), runner=FakeRunner(),
|
|
users_store=store, logs_dir=tempfile.mkdtemp(),
|
|
**web_app.portal_settings(base, server=True))
|
|
r = TestClient(app, base_url=base).post(
|
|
"/signin", headers={"Origin": base}, follow_redirects=False,
|
|
data={"username": "sam", "password": PW,
|
|
"token": app.state.run_token, "next": "/"})
|
|
assert r.status_code == 303, r.status_code
|
|
assert "; secure" in r.headers["set-cookie"].lower(), r.headers
|
|
|
|
|
|
@case
|
|
def the_container_keeps_the_odbc_drivers_kerberos_library():
|
|
"""Dokploy, 2026-10-27: autoremove took libgssapi-krb5-2 with curl, so
|
|
ODBC Driver 18 could not load ("Can't open lib ... file not found")."""
|
|
docker = (pathlib.Path(__file__).resolve().parent.parent
|
|
/ "Dockerfile").read_text(encoding="utf-8")
|
|
line = next(l for l in docker.splitlines() if "msodbcsql18" in l
|
|
and "install" in l)
|
|
assert "libgssapi-krb5-2" in line, line
|
|
|
|
|
|
@case
|
|
def the_container_keeps_india_time():
|
|
"""Dokploy, 2026-10-27: the server clock is UTC, so a Sign-in at 15:45
|
|
IST was recorded 10:15 and "today" would turn at 18:30 IST. The bot's
|
|
rows are IST; the Portal must be too."""
|
|
docker = (pathlib.Path(__file__).resolve().parent.parent
|
|
/ "Dockerfile").read_text(encoding="utf-8")
|
|
assert "TZ=Asia/Kolkata" in docker
|
|
line = next(l for l in docker.splitlines() if "msodbcsql18" in l
|
|
and "install" in l)
|
|
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()
|
|
c, _ = _sign_in(app, "asha")
|
|
for path in ("/signin", "/account/password", "/admin/users",
|
|
"/admin/signins"):
|
|
assert " style=" not in c.get(path).text, path
|
|
|
|
|
|
def main() -> int:
|
|
failures = 0
|
|
for fn in CASES:
|
|
name = fn.__name__.replace("_", " ")
|
|
try:
|
|
fn()
|
|
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
|
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|