feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s

This commit is contained in:
2026-10-08 22:33:29 -04:00
parent 55a9fcc0f4
commit f00a8bea8f
23 changed files with 946 additions and 66 deletions
+157
View File
@@ -8,6 +8,13 @@ import pytest
from backend import file_chat as _store
def _login(client, username="admin", password="chab30") -> dict:
"""Log in against the auth-enabled ``admin_client`` fixture (#191)."""
resp = client.post("/api/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.fixture(autouse=True)
def _isolated_chat_dir(tmp_path, monkeypatch):
"""Keep every test (store AND routes) out of the real ``data/chats``/
@@ -207,3 +214,153 @@ class TestAttachments:
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
assert r.status_code == 200
assert r.json()["message"]["attachment"]["kind"] == "image"
# --- #191 : suppression, messages privés, link preview ----------------------
class TestDelete:
def test_author_deletes_own_post(self, client, test_vault_dir):
msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
r = client.delete(f"/api/chat/{msg['id']}")
assert r.status_code == 200
assert client.get("/api/chat").json()["messages"] == []
def test_delete_unknown_404(self, client, test_vault_dir):
assert client.delete("/api/chat/nope").status_code == 404
def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
client.delete(f"/api/chat/{msg['id']}")
assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
class TestPrivateChat:
"""DM flows need a real logged-in identity (auth enabled)."""
def test_dm_roundtrip(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
assert r.status_code == 200
assert r.json()["message"]["text"] == "salut"
def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
assert r.status_code == 404
def test_dm_to_self_400(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
assert r.status_code == 400
def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat", json={"text": "public"}, headers=h)
admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
gen = admin_client.get("/api/chat", headers=h).json()["messages"]
assert [m["text"] for m in gen] == ["public"]
dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
assert [m["text"] for m in dm] == ["prive"]
def test_dm_history(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
r = admin_client.get("/api/chat/dm/normaluser", headers=h)
assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
def test_dm_delete(self, admin_client, test_vault_dir):
h = _login(admin_client)
msg = admin_client.post(
"/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
).json()["message"]
r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
assert r.status_code == 200
assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
def test_requires_auth(self, admin_client, test_vault_dir):
assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
assert admin_client.get("/api/chat/users").status_code == 401
def test_user_list_for_picker(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.get("/api/chat/users", headers=h)
assert r.status_code == 200
names = [u["username"] for u in r.json()]
assert "normaluser" in names
assert "admin" not in names # oneself is not a DM target
assert all("password" not in u and "hash" not in str(u) for u in r.json())
class TestLinkPreview:
def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(_store, "build_preview", lambda text: None)
msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
# the schema always serialises the key (null) — it must stay falsy
assert not msg.get("preview")
def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(
_store, "build_preview",
lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
)
msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
assert msg["preview"]["title"] == "Titre"
def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(
_store, "build_preview",
lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
)
client.post("/api/chat", json={"text": "https://y.test"})
got = client.get("/api/chat").json()["messages"]
assert got[-1]["preview"]["site"] == "y.test"
def test_build_preview_rejects_non_http(self):
assert _store.build_preview("javascript:alert(1)") is None
assert _store.build_preview("aucune URL") is None
def test_build_preview_blocks_private_hosts(self, monkeypatch):
# SSRF guard rejects loopback before any network round-trip
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
assert _store.build_preview("http://127.0.0.1/x") is None
def test_build_preview_happy_path_parses_og(self, monkeypatch):
"""The fetch itself (a bad kwarg here used to fail silently)."""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
# the SSRF guard resolves DNS for real — out of scope for this test
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Resp:
status_code = 200
text = (
"<html><head><title>Fallback</title>"
'<meta property="og:title" content="Titre OG">'
'<meta property="og:description" content="Desc">'
'<meta property="og:site_name" content="Site">'
"</head></html>"
)
called = {}
def _fake_get(url, **kw):
called["url"] = url
return _Resp()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://example.test/page")
assert called["url"] == "https://example.test/page"
assert preview == {
"url": "https://example.test/page",
"title": "Titre OG",
"description": "Desc",
"image": "",
"site": "Site",
}