feat: wire validation models into admin routes and deduplicate error handling
Replace manual validation error formatting with shared helper in both admin and manage profile routes. Add UsernameInput validation to invite route and GroupListInput validation to groups route. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56c177c817
commit
72a93984f2
4 changed files with 205 additions and 49 deletions
88
tests/test_admin_groups_validation.py
Normal file
88
tests/test_admin_groups_validation.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from porchlight.authn.password import PasswordHasher, PasswordService
|
||||
from porchlight.models import PasswordCredential, User
|
||||
|
||||
from tests.conftest import get_csrf_token
|
||||
|
||||
|
||||
async def _setup_admin_and_target(client: AsyncClient) -> tuple[str, str]:
|
||||
"""Create admin + target user, login as admin, return (token, target_userid)."""
|
||||
app = client._transport.app
|
||||
user_repo = app.state.user_repo
|
||||
cred_repo = app.state.credential_repo
|
||||
|
||||
admin = User(
|
||||
userid="admin-g01",
|
||||
username="admin_g",
|
||||
groups=["admin", "users"],
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
await user_repo.create(admin)
|
||||
|
||||
target = User(
|
||||
userid="target-g01",
|
||||
username="target_g",
|
||||
groups=["users"],
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
await user_repo.create(target)
|
||||
|
||||
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||
await cred_repo.create_password(
|
||||
PasswordCredential(user_id=admin.userid, password_hash=svc.hash("AdminPass123!"))
|
||||
)
|
||||
|
||||
token = await get_csrf_token(client)
|
||||
await client.post(
|
||||
"/login/password",
|
||||
data={"username": "admin_g", "password": "AdminPass123!"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
return token, target.userid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_groups(client: AsyncClient) -> None:
|
||||
token, userid = await _setup_admin_and_target(client)
|
||||
response = await client.post(
|
||||
f"/admin/users/{userid}/groups",
|
||||
data={"groups": "users, staff"},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert "Groups updated" in response.text
|
||||
|
||||
app = client._transport.app
|
||||
user = await app.state.user_repo.get_by_userid(userid)
|
||||
assert sorted(user.groups) == ["staff", "users"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_group_name_rejected(client: AsyncClient) -> None:
|
||||
token, userid = await _setup_admin_and_target(client)
|
||||
response = await client.post(
|
||||
f"/admin/users/{userid}/groups",
|
||||
data={"groups": "users, Bad Group!"},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert "alert" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_groups_clears(client: AsyncClient) -> None:
|
||||
token, userid = await _setup_admin_and_target(client)
|
||||
response = await client.post(
|
||||
f"/admin/users/{userid}/groups",
|
||||
data={"groups": ""},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert "Groups updated" in response.text
|
||||
|
||||
app = client._transport.app
|
||||
user = await app.state.user_repo.get_by_userid(userid)
|
||||
assert user.groups == []
|
||||
74
tests/test_admin_invite_validation.py
Normal file
74
tests/test_admin_invite_validation.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from porchlight.authn.password import PasswordHasher, PasswordService
|
||||
from porchlight.models import PasswordCredential, User
|
||||
|
||||
from tests.conftest import get_csrf_token
|
||||
|
||||
|
||||
async def _login_admin(client: AsyncClient) -> str:
|
||||
"""Create and login as admin user, return CSRF token."""
|
||||
app = client._transport.app
|
||||
user_repo = app.state.user_repo
|
||||
cred_repo = app.state.credential_repo
|
||||
|
||||
user = User(
|
||||
userid="admin-01",
|
||||
username="admin",
|
||||
groups=["admin", "users"],
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
await user_repo.create(user)
|
||||
|
||||
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||
await cred_repo.create_password(
|
||||
PasswordCredential(user_id=user.userid, password_hash=svc.hash("AdminPass123!"))
|
||||
)
|
||||
|
||||
token = await get_csrf_token(client)
|
||||
await client.post(
|
||||
"/login/password",
|
||||
data={"username": "admin", "password": "AdminPass123!"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_valid_username(client: AsyncClient) -> None:
|
||||
token = await _login_admin(client)
|
||||
response = await client.post(
|
||||
"/admin/invite",
|
||||
data={"username": "newuser@example.com"},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Invite created" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_empty_username_rejected(client: AsyncClient) -> None:
|
||||
token = await _login_admin(client)
|
||||
response = await client.post(
|
||||
"/admin/invite",
|
||||
data={"username": ""},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
# Empty username is rejected — either by FastAPI (422) or validation (alert)
|
||||
assert response.status_code == 422 or "alert" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_invalid_username_rejected(client: AsyncClient) -> None:
|
||||
token = await _login_admin(client)
|
||||
response = await client.post(
|
||||
"/admin/invite",
|
||||
data={"username": "bad user<script>"},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert "alert" in response.text
|
||||
assert "letters" in response.text.lower() or "Username" in response.text
|
||||
Loading…
Add table
Add a link
Reference in a new issue