🚧 WIP
This commit is contained in:
+537
-9
@@ -19,12 +19,18 @@ os.environ.setdefault(
|
||||
)
|
||||
|
||||
from api.app import ( # noqa: E402
|
||||
DeviceRenameStore,
|
||||
ImeiAttributesStore,
|
||||
ImeiMapStore,
|
||||
SessionStore,
|
||||
TokenStore,
|
||||
UserStore,
|
||||
_validate_attribute_body,
|
||||
_validate_rename_body,
|
||||
_validate_token_body,
|
||||
hash_password,
|
||||
hash_token,
|
||||
verify_password,
|
||||
)
|
||||
from listener.process import ( # noqa: E402
|
||||
InjectionResult,
|
||||
@@ -355,6 +361,145 @@ class TestTokenHelpers:
|
||||
_validate_token_body({"tokens": {"app": ""}}, "test")
|
||||
|
||||
|
||||
class TestValidateRenameBody:
|
||||
"""_validate_rename_body is the schema check for rename requests."""
|
||||
|
||||
def test_accepts_full_body(self) -> None:
|
||||
name, frm, to, dry = _validate_rename_body(
|
||||
{"device": "New name", "from": "2026-08-01T00:00:00Z", "to": "2026-08-31T00:00:00Z", "dry_run": True},
|
||||
"test",
|
||||
)
|
||||
assert name == "New name"
|
||||
assert frm == datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
assert to == datetime(2026, 8, 31, tzinfo=timezone.utc)
|
||||
assert dry is True
|
||||
|
||||
def test_absent_bounds_are_unbounded(self) -> None:
|
||||
# the core framing rule: absent/null bound = no framing on that end
|
||||
name, frm, to, dry = _validate_rename_body({"device": "X"}, "test")
|
||||
assert frm is None
|
||||
assert to is None
|
||||
assert dry is False
|
||||
name, frm, to, dry = _validate_rename_body(
|
||||
{"device": "X", "from": None, "to": None}, "test"
|
||||
)
|
||||
assert frm is None and to is None
|
||||
|
||||
def test_one_sided_frames(self) -> None:
|
||||
_, frm, to, _ = _validate_rename_body(
|
||||
{"device": "X", "from": "2026-08-01T00:00:00Z"}, "test"
|
||||
)
|
||||
assert frm is not None and to is None
|
||||
_, frm, to, _ = _validate_rename_body(
|
||||
{"device": "X", "to": "2026-08-31T00:00:00Z"}, "test"
|
||||
)
|
||||
assert frm is None and to is not None
|
||||
|
||||
def test_rejects_bad_bodies(self) -> None:
|
||||
for body in ({}, {"device": ""}, {"device": 42}, {"device": "X", "from": 5}, {"device": "X", "from": "garbage"}, [1]):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_rename_body(body, "test")
|
||||
|
||||
|
||||
class TestRenameEndpoint:
|
||||
"""POST /devices/{imei}/rename — backfill rename, superuser-gated,
|
||||
optional inclusive received_at frame, dry_run preview."""
|
||||
|
||||
HDRS = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, *_ = api
|
||||
renames = api[6]
|
||||
return TestClient(app), renames
|
||||
|
||||
def test_requires_auth(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.post("/devices/350123456789012/rename", json={"device": "X"})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_viewer_role_403(self, api) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, *_ = api
|
||||
sessions = api[5]
|
||||
client = TestClient(app)
|
||||
token = sessions.login("viewer1")
|
||||
r = client.post(
|
||||
"/devices/350123456789012/rename",
|
||||
json={"device": "X"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_dry_run_and_apply_pass_frame_through(self, client_and_store) -> None:
|
||||
client, renames = client_and_store
|
||||
r = client.post(
|
||||
"/devices/350123456789012/rename",
|
||||
json={"device": "New name", "from": "2026-08-01T00:00:00Z", "dry_run": True},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["imei"] == "350123456789012"
|
||||
assert body["device"] == "New name"
|
||||
assert body["dry_run"] is True
|
||||
call = renames.calls[-1]
|
||||
assert call["from"] == datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
assert call["to"] is None # unbounded end: no framing
|
||||
|
||||
r = client.post(
|
||||
"/devices/350123456789012/rename",
|
||||
json={"device": "New name", "from": "2026-08-01T00:00:00Z", "to": "2026-08-31T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.json() == {
|
||||
"imei": "350123456789012",
|
||||
"device": "New name",
|
||||
"updated": 3,
|
||||
"dry_run": False,
|
||||
}
|
||||
|
||||
def test_superuser_allowed(self, api) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, *_ = api
|
||||
sessions = api[5]
|
||||
renames = api[6]
|
||||
client = TestClient(app)
|
||||
token = sessions.login("super1")
|
||||
r = client.post(
|
||||
"/devices/350123456789012/rename",
|
||||
json={"device": "New name"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert renames.calls[-1]["device"] == "New name"
|
||||
|
||||
def test_reversed_frame_422(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.post(
|
||||
"/devices/350123456789012/rename",
|
||||
json={"device": "X", "from": "2026-09-01T00:00:00Z", "to": "2026-08-01T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "earlier than or equal" in r.json()["detail"]
|
||||
|
||||
def test_bad_bodies_422(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
for body in ({}, {"device": ""}, {"device": 42}, {"device": "X", "from": "garbage"}, [1]):
|
||||
r = client.post("/devices/350123456789012/rename", json=body, headers=self.HDRS)
|
||||
assert r.status_code == 422, body
|
||||
|
||||
def test_non_imei_url_422(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.post("/devices/123/rename", json={"device": "X"}, headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
class TestValidateAttributeBody:
|
||||
"""_validate_attribute_body is the schema check for attribute appends."""
|
||||
|
||||
@@ -550,19 +695,155 @@ def app_module(monkeypatch):
|
||||
yield app_module
|
||||
|
||||
|
||||
class FakeUserStore(UserStore):
|
||||
"""In-memory user store: no database, scrypt hashing still real."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
users: dict[str, tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
# users: {username: (password, role)}
|
||||
super().__init__("postgresql://unused")
|
||||
self._users = {
|
||||
name: (hash_password(secret), role)
|
||||
for name, (secret, role) in (users or {}).items()
|
||||
}
|
||||
self.upserted: tuple[str, str, str, str] | None = None
|
||||
self.deleted: str | None = None
|
||||
|
||||
def verify(self, username: str, password: str) -> str | None:
|
||||
entry = self._users.get(username)
|
||||
if entry is None:
|
||||
verify_password(password, hash_password("timing-equalizer"))
|
||||
return None
|
||||
stored, role = entry
|
||||
return role if verify_password(password, stored) else None
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"username": name, "role": entry[1]}
|
||||
for name, entry in sorted(self._users.items())
|
||||
]
|
||||
|
||||
def get_role(self, username: str) -> str | None:
|
||||
entry = self._users.get(username)
|
||||
return entry[1] if entry else None
|
||||
|
||||
def upsert(self, username: str, password: str, role: str, actor_role: str) -> None:
|
||||
if actor_role not in ("user", "superuser", "admin"):
|
||||
raise PermissionError(f"unknown actor role {actor_role!r}")
|
||||
if actor_role != "admin" and role != "user":
|
||||
raise PermissionError(
|
||||
f"{actor_role} may only create or assign the 'user' role"
|
||||
)
|
||||
self.upserted = (username, password, role, actor_role)
|
||||
self._users[username] = (hash_password(password), role)
|
||||
|
||||
def delete(self, username: str, actor_role: str) -> bool:
|
||||
entry = self._users.get(username)
|
||||
if entry is None:
|
||||
return False
|
||||
if actor_role == "superuser" and entry[1] != "user":
|
||||
raise PermissionError(
|
||||
"superuser may only delete accounts with role 'user'"
|
||||
)
|
||||
if entry[1] == "admin" and sum(
|
||||
1 for e in self._users.values() if e[1] == "admin"
|
||||
) <= 1:
|
||||
return False # last admin
|
||||
self.deleted = username
|
||||
del self._users[username]
|
||||
return True
|
||||
|
||||
|
||||
class FakeSessionStore(SessionStore):
|
||||
"""In-memory session store: token -> username; the role is looked up
|
||||
fresh from the user store on resolve (mirrors the real JOIN)."""
|
||||
|
||||
def __init__(self, user_store: FakeUserStore) -> None:
|
||||
super().__init__("postgresql://unused")
|
||||
self._user_store = user_store
|
||||
self._owners: dict[str, str] = {}
|
||||
self._counter = 0
|
||||
|
||||
def login(self, username: str) -> str:
|
||||
self._counter += 1
|
||||
token = f"session-token-{self._counter}"
|
||||
self._owners[token] = username
|
||||
return token
|
||||
|
||||
def resolve(self, token: str) -> tuple[str, str] | None:
|
||||
username = self._owners.get(token)
|
||||
if username is None:
|
||||
return None
|
||||
role = self._user_store.get_role(username)
|
||||
# a deleted user's sessions are invalid (real store: ON DELETE CASCADE)
|
||||
return (username, role) if role is not None else None
|
||||
|
||||
def logout(self, token: str) -> bool:
|
||||
if token in self._owners:
|
||||
del self._owners[token]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FakeRenameStore(DeviceRenameStore):
|
||||
"""In-memory rename store: records calls; the real SQL is exercised
|
||||
in the e2e against a throwaway postgres."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("postgresql://unused")
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def rename(
|
||||
self,
|
||||
imei: str,
|
||||
new_name: str,
|
||||
from_time: datetime | None,
|
||||
to_time: datetime | None,
|
||||
payload_key: str = "device",
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(
|
||||
{
|
||||
"imei": imei,
|
||||
"device": new_name,
|
||||
"from": from_time,
|
||||
"to": to_time,
|
||||
"payload_key": payload_key,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
)
|
||||
return {"updated": 3, "dry_run": dry_run}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(app_module):
|
||||
"""ASGI app wired to in-memory fakes; yields
|
||||
(app, token_store, imei_store, attributes_store)."""
|
||||
(app, token_store, imei_store, attributes_store, user_store, session_store)."""
|
||||
tokens = FakeTokenStore({"test-app": "secret-abc"})
|
||||
imeis = FakeImeiMapStore(
|
||||
{"350123456789012": {"device": "Greenhouse sensor A"}}
|
||||
)
|
||||
attributes = FakeImeiAttributesStore()
|
||||
app = app_module.create_app(
|
||||
token_store=tokens, imei_store=imeis, attributes_store=attributes
|
||||
users = FakeUserStore(
|
||||
{
|
||||
"admin1": ("admin-pass", "admin"),
|
||||
"super1": ("super-pass", "superuser"),
|
||||
"viewer1": ("viewer-pass", "user"),
|
||||
}
|
||||
)
|
||||
return app, tokens, imeis, attributes
|
||||
sessions = FakeSessionStore(users)
|
||||
renames = FakeRenameStore()
|
||||
app = app_module.create_app(
|
||||
token_store=tokens,
|
||||
imei_store=imeis,
|
||||
attributes_store=attributes,
|
||||
user_store=users,
|
||||
session_store=sessions,
|
||||
rename_store=renames,
|
||||
)
|
||||
return app, tokens, imeis, attributes, users, sessions, renames
|
||||
|
||||
|
||||
class TestApiAuth:
|
||||
@@ -652,7 +933,7 @@ class TestConfigEndpoints:
|
||||
def client_and_stores(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, tokens, imeis, _attributes = api
|
||||
app, tokens, imeis, _attributes, _users, _sessions, _renames = api
|
||||
return TestClient(app), tokens, imeis
|
||||
|
||||
def test_get_imei_map(self, client_and_stores) -> None:
|
||||
@@ -757,7 +1038,7 @@ class TestDeviceEndpoints:
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _, imeis, _attributes = api
|
||||
app, _, imeis, _attributes, _users, _sessions, _renames = api
|
||||
return TestClient(app), imeis
|
||||
|
||||
def test_list_devices(self, client_and_store) -> None:
|
||||
@@ -867,7 +1148,7 @@ class TestTokenEndpoints:
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, tokens, _, _attributes = api
|
||||
app, tokens, _imeis, _attributes, _users, _sessions, _renames = api
|
||||
return TestClient(app), tokens
|
||||
|
||||
def test_list_tokens_masks_secrets(self, client_and_store) -> None:
|
||||
@@ -926,7 +1207,7 @@ class TestDeviceAttributeEndpoints:
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _tokens, _imeis, attributes = api
|
||||
app, _tokens, _imeis, attributes, _users, _sessions, _renames = api
|
||||
return TestClient(app), attributes
|
||||
|
||||
def test_endpoints_require_token(self, client_and_store) -> None:
|
||||
@@ -1087,4 +1368,251 @@ class TestDeviceAttributeEndpoints:
|
||||
r = client.delete(
|
||||
"/devices/350123456789012/attributes/does-not-exist", headers=self.HDRS
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_and_verify_roundtrip(self) -> None:
|
||||
stored = hash_password("secret-pass")
|
||||
assert stored.startswith("scrypt$")
|
||||
assert verify_password("secret-pass", stored)
|
||||
assert not verify_password("wrong-pass", stored)
|
||||
|
||||
def test_hashes_are_salted(self) -> None:
|
||||
# same password, different salts -> different stored hashes
|
||||
assert hash_password("same") != hash_password("same")
|
||||
|
||||
def test_verify_rejects_garbage(self) -> None:
|
||||
assert not verify_password("x", "not-a-hash")
|
||||
assert not verify_password("x", "bcrypt$abc$def")
|
||||
assert not verify_password("x", "")
|
||||
|
||||
|
||||
class TestAuthEndpoints:
|
||||
"""POST /auth/login, GET /auth/me, POST /auth/logout — session flow."""
|
||||
|
||||
HDRS = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_stores(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _tokens, _imeis, _attributes, users, sessions, _renames = api
|
||||
return TestClient(app), users, sessions
|
||||
|
||||
def test_login_returns_token_and_role(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "admin1", "password": "admin-pass"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["token"]
|
||||
assert body["username"] == "admin1"
|
||||
assert body["role"] == "admin"
|
||||
|
||||
def test_login_wrong_password_401(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "admin1", "password": "nope"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json()["detail"] == "invalid username or password"
|
||||
|
||||
def test_login_unknown_user_same_401(self, client_and_stores) -> None:
|
||||
# same body as wrong password: no username enumeration
|
||||
client, _, _ = client_and_stores
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "ghost", "password": "whatever"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json()["detail"] == "invalid username or password"
|
||||
|
||||
def test_login_requires_both_fields(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
assert client.post("/auth/login", json={}).status_code == 422
|
||||
assert (
|
||||
client.post("/auth/login", json={"username": "a"}).status_code == 422
|
||||
)
|
||||
|
||||
def test_lockout_after_failed_attempts(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
for _ in range(5):
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "admin1", "password": "bad"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
# 6th attempt is locked out even with the CORRECT password
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "admin1", "password": "admin-pass"}
|
||||
)
|
||||
assert r.status_code == 429
|
||||
|
||||
def test_me_requires_auth(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
assert client.get("/auth/me").status_code == 401
|
||||
|
||||
def test_me_with_session(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
token = sessions.login("viewer1")
|
||||
r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"username": "viewer1", "role": "user"}
|
||||
|
||||
def test_me_with_app_token_has_no_role(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
r = client.get("/auth/me", headers=self.HDRS)
|
||||
assert r.json() == {"username": None, "role": None}
|
||||
|
||||
def test_logout_invalidates_session(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
token = sessions.login("admin1")
|
||||
r = client.post("/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_invalid_bearer_401(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
r = client.get("/auth/me", headers={"Authorization": "Bearer bogus"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestAuthorizationMatrix:
|
||||
"""Role gating across the API: anonymous / app token / user /
|
||||
superuser / admin, plus the superuser caps."""
|
||||
|
||||
APP = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_stores(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _tokens, _imeis, _attributes, users, sessions, _renames = api
|
||||
return TestClient(app), users, sessions
|
||||
|
||||
def bearer(self, sessions: FakeSessionStore, username: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {sessions.login(username)}"}
|
||||
|
||||
# --- read endpoints: everyone authenticated may read -------------------
|
||||
def test_reads_open_to_all_authenticated(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
for headers in (
|
||||
self.APP,
|
||||
self.bearer(sessions, "viewer1"),
|
||||
self.bearer(sessions, "super1"),
|
||||
self.bearer(sessions, "admin1"),
|
||||
):
|
||||
assert client.get("/devices", headers=headers).status_code == 200
|
||||
assert (
|
||||
client.get(
|
||||
"/devices/350123456789012/attributes", headers=headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/users", headers=headers).status_code == 200
|
||||
assert client.get("/tokens", headers=headers).status_code == 200
|
||||
|
||||
def test_anonymous_gets_401_everywhere(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
for path in ("/devices", "/users", "/tokens", "/messages?limit=1"):
|
||||
assert client.get(path).status_code == 401
|
||||
assert client.put(
|
||||
"/devices/111111111111111", json={"device": "X"}
|
||||
).status_code == 401
|
||||
|
||||
# --- write endpoints: gated at superuser ------------------------------
|
||||
WRITE_CASES = [
|
||||
("/configs/imei_map", "PUT", {"350123456789012": "Renamed"}),
|
||||
("/devices/350123456789012", "PUT", {"device": "Renamed"}),
|
||||
("/devices/350123456789012", "DELETE", None),
|
||||
(
|
||||
"/devices/350123456789012/attributes",
|
||||
"PUT",
|
||||
{"key": "k", "value": 1},
|
||||
),
|
||||
("/devices/350123456789012/attributes/k", "DELETE", None),
|
||||
("/tokens/someapp", "PUT", {"token": "secret"}),
|
||||
("/tokens/someapp", "DELETE", None),
|
||||
("/users/newuser", "PUT", {"password": "pw"}),
|
||||
]
|
||||
|
||||
def test_viewer_role_cannot_write(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "viewer1")
|
||||
for path, method, body in self.WRITE_CASES:
|
||||
r = client.request(method, path, json=body, headers=headers)
|
||||
assert r.status_code == 403, (method, path, r.status_code)
|
||||
|
||||
def test_superuser_and_admin_can_write(self, client_and_stores) -> None:
|
||||
client, users, sessions = client_and_stores
|
||||
for username in ("super1", "admin1"):
|
||||
headers = self.bearer(sessions, username)
|
||||
# device writes are safe (idempotent upsert); use a scratch IMEI
|
||||
r = client.put(
|
||||
"/devices/111111111111111",
|
||||
json={"device": f"by-{username}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, (username, r.status_code)
|
||||
|
||||
def test_app_token_still_writes(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
r = client.put(
|
||||
"/devices/111111111111111",
|
||||
json={"device": "by-app-token"},
|
||||
headers=self.APP,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# --- superuser caps on user management ---------------------------------
|
||||
def test_superuser_creates_only_user_role(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "super1")
|
||||
# creating a plain user is fine
|
||||
r = client.put(
|
||||
"/users/newviewer", json={"password": "pw", "role": "user"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# attempting to create a superuser/admin is refused (no escalation)
|
||||
for role in ("superuser", "admin"):
|
||||
r = client.put(
|
||||
"/users/escalate", json={"password": "pw", "role": role}, headers=headers
|
||||
)
|
||||
assert r.status_code == 403, (role, r.status_code)
|
||||
|
||||
def test_superuser_deletes_only_user_accounts(self, client_and_stores) -> None:
|
||||
client, users, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "super1")
|
||||
# target with role 'user': allowed
|
||||
assert client.delete("/users/viewer1", headers=headers).status_code == 200
|
||||
# target with role 'superuser'/'admin': refused
|
||||
assert client.delete("/users/super1", headers=headers).status_code == 403
|
||||
assert client.delete("/users/admin1", headers=headers).status_code == 403
|
||||
|
||||
def test_admin_manages_any_account(self, client_and_stores) -> None:
|
||||
client, users, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "admin1")
|
||||
r = client.put(
|
||||
"/users/newsuper",
|
||||
json={"password": "pw", "role": "superuser"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert client.delete("/users/newsuper", headers=headers).status_code == 200
|
||||
|
||||
def test_last_admin_cannot_be_deleted(self, client_and_stores) -> None:
|
||||
client, users, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "admin1")
|
||||
# ensure only ONE admin exists (admin1); delete another admin first
|
||||
# if the fixture had one - it doesn't, admin1 is the only admin.
|
||||
assert client.delete("/users/admin1", headers=headers).status_code == 404
|
||||
|
||||
def test_viewer_cannot_manage_users(self, client_and_stores) -> None:
|
||||
client, _, sessions = client_and_stores
|
||||
headers = self.bearer(sessions, "viewer1")
|
||||
r = client.put(
|
||||
"/users/sneaky", json={"password": "pw", "role": "user"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert client.delete("/users/admin1", headers=headers).status_code == 403
|
||||
Reference in New Issue
Block a user