summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_chat_send.py
blob: 250c9a3ac34444022e10100dd1beeea090f94afc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
"""
Sending a chat message must come back — and must go out encrypted.

The node answers a chat message with a bare `{"type": "ack"}` — no request id,
no type of its own — so `_dispatch` had nothing to match it on and left it to
the arrival-order guess at the end of the function. That guess is wrong as soon
as anything else this browser asked for is still waiting: the ack was handed to
*that* request, and the send waited out `_sendAndWait`'s 30s timeout. Since the
composer is disabled while a send is in flight, the Chat tab stopped taking
clicks and keys, the message never appeared — and it was there on the next
visit, because the node had stored it and answered.

An outstanding request is the ordinary case, not a rare one: the node refuses
an unknown file_id with a bare `error`, which names no request either, so a
Videos tab that asked about a file the index no longer has leaves a
`media_meta_req` in `_pending` for a full 30s.

None of that is visible in `chat-app.js`, where every line is correct, so this
drives the real panel over the real transport in a browser rather than reading
either source.

Extended for MNP 2.0, where a send seals and signs before it goes anywhere.
That turned out to matter twice on its first run:

  * `chat_keys_resp` answers a `chat_keys_req` under a different type string,
    so it fell through to the arrival-order guess and was handed to the very
    `media_meta_req` this probe leaves outstanding — the original defect, one
    feature later, in a message type that did not exist when it was written.
  * `_asText` had been deleted along with an unrelated helper beside it. Its
    only caller is inside `_openChatMessage`, whose rejection the panel
    swallows, so the whole conversation rendered empty with nothing in the
    console and the node answering perfectly.

Neither is visible in any source file, and neither would have been caught by a
test that reads one.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path

import pytest

HARNESS = Path(__file__).parent / "harness" / "chat_send_probe.py"
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"

pytestmark = pytest.mark.skipif(
    shutil.which("google-chrome") is None or not (STATIC / "chat-app.js").exists(),
    reason="Chrome or the SPA sources are not available")


@pytest.fixture(scope="module")
def probe():
    # `sys.executable`, not a bare "python3": the harness now imports
    # `meshbay_common` to seal the chat keys the way the node does, and the
    # system interpreter has neither that nor msgpack. The other probes get
    # away with "python3" because they import nothing from this project.
    run = subprocess.run([sys.executable, str(HARNESS)],
                         capture_output=True, timeout=180)
    assert run.returncode == 0, run.stderr.decode()[-2000:]
    data = json.loads(run.stdout.decode())
    return data, {s["label"]: s for s in data["steps"]}


def test_the_composer_comes_back(probe):
    """The one thing a person sees: the tab is usable again."""
    _, steps = probe
    assert steps["stale request pending"]["composerDisabled"] is False, (
        "the composer was already unusable before the send")
    assert steps["after send"]["composerDisabled"] is False, (
        "the composer is still disabled well inside the 30s request timeout -- "
        "the send never came back, which is what reads as a frozen Chat tab")


def test_the_message_is_displayed(probe):
    """A sent message appears at once, not on the next visit to the tab."""
    _, steps = probe
    before = steps["stale request pending"]["bubbles"]
    assert steps["after send"]["bubbles"] == before + 1, (
        "the message was not added to the conversation")
    assert steps["after send"]["lastText"] == "hello"
    assert steps["after send"]["composerValue"] == "", (
        "the text came back into the composer, so the send was treated as failed")


def test_the_ack_is_not_handed_to_another_request(probe):
    """The other half of the same defect: whatever was waiting got the ack and
    carried on with a reply to a question it never asked."""
    data, _ = probe
    assert "media_meta resolved with ack" not in data["log"], (
        "the chat ack was routed to the pending media_meta_req -- that request "
        "now believes it has an answer, and the chat send is waiting for a "
        "reply that already arrived")


def test_the_message_goes_out_sealed_and_signed(probe):
    """
    What actually left the browser. A composer that let a plaintext message
    through would be refused by the node, but the refusal arrives after the
    fact and reads as "the message did not send" — so assert the shape here,
    where the reason is visible.
    """
    data, _ = probe
    sent = [line for line in data["log"] if line.startswith("chat_msg ")]
    assert sent, ("no chat_msg reached the stand-in node — the send did not "
                  f"complete. log: {data['log']}")
    assert "format=1" in sent[0], "the message was not sealed"
    assert "sig=64" in sent[0], "the message was not signed"
    assert "ct=" in sent[0] and "ct=0" not in sent[0], "there was no ciphertext"
    assert "plaintextLeak=false" in sent[0], (
        "the text the person typed appears somewhere in the message that went "
        "on the wire")


def test_the_chat_keys_answer_is_not_handed_to_another_request(probe):
    """
    The original defect's shape, in the message type that carries the group's
    chat keys. An unanswered request is the ordinary case, not a rare one, and
    the one this probe leaves outstanding swallowed the keys on the first run.
    """
    data, _ = probe
    assert not any("media_meta resolved with chat_keys_resp" in line
                   for line in data["log"]), (
        "chat_keys_resp was routed by arrival order and handed to the stale "
        "media_meta_req — the send then waits out its own 30s timeout")