This commit is contained in:
Jonas Bengtsson
2026-09-05 14:22:29 +02:00
parent f9d10364f8
commit 2117f96ae7
20 changed files with 1721 additions and 112 deletions
+537 -9
View File
@@ -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