🚧 Add message delete
This commit is contained in:
@@ -22,10 +22,12 @@ from api.app import ( # noqa: E402
|
||||
DeviceRenameStore,
|
||||
ImeiAttributesStore,
|
||||
ImeiMapStore,
|
||||
MessageRetentionPolicy,
|
||||
SessionStore,
|
||||
TokenStore,
|
||||
UserStore,
|
||||
_validate_attribute_body,
|
||||
_validate_id_list,
|
||||
_validate_rename_body,
|
||||
_validate_token_body,
|
||||
hash_password,
|
||||
@@ -1053,6 +1055,217 @@ class TestMessageImport:
|
||||
)
|
||||
assert r.status_code == 503 # validation passed; fails on the DB
|
||||
|
||||
|
||||
class TestValidateIdList:
|
||||
"""_validate_id_list: {"ids": [...]} body for delete/restore."""
|
||||
|
||||
def _validate(self, data: object) -> list[int]:
|
||||
from api.app import _validate_id_list
|
||||
|
||||
return _validate_id_list(data, "request body")
|
||||
|
||||
def test_valid_list(self) -> None:
|
||||
assert self._validate({"ids": [1, 5, 9]}) == [1, 5, 9]
|
||||
|
||||
def test_rejects_non_object(self) -> None:
|
||||
with pytest.raises(ValueError, match="ids"):
|
||||
self._validate([1, 2, 3])
|
||||
|
||||
def test_rejects_missing_ids(self) -> None:
|
||||
with pytest.raises(ValueError, match="ids"):
|
||||
self._validate({})
|
||||
|
||||
def test_rejects_empty_list(self) -> None:
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
self._validate({"ids": []})
|
||||
|
||||
def test_rejects_non_int_entries(self) -> None:
|
||||
with pytest.raises(ValueError, match="ids\[2\] must be an integer"):
|
||||
self._validate({"ids": [1, 2, "three"]})
|
||||
|
||||
def test_rejects_booleans(self) -> None:
|
||||
# bool is an int subclass - must still be rejected explicitly
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
self._validate({"ids": [True]})
|
||||
|
||||
def test_rejects_oversized_list(self) -> None:
|
||||
with pytest.raises(ValueError, match="too many"):
|
||||
self._validate({"ids": list(range(10001))})
|
||||
|
||||
|
||||
class FakeRetentionPolicy(MessageRetentionPolicy):
|
||||
"""In-memory retention policy: records calls; the real CTE moves are
|
||||
exercised in the e2e against a throwaway postgres."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("postgresql://unused")
|
||||
self.deleted: list[tuple[list[int], str | None]] = []
|
||||
self.restored: list[list[int]] = []
|
||||
|
||||
def delete(self, ids: list[int], deleted_by: str | None) -> int:
|
||||
self.deleted.append((list(ids), deleted_by))
|
||||
return len(ids)
|
||||
|
||||
def restore(self, ids: list[int]) -> int:
|
||||
self.restored.append(list(ids))
|
||||
return len(ids)
|
||||
|
||||
def count(self) -> int:
|
||||
return 3
|
||||
|
||||
def list(self, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
|
||||
return (
|
||||
[
|
||||
{
|
||||
"id": 7,
|
||||
"received_at": "2026-09-06T12:00:00+00:00",
|
||||
"topic": "/vivab/350123456789012",
|
||||
"imei": "350123456789012",
|
||||
"device": "Dev",
|
||||
"payload": {"temp": 1},
|
||||
"payload_time": None,
|
||||
"deleted_at": "2026-09-06T13:00:00+00:00",
|
||||
"deleted_by": "admin1",
|
||||
"expires_at": "2026-10-06T13:00:00+00:00",
|
||||
}
|
||||
],
|
||||
3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def retention_api(app_module):
|
||||
"""App wired with the fake retention policy AND the fake user stores
|
||||
(session users resolve their role from the store); yields (app, policy)."""
|
||||
policy = FakeRetentionPolicy()
|
||||
users = FakeUserStore(
|
||||
{
|
||||
"admin1": ("admin-pass", "admin"),
|
||||
"viewer1": ("viewer-pass", "user"),
|
||||
}
|
||||
)
|
||||
app = app_module.create_app(
|
||||
token_store=FakeTokenStore({"test-app": "secret-abc"}),
|
||||
imei_store=FakeImeiMapStore({}),
|
||||
retention_policy=policy,
|
||||
user_store=users,
|
||||
session_store=FakeSessionStore(users),
|
||||
)
|
||||
return app, policy
|
||||
|
||||
|
||||
class TestRetentionEndpoints:
|
||||
"""POST /messages/delete, GET /messages/retention(+count), POST
|
||||
/messages/retention/restore — auth, role gating, body validation,
|
||||
deleted_by plumbing. The CTE move semantics live in the e2e."""
|
||||
|
||||
APP = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_policy(self, retention_api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, policy = retention_api
|
||||
return TestClient(app), policy
|
||||
|
||||
def test_delete_requires_token(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
assert (
|
||||
client.post("/messages/delete", json={"ids": [1]}).status_code == 401
|
||||
)
|
||||
|
||||
def test_delete_viewer_role_forbidden(self, client_and_policy) -> None:
|
||||
client, policy = client_and_policy
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "viewer1", "password": "viewer-pass"}
|
||||
)
|
||||
token = r.json()["token"]
|
||||
r = client.post(
|
||||
"/messages/delete",
|
||||
json={"ids": [1]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert policy.deleted == [] # nothing reached the store
|
||||
|
||||
def test_delete_session_user_recorded(self, client_and_policy) -> None:
|
||||
client, policy = client_and_policy
|
||||
r = client.post(
|
||||
"/auth/login", json={"username": "admin1", "password": "admin-pass"}
|
||||
)
|
||||
token = r.json()["token"]
|
||||
r = client.post(
|
||||
"/messages/delete",
|
||||
json={"ids": [11, 12]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "deleted", "deleted": 2}
|
||||
# the session username is recorded as deleted_by
|
||||
assert policy.deleted == [([11, 12], "admin1")]
|
||||
|
||||
def test_delete_empty_ids_rejected(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
r = client.post("/messages/delete", json={"ids": []}, headers=self.APP)
|
||||
assert r.status_code == 422
|
||||
assert "empty" in r.json()["detail"]
|
||||
|
||||
def test_delete_bad_body_rejected(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
r = client.post("/messages/delete", json={"ids": "nope"}, headers=self.APP)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_delete_app_token_records_null_user(self, client_and_policy) -> None:
|
||||
client, policy = client_and_policy
|
||||
r = client.post("/messages/delete", json={"ids": [4, 8]}, headers=self.APP)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "deleted", "deleted": 2}
|
||||
assert policy.deleted == [([4, 8], None)]
|
||||
|
||||
def test_retention_count(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
r = client.get("/messages/retention/count", headers=self.APP)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"count": 3}
|
||||
|
||||
def test_retention_list_shape(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
r = client.get("/messages/retention", headers=self.APP)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 3
|
||||
assert body["limit"] == 100
|
||||
assert body["offset"] == 0
|
||||
row = body["items"][0]
|
||||
assert row["id"] == 7
|
||||
assert row["deleted_by"] == "admin1"
|
||||
assert "expires_at" in row
|
||||
|
||||
def test_restore_requires_token(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
assert (
|
||||
client.post(
|
||||
"/messages/retention/restore", json={"ids": [1]}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
def test_restore_app_token_works(self, client_and_policy) -> None:
|
||||
client, policy = client_and_policy
|
||||
r = client.post(
|
||||
"/messages/retention/restore", json={"ids": [7]}, headers=self.APP
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "restored", "restored": 1}
|
||||
assert policy.restored == [[7]]
|
||||
|
||||
def test_restore_bad_body_rejected(self, client_and_policy) -> None:
|
||||
client, _ = client_and_policy
|
||||
r = client.post(
|
||||
"/messages/retention/restore", json={"ids": {}}, headers=self.APP
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_request_reaches_db(self, api) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -1799,6 +2012,8 @@ class TestAuthorizationMatrix:
|
||||
"POST",
|
||||
{"imei": "350123456789012", "device": "X", "payload": {}},
|
||||
),
|
||||
("/messages/delete", "POST", {"ids": [1]}),
|
||||
("/messages/retention/restore", "POST", {"ids": [1]}),
|
||||
]
|
||||
|
||||
def test_viewer_role_cannot_write(self, client_and_stores) -> None:
|
||||
|
||||
Reference in New Issue
Block a user