aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py135
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/chat.py108
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py14
-rw-r--r--packages/meshbay-cli/tests/fake_node.py61
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py45
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py121
7 files changed, 473 insertions, 15 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py
index dbb5a19..dd13d50 100644
--- a/packages/meshbay-cli/src/meshbay_cli/__main__.py
+++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py
@@ -13,6 +13,7 @@ import sys
from . import __version__
from .commands import (
browser_access,
+ chat,
decline,
devices,
get,
@@ -33,7 +34,7 @@ from .errors import INTERRUPTED, USAGE, CliError
ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations,
"join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get,
- "put": put, "rm": rm, "devices": devices, "trust": trust,
+ "put": put, "rm": rm, "chat": chat, "devices": devices, "trust": trust,
"browser-access": browser_access}
@@ -118,6 +119,7 @@ def main(argv: list[str] | None = None) -> int:
return run()
parser = build_parser()
interactive = not argv
+ argv = chat.rewrite_count(argv, ACTIONS)
try:
args = parser.parse_args(argv)
except SystemExit as e: # --help, --version, or a usage error
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
new file mode 100644
index 0000000..bb0c83f
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
@@ -0,0 +1,135 @@
+"""
+A group's chat: reading its history, and posting (docs/MESHBAY_DESIGN.md §4.5).
+
+Every message is sealed under the group's current chat epoch key, through a
+subkey derived for the sending device, and signed by that device's key on the
+node — `meshbay_common.chatbox`, the same construction the application uses
+(`crypto.js` `sealChat`, held to it by the parity tests). The node relays and
+stores ciphertext; it reads nothing.
+
+Reading is done the way the application reads: authorship first — the
+signature is over the ciphertext, so a message that does not verify is never
+shown as written by anyone — then the epoch key, then the content. A message
+that does not open is kept and marked, never dropped: a gap the reader can see
+is honest, a conversation quietly missing a message is not.
+
+Not done here, and done by the application: checking that the signing device
+is one the group's roster evidences for the account the node names (Tier 2,
+design §3.3). The name shown is the one sealed in the message by its sender.
+"""
+
+import time
+from dataclasses import dataclass
+
+from meshbay_common import MNP_VERSION
+from meshbay_common.chatbox import open_message, seal, verify
+from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, unseal
+
+from ..errors import NODE, USAGE, CliError
+from .keys import b64, unb64
+from .session import GroupSession
+
+# The node answers at most this many messages per request.
+PAGE = 200
+# Sealed messages only: MNP 2.0 has no plaintext chat.
+FORMAT_SEALED = 1
+# The node's bound on one message's ciphertext (`MAX_CHAT_CIPHERTEXT`): checked
+# on the sealed bytes, so nothing is sent that it would refuse.
+MAX_CIPHERTEXT = 64 * 1024
+
+
+@dataclass
+class Message:
+ id: int
+ timestamp: float
+ sender: str # the name sealed by its sender, else the node's
+ text: str
+ unreadable: str = "" # why it could not be read: signature, epoch, ...
+
+
+class ChatKeys:
+ """Every epoch key the node gives this member, and the current epoch."""
+
+ def __init__(self, by_epoch: dict[int, bytes], current: int):
+ self.by_epoch = by_epoch
+ self.current = current
+
+
+async def chat_keys(session: GroupSession) -> ChatKeys:
+ resp = await session.ch.request({"type": "chat_keys_req", "v": MNP_VERSION,
+ "group_id": session.group_id}, "chat_keys_resp")
+ try:
+ payload = unseal(session.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp",
+ session.group_id, resp)
+ except Exception as e:
+ # Not "no keys": a reply that does not open is a peer this client cannot
+ # talk to, and reading it as an empty set would show an empty chat.
+ raise CliError(NODE, "The chat keys do not open under the group key.") from e
+ by_epoch = {int(e["epoch"]): bytes(e["key"]) for e in payload.get("epochs") or []}
+ return ChatKeys(by_epoch, int(payload.get("current") or 0))
+
+
+async def history(session: GroupSession, count: int) -> list[dict]:
+ """The last `count` stored messages, oldest first, paging back as needed."""
+ rows: list[dict] = []
+ before = None
+ while len(rows) < count:
+ page = await session.ch.request(
+ {"type": "chat_hist", "v": MNP_VERSION, "before": before,
+ "limit": min(PAGE, count - len(rows))}, "chat_hist_resp")
+ got = page.get("messages") or []
+ rows = got + rows
+ if not got or not page.get("has_more"):
+ break
+ before = got[0]["id"]
+ return rows[-count:]
+
+
+def open_row(row: dict, keys: ChatKeys, group_id: str) -> Message:
+ sender = str(row.get("sender_name") or row.get("sender_id") or "")
+ msg = Message(int(row.get("id") or 0), float(row.get("timestamp") or 0), sender, "")
+ if (row.get("format") or 0) != FORMAT_SEALED:
+ msg.unreadable = "not sealed"
+ return msg
+ epoch = int(row.get("epoch") or 0)
+ device, nonce, ct, sig = (row.get(k) for k in ("device", "nonce", "ct", "sig"))
+ if not (device and nonce and ct and sig):
+ msg.unreadable = "envelope"
+ return msg
+ if not verify(group_id, epoch, bytes(device), bytes(nonce), bytes(ct), bytes(sig)):
+ msg.unreadable = "signature"
+ return msg
+ key = keys.by_epoch.get(epoch)
+ if key is None:
+ msg.unreadable = "no key for its epoch"
+ return msg
+ try:
+ payload = open_message(key, group_id, epoch, b64(bytes(device)), nonce, ct)
+ except Exception:
+ msg.unreadable = "does not decrypt"
+ return msg
+ msg.text = str(payload.get("text") or "")
+ msg.sender = str(payload.get("sender_name") or sender)
+ return msg
+
+
+async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: str) -> None:
+ """Seal, sign and send one message under the current epoch."""
+ epoch = int(session.ack.get("chat_epoch") or keys.current)
+ key = keys.by_epoch.get(epoch)
+ if key is None:
+ raise CliError(NODE, "No chat key for this group's current epoch; try again.")
+ ident = session.identity
+ device_b64 = ident.pk_ed
+ sealed = seal(key, session.group_id, epoch, device_b64, unb64(device_b64), ident.sk_ed,
+ {"text": text, "thread_id": None, "sender_name": sender_name,
+ "sent_at": int(time.time())})
+ if len(sealed["ct"]) > MAX_CIPHERTEXT:
+ raise CliError(USAGE, "This message is too large for chat; send it as a file "
+ "with `put` instead.")
+ await session.ch.request({"type": "chat_msg", "v": MNP_VERSION,
+ "format": FORMAT_SEALED, "epoch": epoch,
+ "ct": sealed["ct"], "nonce": sealed["nonce"],
+ "device": unb64(device_b64), "sig": sealed["sig"],
+ "thread_id": None, "sender_name": None}, "ack")
+
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
new file mode 100644
index 0000000..61e9fa7
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
@@ -0,0 +1,108 @@
+"""
+`chat`: a group's conversation — the last messages, or one more posted.
+
+`chat GROUP` prints the last ten, oldest first; `-N` (as `head -N`) or `-n N`
+asks for another count. `chat GROUP -m TEXT` posts TEXT (`-m -` reads it from
+stdin), sealed and signed as the application seals and signs it. Each message
+is shown with its time and the name its sender sealed in it; one that cannot
+be read is shown as such, never left out.
+"""
+
+import json
+import re
+import sys
+
+from ..client.chat import chat_keys, history, open_row, post
+from ..client.groups import my_groups, open_group
+from ..client.paths import group_arg, handle, resolve_group
+from ..errors import USAGE, CliError
+from ..output import date, err, safe, sentence
+from .context import Context
+
+HELP = "read a group's chat (the last 10 messages, -N for N), or post with -m"
+
+_COUNT = re.compile(r"^-(\d+)$")
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("chat", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". Messages are end-to-end encrypted and signed; the node "
+ "stores what it cannot read.")
+ p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it")
+ p.add_argument("-n", "--last", type=int, default=10, metavar="N",
+ help="how many of the last messages to show (also -N, as -100)")
+ p.add_argument("-m", "--message", metavar="TEXT",
+ help="post TEXT instead of reading; - reads it from stdin")
+
+
+def rewrite_count(argv: list[str], actions) -> list[str]:
+ """`chat -100 GROUP` as `chat --last 100 GROUP`: argparse reads -100 as a
+ negative number, not as an option. Only when the action is `chat`."""
+ action = next((i for i, a in enumerate(argv) if a in actions), None)
+ if action is None or argv[action] != "chat":
+ return argv
+ out = argv[:action + 1]
+ for a in argv[action + 1:]:
+ m = _COUNT.match(a)
+ out += ["--last", m.group(1)] if m else [a]
+ return out
+
+
+def _text(message: str) -> str:
+ text = sys.stdin.read() if message == "-" else message
+ text = text.rstrip("\n")
+ if not text.strip():
+ raise CliError(USAGE, "Nothing to post.")
+ return text
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ if a.last < 1:
+ raise CliError(USAGE, "Ask for one message at least.")
+ text = _text(a.message) if a.message is not None else None
+ await ctx.hub.check_version()
+ account = ctx.account()
+ session = None
+ try:
+ group = resolve_group(await my_groups(account), group_arg(a.group))
+ session = await open_group(account, group)
+ keys = await chat_keys(session)
+ if text is not None:
+ await post(session, keys, text, account.username)
+ if a.json:
+ print(json.dumps({"posted": True, "group": handle(group)}))
+ elif not a.quiet:
+ err(f"Posted to {safe(handle(group))}.")
+ return 0
+ messages = [open_row(r, keys, group["id"]) for r in await history(session, a.last)]
+ finally:
+ if session is not None:
+ await session.close()
+ await account.close()
+
+ if a.json:
+ json.dump([{"id": m.id, "timestamp": m.timestamp, "sender": m.sender,
+ "text": m.text, **({"unreadable": m.unreadable} if m.unreadable
+ else {})} for m in messages],
+ sys.stdout, indent=1, ensure_ascii=False)
+ print()
+ return 0
+ if not messages and not a.quiet:
+ err(f"No message in {safe(handle(group))} yet.")
+ for m in messages:
+ head = f"{date(int(m.timestamp))} {safe(m.sender)}: "
+ if m.unreadable:
+ print(f"{head}[this message cannot be read: {m.unreadable}]")
+ continue
+ lines = safe_lines(m.text)
+ print(head + lines[0])
+ for line in lines[1:]:
+ print(" " * len(head) + line)
+ return 0
+
+
+def safe_lines(text: str) -> list[str]:
+ """A message's lines, each escaped: a line break stays a line break, every
+ other control character is shown, never sent to the terminal."""
+ return [safe(line) for line in text.split("\n")] or [""]
diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py
index 3fa9550..4491896 100644
--- a/packages/meshbay-cli/src/meshbay_cli/manpage.py
+++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py
@@ -45,7 +45,8 @@ DESCRIPTION = r"""
.B meshbay-cli
is a member's view of their MeshBay groups from a terminal: sign in, join and
leave groups, list them and their folders, search them all at once, download,
-upload and delete files, and approve this account's other devices.
+upload and delete files, read and post in a group's chat, and approve this
+account's other devices.
It talks to the hub only to sign in and to find a group's nodes; files go
directly between this machine and the node, end-to-end encrypted, never
through the hub.
@@ -227,6 +228,13 @@ as it is when the owner hosts it with the desktop application.
A run that names a file this account may not delete, or a folder, is refused
before anything is asked or sent.
.PP
+.B chat
+shows the last messages of a group, oldest first, each with its time and the name
+its sender sealed in it, and posts one with
+.BR \-m .
+Messages are encrypted and signed end to end, as the application's are; one this
+machine cannot verify or open is shown as unreadable, never left out.
+.PP
.B browser\-access on
makes this machine leave, on each node it opens a group on, a copy of its keys
sealed under your passphrase, so that a browser can open the group;
@@ -301,6 +309,8 @@ meshbay\-cli ls \-\-json \-R / | jq '.[] | .path'
meshbay\-cli search \-t audio holiday
meshbay\-cli search beach 2024 | head \-1 | xargs \-d '\\n' meshbay\-cli get
meshbay\-cli rm 'Family/documents/draft*.odt'
+meshbay\-cli chat \-100 Family
+echo 'On my way' | meshbay\-cli chat Family \-m \-
meshbay\-cli devices approve Family@bob ZX4P\-7QRT
meshbay\-cli leave \-\-yes Club@carol
.EE
@@ -400,7 +410,7 @@ def render() -> str:
out += [".SH REMOTE PATHS", PATHS.strip(),
".SH INTERACTIVE MODE", INTERACTIVE.strip(),
".SH SESSION, DEVICES AND NODE KEYS", SESSION.strip(),
- ".SH GROUPS: JOINING, LEAVING, DELETING, DEVICES", MEMBERSHIP.strip(),
+ ".SH GROUPS: JOINING, LEAVING, DELETING, CHAT, DEVICES", MEMBERSHIP.strip(),
".SH EXIT STATUS"]
for code, text in sorted(EXIT_CODES.items()):
out += [".TP", f".B {code}", text]
diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py
index 73d6f72..f5fe631 100644
--- a/packages/meshbay-cli/tests/fake_node.py
+++ b/packages/meshbay-cli/tests/fake_node.py
@@ -22,13 +22,20 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from fake_hub import USER_ID
from meshbay_cli.client.keys import Identity
from meshbay_cli.client.session import GroupSession, NodeRefusal
+from meshbay_common import chatbox
from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
from meshbay_common.device import (
device_add_transcript,
device_code_hash,
device_revoke_transcript,
)
-from meshbay_common.groupbox import PURPOSE_INDEX, PURPOSE_UPLOAD, seal, unseal
+from meshbay_common.groupbox import (
+ PURPOSE_CHAT_KEYS,
+ PURPOSE_INDEX,
+ PURPOSE_UPLOAD,
+ seal,
+ unseal,
+)
from meshbay_common.protocol import UPLOAD_PROBE_INDEX, file_upload_ack_wire
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
@@ -59,7 +66,9 @@ class FakeNode:
"pinned_via": "code"}]
self.requests: list[dict] = []
self.role = "" # "operator": this key was paired here
- self.ack: dict = {} # the sealed configuration, opened
+ self.ack: dict = {"chat_epoch": 1} # the sealed configuration, opened
+ self.chat_epochs = {1: secrets.token_bytes(32)}
+ self.chat: list[dict] = [] # stored rows, oldest first
self.admin_ops: dict[str, dict] = {}
def add(self, path: str, name: str, data: bytes, uploader_id: str = "") -> dict:
@@ -77,6 +86,25 @@ class FakeNode:
return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", NODE_PK, self.gek,
dict(self.ack), self.identity, self.nonce, self.role)
+ def say(self, text: str, *, sender: str = "bob-test", epoch: int = 1,
+ forge: bool = False) -> None:
+ """Another member's message, sealed and signed as a client does; `forge`
+ signs it with a key other than the device it names."""
+ other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
+ sealed = chatbox.seal(self.chat_epochs[epoch], GROUP_ID, epoch, other.pk_ed,
+ base64.b64decode(other.pk_ed), other.sk_ed,
+ {"text": text, "sender_name": sender})
+ if forge:
+ sealed["sig"] = Ed25519PrivateKey.generate().sign(b"something else")
+ self._store(base64.b64decode(other.pk_ed), epoch, sealed, "u-" + sender)
+
+ def _store(self, device: bytes, epoch: int, sealed: dict, sender_id: str) -> None:
+ self.chat.append({"id": len(self.chat) + 1, "sender_id": sender_id,
+ "sender_name": "", "timestamp": 1_700_000_000 + len(self.chat),
+ "thread_id": None, "format": 1, "epoch": epoch,
+ "device": device, "nonce": sealed["nonce"], "sig": sealed["sig"],
+ "payload": "", "ct": sealed["ct"]})
+
def request_device(self, code: str) -> Identity:
"""Another machine files a request with `code`, as it would before its proof."""
other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
@@ -199,6 +227,35 @@ class FakeChannel:
dir=folder)
+ # ── Chat ─────────────────────────────────────────────────────────────────
+
+ def _chat_keys_req(self, msg):
+ n = self.node
+ payload = {"epochs": [{"epoch": e, "key": k} for e, k in sorted(n.chat_epochs.items())],
+ "current": max(n.chat_epochs)}
+ return {"type": "chat_keys_resp", "group_id": GROUP_ID,
+ **seal(n.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, payload)}
+
+ def _chat_hist(self, msg):
+ rows = self.node.chat
+ if msg.get("before"):
+ rows = [r for r in rows if r["id"] < msg["before"]]
+ page = rows[-min(int(msg["limit"]), 200):]
+ return {"type": "chat_hist_resp", "messages": page,
+ "has_more": bool(page) and page[0]["id"] > rows[0]["id"]}
+
+ def _chat_msg(self, msg):
+ n = self.node
+ if bytes(msg["device"]) != base64.b64decode(n.identity.pk_ed):
+ return {"type": "error", "detail": "That is not the device on this connection"}
+ if msg["epoch"] != max(n.chat_epochs) or msg["format"] != 1:
+ return {"type": "error", "detail": "stale epoch or plaintext"}
+ if not chatbox.verify(GROUP_ID, msg["epoch"], bytes(msg["device"]), msg["nonce"],
+ msg["ct"], msg["sig"]):
+ return {"type": "error", "detail": "bad signature"}
+ n._store(bytes(msg["device"]), msg["epoch"], msg, USER_ID)
+ return {"type": "ack"}
+
# ── Deleting: a challenge, signed with this machine's key ──────────────
def _file_delete(self, msg):
diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py
index e06fb9c..5443d9b 100644
--- a/packages/meshbay-cli/tests/test_cli_actions.py
+++ b/packages/meshbay-cli/tests/test_cli_actions.py
@@ -510,6 +510,51 @@ def test_search_finds_across_groups_once_per_file(hub, monkeypatch, capsys):
assert main("search", "beach") == 0
+def test_chat_reads_the_last_messages_and_posts(node, monkeypatch, capsys):
+ from meshbay_cli.commands import chat
+
+ async def open_group(account, group):
+ return node.session()
+ monkeypatch.setattr(chat, "open_group", open_group)
+
+ for i in range(1, 231): # more than one page of the node's
+ node.say(f"message {i}")
+ node.say("forged", forge=True)
+ node.say("two lines\nand an \x1b[31m escape")
+ node.chat_epochs[2] = b"\x09" * 32
+ node.say("from an epoch this member was not given", epoch=2)
+ del node.chat_epochs[2]
+
+ assert main("chat", "Family") == 0
+ lines = capsys.readouterr().out.splitlines()
+ assert len(lines) == 11 and lines[0].endswith("bob-test: message 224")
+ assert lines[7].endswith("bob-test: [this message cannot be read: signature]")
+ assert lines[8].endswith("bob-test: two lines")
+ assert lines[9].strip() == "and an \\x1b[31m escape" # shown, never run
+ assert "cannot be read: no key for its epoch" in lines[10]
+
+ assert main("chat", "-5", "Family") == 0
+ assert capsys.readouterr().out.splitlines()[0].endswith("message 229")
+ assert main("--json", "chat", "Family", "-n", "300") == 0 # paged back
+ got = json.loads(capsys.readouterr().out)
+ assert len(got) == 233 and got[0]["text"] == "message 1"
+ assert got[230]["unreadable"] == "signature"
+
+ assert main("chat", "Family", "-m", "hello from the terminal") == 0
+ assert "Posted to Family@alice-test" in capsys.readouterr().err
+ monkeypatch.setattr("sys.stdin", io.StringIO("piped\n"))
+ assert main("chat", "Family", "-m", "-") == 0
+ capsys.readouterr()
+ assert main("chat", "-2", "Family") == 0
+ out = capsys.readouterr().out.splitlines()
+ assert out[0].endswith(f"{USERNAME}: hello from the terminal")
+ assert out[1].endswith(f"{USERNAME}: piped")
+
+ assert main("chat", "Family", "-m", " ") == 1
+ assert main("chat", "Family", "-m", "x" * 70_000) == 1 # over the node's bound
+ assert main("chat", "-0", "Family") == 1
+
+
def test_every_action_is_run_here():
"""Runs last in this module: every action the parser knows was run above."""
assert RUN == set(cli.ACTIONS), f"never run: {set(cli.ACTIONS) - RUN}"
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 8ce3ebc..19f5ca9 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -312,6 +312,27 @@ class App:
assert r.get("detail") == "keypair_bundle_deleted", r
await close()
+ async def revoke_the_others(self) -> None:
+ """Retire every device of the account but this one: the node admits five,
+ and the tests link more machines than that between them."""
+ from meshbay_cli.client.keys import b64
+ from meshbay_common import MNP_VERSION
+ from meshbay_common.device import device_revoke_transcript
+ ch, close, node_pk, nonce_s, account = await self.session()
+ listed = await ch.request({"type": "device_list", "v": MNP_VERSION},
+ "device_list_result")
+ for d in listed["devices"]:
+ if d["pk_ed25519"] == self.ident.pk_ed:
+ continue
+ ts = int(time.time())
+ sig = self.ident.sk_ed.sign(device_revoke_transcript(
+ node_pk_b64=node_pk, user_id=account.user_id,
+ pk_ed25519_b64=d["pk_ed25519"], nonce_node=nonce_s, ts=ts))
+ await ch.request({"type": "device_revoke", "v": MNP_VERSION,
+ "pk_ed25519": d["pk_ed25519"], "ts": ts, "sig": b64(sig)},
+ "device_add_ack")
+ await close()
+
async def approve(self, code: str) -> None:
"""Approve a device by the code it shows, as the group's Settings tab does."""
from meshbay_cli.client.keys import b64
@@ -668,7 +689,9 @@ _LINK_FORM = r"""(() => {
return { form: Boolean(form), button: Boolean(form && form.querySelector('button')),
code: code || '', minted: window.__minted,
error: (document.querySelector('.error-msg') || {}).innerText || '',
- opened: /No messages yet/.test(page), page: page.slice(-400) };
+ // The group open on its Chat tab, the composer ready.
+ opened: Boolean(document.querySelector('.chat-input:not([disabled])')),
+ page: page.slice(-400) };
})()"""
@@ -730,12 +753,10 @@ class _Chrome:
self.profile.cleanup()
-@pytest.mark.skipif(not shutil.which("google-chrome"),
- reason="google-chrome is not available")
-def test_the_application_links_itself_from_its_group_page(world):
+def _session_for_the_page(world: dict) -> dict:
+ """What the page keeps in `mb_auth` after signing in, for the account."""
from meshbay_cli.client.keys import auth_key
- app, hub_url = world["app"], world["hub_url"]
- asyncio.run(app.withdraw_bundle())
+ hub_url = world["hub_url"]
deadline = time.monotonic() + 90
while (r := httpx.post(f"{hub_url}/v1/users/login", json={
"username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)})
@@ -745,12 +766,19 @@ def test_the_application_links_itself_from_its_group_page(world):
tokens = r.json()
me = httpx.get(f"{hub_url}/v1/users/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"}).json()
- auth = {"username": USERNAME, "userId": me["user_id"], "role": "user",
+ return {"username": USERNAME, "userId": me["user_id"], "role": "user",
"token": tokens["access_token"], "refreshToken": tokens.get("refresh_token")}
- chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(auth)))
+
+def _linked_group_page(world: dict) -> "_Chrome":
+ """The group page in Chrome as the desktop application, linked as a new
+ device by its own "Get a linking code" and open. The caller closes it."""
+ app = world["app"]
+ asyncio.run(app.withdraw_bundle())
+ chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(_session_for_the_page(world))))
try:
- chrome.call("Page.navigate", url=f"{hub_url}/#/group/{world['group_id']}")
+ chrome.call("Page.navigate",
+ url=f"{world['hub_url']}/#/group/{world['group_id']}")
# Refused as an unknown device: the form, and nothing shown as an error.
s = chrome.until(_LINK_FORM, lambda s: s["button"])
assert not s["error"], s
@@ -763,9 +791,16 @@ def test_the_application_links_itself_from_its_group_page(world):
s = chrome.until(_LINK_FORM, lambda s: s["opened"] and not s["form"], timeout=60)
assert not s["error"], s
assert s["minted"] == 1, "the identity linked is not the one kept"
- finally:
+ return chrome
+ except BaseException:
chrome.close()
+ raise
+
+@pytest.mark.skipif(not shutil.which("google-chrome"),
+ reason="google-chrome is not available")
+def test_the_application_links_itself_from_its_group_page(world):
+ _linked_group_page(world).close()
# ── A second account: invited, joined, its devices, browser access, leaving ──
@@ -916,3 +951,69 @@ def test_search_finds_a_file_and_get_takes_its_path(world, tmp_path):
assert (tmp_path / "plage de l'ouest.jpg").stat().st_size == 3000
r = cli(world, "search", "no-such-thing-anywhere", home="cli-search")
assert r.returncode == 3 and "Nothing found" in r.stderr
+
+
+def _cli_signed_in(world: dict, home: str) -> None:
+ deadline = time.monotonic() + 90
+ while True:
+ r = cli(world, "--hub", world["hub_url"], "-u", USERNAME, "login",
+ "--password-file", "-", stdin=PASSPHRASE + "\n", home=home)
+ if r.returncode == 0:
+ return
+ assert "Too many attempts" in r.stderr and time.monotonic() < deadline, r.stderr
+ time.sleep(5)
+
+
+@pytest.mark.skipif(not shutil.which("google-chrome"),
+ reason="google-chrome is not available")
+def test_chat_between_the_command_line_and_the_application(world):
+ """Posted here, read on the group page in Chrome; answered there, read here —
+ the Python seal and the page's crypto.js, on a real node."""
+ group = _handle()
+ asyncio.run(world["app"].revoke_the_others()) # room for this machine and the page
+ _cli_signed_in(world, "cli-chat")
+ r = cli(world, "chat", group, "-m", "hello from the terminal 1", home="cli-chat")
+ if r.returncode == 5: # a new device here: approved first
+ found = re.search(r"enter\s+([0-9A-Z]{4}-[0-9A-Z]{4})", r.stderr)
+ assert found, r.stderr
+ code = found.group(1)
+ asyncio.run(world["app"].approve(code))
+ r = cli(world, "chat", group, "-m", "hello from the terminal 1", home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ for i in range(2, 13):
+ r = cli(world, "chat", group, "-m", f"hello from the terminal {i}", home="cli-chat")
+ assert r.returncode == 0, r.stderr
+
+ r = cli(world, "chat", group, home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ lines = r.stdout.splitlines()
+ assert len(lines) == 10
+ assert lines[0].endswith(f"{USERNAME}: hello from the terminal 3")
+ assert lines[-1].endswith(f"{USERNAME}: hello from the terminal 12")
+ r = cli(world, "chat", "-100", group, home="cli-chat")
+ assert len(r.stdout.splitlines()) == 12, r.stdout
+
+ chrome = _linked_group_page(world)
+ try:
+ # The page opens the terminal's messages: signature, epoch key, content.
+ chrome.until("document.body.innerText",
+ lambda text: "hello from the terminal 12" in (text or ""))
+ assert "cannot be read" not in chrome.js("document.body.innerText").lower()
+ chrome.js("""(() => {
+ const box = document.querySelector('.chat-input');
+ box.value = 'hello from the browser';
+ box.dispatchEvent(new Event('input', { bubbles: true }));
+ })()""")
+ chrome.until("!document.querySelector('.chat-send').disabled", bool)
+ chrome.js("document.querySelector('.chat-send').click()")
+ chrome.until("document.querySelector('.chat-input').value === ''", bool)
+ finally:
+ chrome.close()
+
+ deadline = time.monotonic() + 30
+ while True:
+ r = cli(world, "chat", "-1", group, home="cli-chat")
+ if r.stdout.strip().endswith(f"{USERNAME}: hello from the browser"):
+ break
+ assert time.monotonic() < deadline, (r.stdout, r.stderr)
+ time.sleep(1)