"""What the app asks of a new password, and what it says about one. At least 10 characters, as before, and now at most 64 (NIST 800-63B-4 asks that 64 be allowed; ASVS 6.2.9 the same). No other rule: no mix of character types, no list of banned passwords. The cap applies where a password is set, never at sign-in, so an account with a longer one still gets in. Creating an account asks for it twice, as changing it already did. """ from __future__ import annotations import html import re from pathlib import Path import pytest from sqlalchemy import select from app import auth from app.models import User from test_routes import PASSWORD, make_login, pre_auth_csrf, session_csrf HTML = {"accept": "text/html"} TEMPLATES = Path(__file__).resolve().parent.parent / "app" / "templates" @pytest.mark.parametrize("length, problem", [ (9, "at least 10"), (10, None), (64, None), (65, "at most 64"), ]) def test_a_password_is_10_to_64_characters(length, problem): found = auth.password_problem("x" * length) assert (found is None) if problem is None else (problem in (found or "")), found def test_characters_not_bytes_are_counted(): """NIST: each code point is one character. 64 of a three-byte one is fine.""" assert auth.password_problem("€" * 64) is None def test_changing_to_65_characters_is_refused(client, session_factory): make_login(client, session_factory) long = "x" * 65 page = client.post("/profile/password", data={ "current_password": PASSWORD, "password": long, "confirm": long, "_csrf": session_csrf(session_factory)}, headers=HTML) assert page.status_code == 200 and "at most 64" in page.text def test_a_longer_password_from_before_still_signs_in(client, session_factory): """The cap is for choosing one. Refusing an existing account at the door would lock its owner out of the only place they could change it.""" make_login(client, session_factory) long = "an old passphrase that is well over sixty-four characters long, as some were" with session_factory() as s: s.scalar(select(User)).password_hash = auth.hash_password(long) s.commit() client.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) login = client.get("/login", headers=HTML).text token = re.search(r'name="_csrf" value="([^"]+)"', login).group(1) resp = client.post("/login", data={"email": "user@example.test", "password": long, "_csrf": token}, headers=HTML, follow_redirects=False) assert resp.status_code == 303 and resp.headers["location"] == "/" def _invite(client, session_factory) -> str: make_login(client, session_factory) resp = client.post("/members/invite", follow_redirects=False, data={"_csrf": session_csrf(session_factory), "role": "member"}) token = re.search(r"/invite/([A-Za-z0-9_-]+)", resp.text).group(1) client.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) return token def _joiner(session_factory): with session_factory() as s: return s.scalar(select(User).where(User.email == "joiner@example.test")) @pytest.mark.parametrize("confirm", ["something else entirely", ""]) def test_signing_up_asks_for_the_password_twice(client, session_factory, confirm): token = _invite(client, session_factory) page = client.get(f"/invite/{token}", headers=HTML).text assert re.search(r' None: """A refusal sends back what was typed, so only the passwords are typed again.""" assert re.search(rf']*>', text, re.S) assert inputs, f"no password field in {template}" for tag in inputs: assert 'minlength="10"' in tag and 'maxlength="64"' in tag, (template, tag) def test_the_meter_loads_zxcvbn_from_where_it_is_served(client, session_factory): """The browser test inlines zxcvbn; this is the real path, which the meter asks for on the first keystroke.""" make_login(client, session_factory) page = client.get("/profile", headers=HTML).text src = re.search(r'class="strength"[^>]*data-zxcvbn="([^"?]+)', page) assert src, "no meter on the change-password form" served = client.get(src.group(1)) assert served.status_code == 200 and "zxcvbn" in served.text[:200]