diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 12:16:51 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-09 12:17:38 +0200 |
| commit | 4ae61dc4e1dcca6ae24131fcd9acc55124e3c0af (patch) | |
| tree | 44fd15f6175ade9b0b884f846dfd9d66e2b3bc80 /packages/meshbay-node | |
| parent | 6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 (diff) | |
| download | meshbay-4ae61dc4e1dcca6ae24131fcd9acc55124e3c0af.tar.gz | |
feat: let the operator purge a group's chat (MNP 6.1)
Signed chat_purge from the Chat settings deletes every stored message;
epoch keys and attachments stay. The ack is broadcast so open chat
panels empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
8 files changed, 604 insertions, 37 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py index 17cd68e..9e07aff 100644 --- a/packages/meshbay-node/src/meshbay_node/chat/store.py +++ b/packages/meshbay-node/src/meshbay_node/chat/store.py @@ -289,6 +289,12 @@ class ChatStore: await self._db.commit() return cursor.rowcount + async def delete_all(self) -> int: + """The operator's purge. Returns how many rows went.""" + cursor = await self._db.execute("DELETE FROM messages") + await self._db.commit() + return cursor.rowcount + async def message_count(self) -> int: cursor = await self._db.execute("SELECT COUNT(*) FROM messages") row = await cursor.fetchone() diff --git a/packages/meshbay-node/src/meshbay_node/ops/__init__.py b/packages/meshbay-node/src/meshbay_node/ops/__init__.py index bfdfd7b..fc5b7ef 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/ops/__init__.py @@ -41,6 +41,7 @@ from meshbay_node.ops.chat import ( ensure_chat_epoch, open_chat_epoch, prune_chat, + purge_chat, ) from meshbay_node.ops.core import ( OpError, @@ -140,6 +141,7 @@ __all__ = [ "pair_operator", "plug_root", "prune_chat", + "purge_chat", "prune_index_cache", "read_denylist", "read_roster", diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py index 539284b..9fc7b94 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/chat.py +++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py @@ -252,3 +252,22 @@ async def prune_chat(state: dict, group_id: str, max_age_days: int) -> dict: group_id[:8], removed) return {"group_id": group_id, "removed": removed, "max_age_days": max_age_days} + + +async def purge_chat(state: dict, group_id: str) -> dict: + """ + Delete every stored message of the group. Epoch keys are kept, as in + `prune_chat`, and attachments too: they are files in a shared folder. + + The replay index goes with the rows, which is harmless: the node takes a + sealed message only from the device that signed it, so the one party able + to send a purged message again is its author. + """ + ctx = _group_ctx(state, group_id) + store = ctx.get("chat_store") + if store is None: + raise OpError("This group has no chat store", status=404) + removed = await store.delete_all() + log.info("Chat purged for group %s: %d message(s) removed", + group_id[:8], removed) + return {"group_id": group_id, "removed": removed} diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py index 48734ab..7b3b1c9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py @@ -15,6 +15,7 @@ from meshbay_common.adminop import ( OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, + OP_CHAT_PURGE, OP_DIR_DELETE, OP_FILE_DELETE, OP_INVITE_CANCEL, @@ -68,6 +69,7 @@ _ADMIN_EXECUTORS = { OP_CHAT_LINK_PREVIEW: "_admin_exec_chat_link_preview", OP_SEARCH_LISTED: "_admin_exec_search_listed", OP_CHAT_EPOCH: "_admin_exec_chat_epoch", + OP_CHAT_PURGE: "_admin_exec_chat_purge", OP_ROOT_EJECT: "_admin_exec_root_eject", OP_ROOT_PLUG: "_admin_exec_root_plug", } diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py index dc43ae2..f200884 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py @@ -7,7 +7,12 @@ import time import blake3 from meshbay_common import MNP_VERSION -from meshbay_common.adminop import OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW +from meshbay_common.adminop import ( + OP_CHAT_DIRECTORY, + OP_CHAT_EPOCH, + OP_CHAT_LINK_PREVIEW, + OP_CHAT_PURGE, +) from meshbay_common.chatbox import NONCE_LEN as CHAT_NONCE_LEN from meshbay_common.chatbox import SIG_LEN as CHAT_SIG_LEN from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal @@ -227,6 +232,40 @@ class ChatMixin: self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION, "epoch": result["epoch"]}) + def _do_chat_purge(self, msg: dict) -> None: + """ + Delete the group's stored chat. Operator only, and signed. The subject is + the group id, so a signature for one group's purge purges no other. + + Always this connection's group, never one named in the message: the ack + is broadcast to this group, and an operator purges the chat they see. + """ + group_id = self._group_id + if not group_id: + self._send({"type": "error", "detail": "No group on this connection"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_CHAT_PURGE, group_id, group_id=group_id) + + async def _admin_exec_chat_purge( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"chat_purge:{pending['subject'][:8]}") + return + try: + result = await self._run_op(ops.purge_chat, pending["subject"]) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_purge", str(result["removed"])) + # Every open chat panel empties, not only the operator's. + self._broadcast_to_group({"type": MNP.CHAT_PURGE_ACK, + "v": MNP_VERSION, "removed": result["removed"]}) + async def _do_chat_keys_req(self, msg: dict) -> None: """ Hand this member every chat epoch key the group has, sealed. diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py index a8b5767..5d4ed94 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py @@ -56,6 +56,7 @@ _HANDLERS = { MNP.CHAT_LINK_PREVIEW: ("_do_chat_link_preview", INLINE), MNP.SEARCH_LISTED: ("_do_search_listed", INLINE), MNP.CHAT_EPOCH: ("_do_chat_epoch", INLINE), + MNP.CHAT_PURGE: ("_do_chat_purge", INLINE), MNP.CHAT_KEYS_REQ: ("_do_chat_keys_req", SPAWNED), MNP.GROUP_ROSTER_REQ: ("_do_group_roster_req", SPAWNED), MNP.MEDIA_META_REQ: ("_do_media_meta_request", SPAWNED), diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json index 44d030a..a39c57d 100644 --- a/packages/meshbay-node/tests/golden/dispatch.json +++ b/packages/meshbay-node/tests/golden/dispatch.json @@ -60,6 +60,14 @@ "_admin_exec_chat_link_preview" ] }, + "chat_purge": { + "audit": [], + "log": [], + "sent": [], + "spawned": [ + "_admin_exec_chat_purge" + ] + }, "dir_delete": { "audit": [], "log": [], @@ -2012,7 +2020,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2335,7 +2343,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2354,7 +2362,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2373,7 +2381,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2392,7 +2400,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2662,7 +2670,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2676,7 +2684,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2690,7 +2698,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2704,7 +2712,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2718,7 +2726,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2732,7 +2740,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2746,7 +2754,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -2760,7 +2768,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -3817,6 +3825,386 @@ ], "spawned": [] }, + "chat_purge | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | member | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | member | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | member | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | member | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "No authorized key for this", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge | operator | bare": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "gggggggggggggggggggggggggggggggg", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "<volatile>", + "op": "chat_purge", + "op_id": "<volatile>", + "req_id": 4242, + "subject": "gggggggggggggggggggggggggggggggg", + "ts": "<volatile>", + "type": "admin_challenge", + "v": "6.1" + } + ], + "spawned": [] + }, + "chat_purge | operator | lists": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "gggggggggggggggggggggggggggggggg", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "<volatile>", + "op": "chat_purge", + "op_id": "<volatile>", + "req_id": 4242, + "subject": "gggggggggggggggggggggggggggggggg", + "ts": "<volatile>", + "type": "admin_challenge", + "v": "6.1" + } + ], + "spawned": [] + }, + "chat_purge | operator | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "gggggggggggggggggggggggggggggggg", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "<volatile>", + "op": "chat_purge", + "op_id": "<volatile>", + "req_id": 4242, + "subject": "gggggggggggggggggggggggggggggggg", + "ts": "<volatile>", + "type": "admin_challenge", + "v": "6.1" + } + ], + "spawned": [] + }, + "chat_purge | operator | strings": { + "audit": [], + "log": [], + "sent": [ + { + "group_id": "gggggggggggggggggggggggggggggggg", + "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=", + "nonce": "<volatile>", + "op": "chat_purge", + "op_id": "<volatile>", + "req_id": 4242, + "subject": "gggggggggggggggggggggggggggggggg", + "ts": "<volatile>", + "type": "admin_challenge", + "v": "6.1" + } + ], + "spawned": [] + }, + "chat_purge_ack | challenged | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | challenged | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | challenged | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | challenged | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | fresh | bare": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | fresh | lists": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | fresh | numbers": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | fresh | strings": { + "audit": [], + "log": [], + "sent": [ + { + "detail": "Handshake required", + "req_id": 4242, + "type": "error" + } + ], + "spawned": [] + }, + "chat_purge_ack | member | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | member | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | member | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | member | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | operator | bare": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | operator | lists": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | operator | numbers": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, + "chat_purge_ack | operator | strings": { + "audit": [], + "log": [ + "WARNING Unknown MNP message type on DataChannel: %s" + ], + "sent": [], + "spawned": [] + }, "client_diag | challenged | bare": { "audit": [], "log": [], @@ -9771,7 +10159,7 @@ "subject": "link:gggggggggggggggggggggggggggggggg", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -11870,7 +12258,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -11889,7 +12277,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -11908,7 +12296,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13009,7 +13397,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13024,7 +13412,7 @@ "x" ], "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13037,7 +13425,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13050,7 +13438,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13063,7 +13451,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13078,7 +13466,7 @@ "x" ], "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13091,7 +13479,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13104,7 +13492,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13439,7 +13827,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13458,7 +13846,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13477,7 +13865,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13812,7 +14200,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13831,7 +14219,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -13850,7 +14238,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -14185,7 +14573,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -14204,7 +14592,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -14223,7 +14611,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -16850,7 +17238,7 @@ "subject": "{\"language\":null,\"token\":null}", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] @@ -16893,7 +17281,7 @@ "subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}", "ts": "<volatile>", "type": "admin_challenge", - "v": "6.0" + "v": "6.1" } ], "spawned": [] diff --git a/packages/meshbay-node/tests/test_chat_purge.py b/packages/meshbay-node/tests/test_chat_purge.py new file mode 100644 index 0000000..37bd954 --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_purge.py @@ -0,0 +1,110 @@ +""" +The operator's chat purge (`chat_purge`, MNP 6.1): every stored message of the +group goes, signed like every operator instruction, and every connected member +is told so their open chat empties. + +Driven through `_do_admin_response`, as in `test_admin_ops_mnp.py`: the node +rebuilds the transcript from the operation it holds, and skipping that would +test nothing. +""" + +import base64 + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_CHAT_PURGE, admin_transcript +from meshbay_node import ops +from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore +from meshbay_node.roster import open_roster +from test_admin_ops_mnp import GROUP, _drain, _last, _session + + +@pytest.fixture +async def roster(tmp_path): + r = await open_roster(tmp_path) + yield r + await r.close() + + +async def _with_chat(tmp_path, roster, *, operator=True, messages=2): + session = await _session(tmp_path, roster, operator=operator) + store = ChatStore(tmp_path / "chat.db") + await store.open() + for i in range(messages): + await store.save_message( + "grenet", 0, b"ct", format=FORMAT_SEALED_V1, epoch=1, + device=b"d" * 32, nonce=bytes([i]) * 12, sig=b"s" * 64) + session.state["groups_ctx"][GROUP]["chat_store"] = store + # A second member connected to the group, to see the broadcast arrive. + other = [] + session.state["groups_ctx"][GROUP]["_peers"] = { + "grenet": session, "bob": type("Peer", (), {"_send": other.append})()} + return session, store, other + + +def _sign(session, challenge, sk=None): + transcript = admin_transcript( + op=OP_CHAT_PURGE, node_pk_b64=session._node_pk_b64(), group_id=GROUP, + subject=challenge["subject"], nonce=base64.b64decode(challenge["nonce"]), + ts=challenge["ts"]) + sig = (sk or session.sk_op).sign(transcript) + session._do_admin_response({"op_id": challenge["op_id"], + "signature": base64.b64encode(sig).decode()}) + + +async def test_a_signed_purge_empties_the_chat_and_tells_the_group(tmp_path, roster): + session, store, other = await _with_chat(tmp_path, roster) + + session._do_chat_purge({}) + challenge = _last(session) + assert challenge["type"] == "admin_challenge" + assert challenge["op"] == OP_CHAT_PURGE and challenge["subject"] == GROUP + _sign(session, challenge) + await _drain(session) + + assert await store.message_count() == 0 + assert other[-1]["type"] == "chat_purge_ack" and other[-1]["removed"] == 2 + assert _last(session)["type"] == "chat_purge_ack" + await store.close() + + +async def test_the_purge_names_the_connection_group_and_no_other(tmp_path, roster): + """A group named in the message is not the one purged: the ack goes to this + connection's group, and so does the operation.""" + session, store, _ = await _with_chat(tmp_path, roster) + + session._do_chat_purge({"group_id": "h" * 32}) + + assert _last(session)["subject"] == GROUP + await store.close() + + +async def test_a_signature_from_a_non_operator_key_purges_nothing(tmp_path, roster): + session, store, other = await _with_chat(tmp_path, roster) + + session._do_chat_purge({}) + _sign(session, _last(session), sk=Ed25519PrivateKey.generate()) + await _drain(session) + + assert _last(session)["type"] == "error" + assert await store.message_count() == 2 + assert other == [] + await store.close() + + +async def test_no_challenge_without_an_operator(tmp_path, roster): + session, store, _ = await _with_chat(tmp_path, roster, operator=False) + + session._do_chat_purge({}) + + assert _last(session)["type"] == "error" + assert session._admin_ops == {} + await store.close() + + +async def test_a_group_without_a_chat_store_says_so(tmp_path, roster): + session = await _session(tmp_path, roster, operator=True) + + with pytest.raises(ops.OpError) as e: + await ops.purge_chat(session.state, GROUP) + assert e.value.status == 404 |