Private
Public Access
Both account-creation surfaces now require the password twice: - Web signup (invite-based self-service): SignupComplete gains a password_confirm field with a model_validator backstop server-side, plus a client-side match check in SignupPage.tsx for immediate feedback -- the client check is the primary UX, the server check is defense in depth so the guarantee doesn't rely on the client alone. - CLI (python -m app.cli create-user): password is now an optional positional argument. If omitted, prompts interactively via getpass (hidden input) twice, retrying on mismatch -- matching what "entered twice and verified" actually means for a human typing blind. Passing the password directly as before still works unchanged, for scripted/ automated provisioning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
79 lines
2.7 KiB
Python
79 lines
2.7 KiB
Python
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import select
|
|
|
|
from app.cli import _create_user, _prompt_password
|
|
from app.database import async_session_factory
|
|
from app.database import engine as _cli_engine
|
|
from app.models import User
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _dispose_cli_engine_between_tests():
|
|
# _create_user (like the real CLI) uses app.database's module-level
|
|
# engine directly, not the db_session fixture's own per-test engine --
|
|
# pytest-asyncio gives each test function a fresh event loop by
|
|
# default, and a pooled connection opened on a since-closed loop
|
|
# produces asyncpg "another operation is in progress" errors if reused
|
|
# by a later test. Disposing after every test forces a fresh connection
|
|
# next time instead of reusing a stale one.
|
|
yield
|
|
await _cli_engine.dispose()
|
|
|
|
|
|
async def _get_user(username: str) -> User | None:
|
|
async with async_session_factory() as session:
|
|
result = await session.execute(select(User).where(User.username == username))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
def test_prompt_password_matches_on_first_try(monkeypatch):
|
|
responses = iter(["correct-horse", "correct-horse"])
|
|
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
|
|
|
|
assert _prompt_password() == "correct-horse"
|
|
|
|
|
|
def test_prompt_password_retries_on_mismatch(monkeypatch, capsys):
|
|
responses = iter(["typo-password", "correct-password", "correct-password", "correct-password"])
|
|
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
|
|
|
|
assert _prompt_password() == "correct-password"
|
|
assert "didn't match" in capsys.readouterr().out
|
|
|
|
|
|
async def test_create_user_with_explicit_password_succeeds():
|
|
username = _unique("alice")
|
|
await _create_user(username, f"{username}@example.com", "password123", False)
|
|
|
|
user = await _get_user(username)
|
|
assert user is not None
|
|
assert user.is_site_admin is False
|
|
|
|
|
|
async def test_create_user_via_prompted_password(monkeypatch):
|
|
responses = iter(["prompted-pass", "prompted-pass"])
|
|
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
|
|
|
|
username = _unique("bob")
|
|
password = _prompt_password()
|
|
await _create_user(username, f"{username}@example.com", password, True)
|
|
|
|
user = await _get_user(username)
|
|
assert user is not None
|
|
assert user.is_site_admin is True
|
|
|
|
|
|
async def test_create_user_rejects_short_password():
|
|
username = _unique("shortpw")
|
|
with pytest.raises(SystemExit):
|
|
await _create_user(username, f"{username}@example.com", "short", False)
|
|
|
|
assert await _get_user(username) is None
|