Files
FabledSteward/tests/integration/test_users_page.py
T
bvandeusenandClaude Opus 5.5 c533eec33a
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m3s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m21s
feat(settings): Users page — list, add, rename, role, password, disable, delete (#5336)
#5336 "Users page: list accounts, change role, disable, reset a local
password, create a local user".

- Settings › Users (admin only, a Settings tab): every account with how it
  signs in, its role, last sign-in (from the audit log) and working AI
  tokens; disabled accounts last. A row opens the account.
- An account's page: rename (any account), role and password (local
  accounts; an LDAP or single sign-on role comes from the provider at
  every sign-in, and the page says so), disable or enable, and Delete
  behind the confirm dialog.
- Refusals are said beside the control: nobody disables, demotes or
  deletes themselves, the last active admin stays, and an account that
  made alert rules, maintenance windows or playbook runs is disabled
  rather than deleted (those records keep who made them).
- Add user: a local account with a role and a password of 8+ characters.
- Every change is audited as user.*, and the audit log names and links the
  account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:12:56 -04:00

171 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration: Settings › Users (#5336) against real Postgres. An admin adds
a local account, changes its role, sets its password, disables and deletes
it; their own account and an account that made records are refused, and
every change is audited."""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import bcrypt
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role="admin"):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"adm-{uid[:8]}", email=f"{uid}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"adm-{uid[:8]}",
_csrf="test-token", login_at=time.time())
return client, uid
async def _user(app, user_id):
from steward.models.users import User
async with app.db_sessionmaker() as s:
return await s.get(User, user_id)
async def _audits(app, uid):
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def test_an_admin_adds_changes_and_deletes_a_local_account(app):
async def _go():
client, me = await _client(app)
name = f"new-{uuid.uuid4().hex[:8]}"
resp = await client.post("/settings/users/new", headers=CSRF, form={
"username": name, "email": f"{name}@test", "role": "viewer",
"password": "short", "confirm": "short"})
assert resp.status_code == 400 and "at least 8 characters" in await resp.get_data(as_text=True)
resp = await client.post("/settings/users/new", headers=CSRF, form={
"username": name, "email": f"{name}@test", "role": "viewer",
"password": "correct horse", "confirm": "correct horse"})
assert resp.status_code == 302
uid = resp.headers["Location"].split("/settings/users/")[1].split("?")[0]
user = await _user(app, uid)
assert (user.username, user.role.value, user.auth_source) == (name, "viewer", "local")
assert bcrypt.checkpw(b"correct horse", user.password_hash.encode())
page = await (await client.get("/settings/users/")).get_data(as_text=True)
assert f'href="/settings/users/{uid}"' in page and 'aria-label="Settings"' in page
assert (await client.post(f"/settings/users/{uid}/role", headers=CSRF,
form={"role": "operator"})).status_code == 302
assert (await _user(app, uid)).role.value == "operator"
resp = await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "battery staple", "confirm": "battery stapler"})
assert resp.status_code == 400 and "match" in await resp.get_data(as_text=True)
assert (await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "battery staple", "confirm": "battery staple"})).status_code == 302
assert bcrypt.checkpw(b"battery staple", (await _user(app, uid)).password_hash.encode())
assert (await client.post(f"/settings/users/{uid}/active", headers=CSRF)).status_code == 302
assert not (await _user(app, uid)).is_active
assert (await client.post(f"/settings/users/{uid}/active", headers=CSRF)).status_code == 302
assert (await _user(app, uid)).is_active
assert (await client.post(f"/settings/users/{uid}", headers=CSRF,
form={"username": name + "-x", "email": f"{name}@test"})).status_code == 302
assert (await _user(app, uid)).username == name + "-x"
assert (await client.post(f"/settings/users/{uid}/delete", headers=CSRF)).status_code == 302
assert await _user(app, uid) is None
assert [a for a, _ in await _audits(app, me)] == [
"user.created", "user.role_changed", "user.password_reset", "user.disabled",
"user.enabled", "user.renamed", "user.deleted"]
asyncio.run(_go())
def test_an_admin_cannot_disable_demote_or_delete_themselves(app):
async def _go():
client, me = await _client(app)
for path, form, says in ((f"/settings/users/{me}/active", {}, "your own account"),
(f"/settings/users/{me}/role", {"role": "viewer"}, "your own role"),
(f"/settings/users/{me}/delete", {}, "your own account")):
resp = await client.post(path, headers=CSRF, form=form)
assert resp.status_code == 400 and says in await resp.get_data(as_text=True), path
me_now = await _user(app, me)
assert me_now.is_active and me_now.role.value == "admin"
asyncio.run(_go())
def test_an_account_that_made_records_is_disabled_not_deleted(app):
from steward.models.alerts import MaintenanceWindow
async def _go():
client, _ = await _client(app)
_, other = await _client(app, role="operator")
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
# Over already, so it silences no other test's alerts.
s.add(MaintenanceWindow(name="patching", start_at=now - timedelta(days=2),
end_at=now - timedelta(days=1), created_by=other))
page = await (await client.get(f"/settings/users/{other}")).get_data(as_text=True)
assert "1 maintenance window" in page and "Delete account</button>" not in page
resp = await client.post(f"/settings/users/{other}/delete", headers=CSRF)
assert resp.status_code == 400 and "Disable the account instead" in await resp.get_data(as_text=True)
assert await _user(app, other) is not None
asyncio.run(_go())
def test_a_provider_account_s_role_is_not_edited_here(app):
from steward.models.users import User, UserRole
async def _go():
client, _ = await _client(app)
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"sso-{uid[:8]}", email=f"{uid}@oidc", password_hash="x",
role=UserRole.viewer, auth_source="oidc", external_id=uid))
resp = await client.post(f"/settings/users/{uid}/role", headers=CSRF, form={"role": "admin"})
assert resp.status_code == 400 and "at each sign-in" in await resp.get_data(as_text=True)
resp = await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "long enough", "confirm": "long enough"})
assert resp.status_code == 400
assert (await _user(app, uid)).role.value == "viewer"
asyncio.run(_go())
def test_only_an_admin_reaches_users(app):
async def _go():
client, _ = await _client(app, role="operator")
assert (await client.get("/settings/users/")).status_code == 403
assert (await client.post("/settings/users/new", headers=CSRF, form={})).status_code == 403
asyncio.run(_go())