Skip to content

Commit db8f71a

Browse files
igor-sirotinclaude
andauthored
test(functional): transport-hardening regression tests (#7472) (#7473)
* test(functional): add transport-hardening regression tests Establish coverage for behaviors affected by the upcoming go-waku → logos-delivery transport cutover (#7472), on the current go-waku transport: - store/offline-to-online history sync (community messages) - delivery confirmation (online and after recipient offline) - multi-device message and edit sync - profile (display-name and status) sync to contacts - ephemeral messages (not persisted by the store node) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(functional): harden transport tests after local validation Ran the suite locally against the go-waku fleet and fixed issues found: - store-history: switch from community to a private group (community join is unreliable on a single local fleet) and pause long enough to drop the libp2p connection so messages are genuinely missed and recovered on reconnect rather than replayed from the socket buffer. - ephemeral: pause long enough to drop the connection so the ephemeral status is genuinely missed while the persisted control message is backfilled from the store. - delivery: wait for the delivered signal post-hoc by message id to avoid matching delivery signals from the make_contacts exchange. - multi-device: focus on own-message propagation and edit sync to the paired device (an external contact only delivers to known installations), and wait for the second device to be online before asserting. All five files pass locally (8 tests). Lint clean: black, flake8, pyright. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(functional): assert delivered status with a single read The outgoing status is persisted just before the message.delivered signal fires (same loop in markDeliveredMessages), so once the test has observed the signal a single read of outgoingStatus is sufficient — drop the poll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(functional): test store history sync via logout/login History fetch is a status-go feature: on login it requests missed history from the store node for all chats. Drive the store-backfill test through a full logout/login instead of node_pause (which froze the process and left a murky reconnect state, and recovered private-group messages via MVDS rather than the store). The receiver logs out, the sender publishes private-group messages, and on the receiver's next login status-go backfills all of them from the store. Verified locally (passes repeatably). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: fleet expose rest and debug logs * feat(functional-tests): LogosDelivery REST client * fix: improve TestStoreHistorySync * fix(functional-tests): expose store REST on an ephemeral host port Static host ports (8645/8646) would collide on shared CI hosts when multiple fleets run concurrently. Publish the store node's nwaku REST API on an ephemeral host port instead and discover it at runtime via the Docker API in LogosDeliveryClient, mirroring how clients/anvil.py resolves anvil's port. Also drop the boot-1 debug-only mapping and switch the store-sync test log to an f-string (lazy %-format tripped pytest live-log). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(tests-functional): document disabling Rosetta on macOS On Apple Silicon the amd64-only nwaku fleet runs under emulation; with Rosetta the go-libp2p <-> nim-libp2p Noise handshake fails (chacha20poly1305 / decryptWithAd auth errors) and app nodes can't reach boot-1/store, so fleet-dependent tests fail. Document the symptom and the fix (disable Rosetta emulation in Docker Desktop) under Known issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(functional-tests): make logout idempotent for already-stopped nodes A node that is already logged out (e.g. a test that logs out explicitly, or a failed login) returns "there is no running node" on Logout. Teardown only caught ReadTimeout, so this turned otherwise-clean runs into errors on CI. Treat logout of an already-logged-out node as a successful no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(functional): promote StatusType codes to shared enum Move the duplicated StatusUpdate StatusType values out of test_transport_ephemeral_messages.py into resources/enums.py so both the ephemeral and profile-sync tests reference a single StatusType enum instead of bare magic numbers. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 57f733d commit db8f71a

10 files changed

Lines changed: 610 additions & 4 deletions

tests-functional/README.MD

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,32 @@ If you see some import issues, make sure that you have all requirements installe
287287
Make sure that you made `test-functional` source folder (Right click > Mark directory as > Source folder)
288288
289289
290+
## Apple Silicon (macOS): Waku fleet connection failures — disable Rosetta
291+
292+
The `wakuorg/nwaku` fleet image is `amd64`-only, so on Apple Silicon it runs under emulation.
293+
With Docker Desktop's **Rosetta** emulation enabled, the libp2p Noise handshake between the
294+
`status-go` nodes (go-libp2p) and the nwaku fleet nodes (nim-libp2p) fails, so the app nodes
295+
never connect to `boot-1`/`store`. Symptoms:
296+
297+
- status-go logs: `failed to negotiate security protocol: ... chacha20poly1305: message authentication failed`
298+
- nwaku (boot-1/store) logs: `decryptWithAd failed tag authentication.`
299+
- Tests that depend on the fleet (store/history sync, communities, anything store-backed) hang
300+
or fail, and the store node never receives messages.
301+
302+
The handshake only fails across go-libp2p ↔ nim-libp2p (go↔go and nim↔nim connections work),
303+
which points at Rosetta's emulation of the crypto path.
304+
305+
**Fix:** Docker Desktop → Settings → General → uncheck
306+
**"Use Rosetta for x86_64/amd64 emulation on Apple Silicon"**, then restart Docker Desktop and
307+
recreate the fleet:
308+
309+
```sh
310+
docker compose -f tests-functional/docker-compose.anvil.yml -f tests-functional/docker-compose.waku.yml up --build --remove-orphans -d
311+
```
312+
313+
With the default (QEMU) emulation the go↔nim handshake succeeds.
314+
315+
290316
## Reliability tests
291317
292318
This framework also hosts a suite of reliability tests. See more info [here](https://github.com/status-im/status-go/blob/develop/tests-functional/tests/reliability/README.MD)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Client for the logos-delivery store REST API.
2+
3+
The transport that status-go currently runs on (and that backs the functional
4+
test fleet) is historically called "waku". The project term going forward is
5+
"logos delivery", so new code uses that name. The fleet store node exposes the
6+
nwaku REST API (`/store/v3/messages`); this client queries it so tests can
7+
assert that messages were actually persisted by the store node, instead of
8+
sleeping and hoping.
9+
"""
10+
11+
import logging
12+
import time
13+
14+
import docker
15+
import requests
16+
from tenacity import retry, stop_after_attempt, wait_fixed
17+
18+
from utils.config import Config
19+
20+
logger = logging.getLogger(__name__)
21+
22+
# Container port the nwaku store node serves its REST API on (mapped to an
23+
# ephemeral host port by docker-compose.waku.yml).
24+
STORE_REST_CONTAINER_PORT = "8645/tcp"
25+
26+
27+
class LogosDeliveryClient:
28+
def __init__(self, base_url: str | None = None, timeout: float = 10.0):
29+
# The store REST port is published on an ephemeral host port to avoid
30+
# collisions on shared CI hosts, so discover it at runtime via the
31+
# Docker API (same approach as clients/anvil.py). An explicit base_url
32+
# can still be passed (e.g. for --status_backend_url style setups).
33+
self.base_url = (base_url or self._discover_store_url()).rstrip("/")
34+
self.timeout = timeout
35+
36+
@retry(stop=stop_after_attempt(15), wait=wait_fixed(0.2), reraise=True)
37+
def _discover_store_url(self) -> str:
38+
docker_client = docker.from_env()
39+
project = Config.docker_project_name
40+
network = docker_client.networks.get(f"{project}_default")
41+
prefix = f"{project}-store"
42+
store = next((c for c in network.containers if c.name and prefix in c.name), None)
43+
if store is None:
44+
raise RuntimeError(f"store container ('{prefix}*') not found")
45+
mappings = store.attrs["NetworkSettings"]["Ports"].get(STORE_REST_CONTAINER_PORT) or []
46+
if not mappings:
47+
raise RuntimeError("store REST port is not exposed")
48+
host_ip = mappings[0]["HostIp"] or "127.0.0.1"
49+
if host_ip == "0.0.0.0":
50+
host_ip = "127.0.0.1"
51+
return f"http://{host_ip}:{mappings[0]['HostPort']}"
52+
53+
def get_messages(
54+
self,
55+
*,
56+
content_topics: list[str] | None = None,
57+
pubsub_topic: str | None = None,
58+
start_time: int | None = None,
59+
end_time: int | None = None,
60+
hashes: list[str] | None = None,
61+
page_size: int = 100,
62+
ascending: bool = True,
63+
include_data: bool = True,
64+
) -> list[dict]:
65+
"""Return all stored messages matching the filters, following pagination.
66+
67+
Times are Unix nanoseconds (matching the store node's timestamps).
68+
"""
69+
params: dict[str, str] = {
70+
"pageSize": str(page_size),
71+
"ascending": "true" if ascending else "false",
72+
"includeData": "true" if include_data else "false",
73+
}
74+
if content_topics:
75+
params["contentTopics"] = ",".join(content_topics)
76+
if pubsub_topic:
77+
params["pubsubTopic"] = pubsub_topic
78+
if start_time is not None:
79+
params["startTime"] = str(start_time)
80+
if end_time is not None:
81+
params["endTime"] = str(end_time)
82+
if hashes:
83+
params["hashes"] = ",".join(hashes)
84+
85+
messages: list[dict] = []
86+
cursor = None
87+
while True:
88+
page_params = dict(params)
89+
if cursor:
90+
page_params["cursor"] = cursor
91+
response = requests.get(f"{self.base_url}/store/v3/messages", params=page_params, timeout=self.timeout)
92+
response.raise_for_status()
93+
body = response.json()
94+
messages.extend(body.get("messages", []) or [])
95+
cursor = body.get("paginationCursor") or body.get("cursor")
96+
if not cursor:
97+
break
98+
return messages
99+
100+
def count_messages(self, **kwargs) -> int:
101+
return len(self.get_messages(**kwargs))
102+
103+
def get_peers(self) -> list[dict]:
104+
response = requests.get(f"{self.base_url}/admin/v1/peers", timeout=self.timeout)
105+
response.raise_for_status()
106+
return response.json()
107+
108+
def wait_for_message_count(self, expected_count: int, *, timeout: float = 60.0, poll_interval: float = 2.0, **kwargs) -> list[dict]:
109+
"""Poll the store until at least *expected_count* messages match, or time out."""
110+
deadline = time.time() + timeout
111+
messages: list[dict] = []
112+
while time.time() < deadline:
113+
messages = self.get_messages(**kwargs)
114+
if len(messages) >= expected_count:
115+
return messages
116+
time.sleep(poll_interval)
117+
return messages

tests-functional/clients/status_backend.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from tenacity import retry, stop_after_delay, wait_fixed, wait_exponential, retry_if_exception_type
1313

1414
import resources.constants as constants
15-
from clients.api import ApiClient
15+
from clients.api import ApiClient, ApiResponseError
1616
from clients.expvar import ExpvarClient
1717
from clients.metrics import Events, StatusGoMetrics
1818
from clients.rpc import RpcClient
@@ -406,7 +406,17 @@ def login(self, key_uid, password: str, kdf_iterations=256000):
406406

407407
def logout(self, **kwargs):
408408
method = "Logout"
409-
return self.api_request_json(method, {}, **kwargs)
409+
try:
410+
return self.api_request_json(method, {}, **kwargs)
411+
except ApiResponseError as e:
412+
# Logout is idempotent: a node that is already logged out (e.g. a
413+
# test that logs out explicitly, or a failed login) reports "there
414+
# is no running node". Treat that as a successful no-op so teardown
415+
# doesn't turn a clean run into an error.
416+
if "there is no running node" in str(e):
417+
logging.debug("Logout called on an already-logged-out node; treating as no-op")
418+
return None
419+
raise
410420

411421
def wait_for_login(self):
412422
"""Wait until the backend has completed login.

tests-functional/docker-compose.waku.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ services:
1212
"--dns4-domain-name=boot-1",
1313
"--filter=true",
1414
"--lightpush=true",
15-
"--log-level=INFO",
15+
"--log-level=DEBUG",
1616
"--max-connections=18000",
1717
"--nodekey=03ce9122016be1f80a23df36525103bed1c7c4b9a0ff7605d97553ed8ed96bcf",
1818
"--peer-exchange=true",
@@ -35,6 +35,12 @@ services:
3535

3636
store:
3737
image: wakuorg/nwaku:v0.38.1
38+
# Publish the nwaku REST API (incl. Store API) on an ephemeral host port so
39+
# tests on the host can query it without colliding on shared CI hosts.
40+
# The actual host port is discovered at runtime via the Docker API
41+
# (see clients/logos_delivery.py), mirroring how the Anvil client works.
42+
ports:
43+
- "8645"
3844
entrypoint: [
3945
"/usr/bin/wakunode",
4046
"--discv5-discovery=true",
@@ -44,7 +50,7 @@ services:
4450
# Docker DNS server
4551
"--dns-addrs-name-server=127.0.0.11",
4652
"--dns4-domain-name=store",
47-
"--log-level=INFO",
53+
"--log-level=DEBUG",
4854
"--max-connections=18000",
4955
"--nodekey=3190bc9b55b18dbc171997a7a67abcd5bbf0c81002ad9617b1cb67f2f15daa64",
5056
"--peer-exchange=false",

tests-functional/resources/enums.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,14 @@ class ContactVerificationState(Enum):
6161
Accepted = 2
6262
Declined = 3
6363
Canceled = 4
64+
65+
66+
class StatusType(Enum):
67+
# protobuf.StatusUpdate StatusType values (see protocol/protobuf/status_update.proto).
68+
# Only AUTOMATIC status updates are published as ephemeral Waku messages, so they
69+
# are never persisted by the store node.
70+
UNKNOWN_STATUS_TYPE = 0
71+
AUTOMATIC = 1 # ephemeral
72+
DO_NOT_DISTURB = 2 # persisted
73+
ALWAYS_ONLINE = 3
74+
INACTIVE = 4
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import logging
2+
from time import sleep
3+
from uuid import uuid4
4+
5+
import pytest
6+
7+
from clients.signals import SignalType
8+
from steps import messenger
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@pytest.mark.rpc
14+
@pytest.mark.wakuext
15+
class TestDeliveryConfirmation:
16+
"""Regression coverage for delivery confirmation (issue #7472).
17+
18+
A 1:1 message must progress to outgoing status ``delivered`` and the
19+
sender must receive a ``message.delivered`` signal, both when the
20+
recipient is online and when it comes online after a period offline.
21+
"""
22+
23+
@pytest.fixture()
24+
def sender(self, backend_new_profile):
25+
return backend_new_profile("sender")
26+
27+
@pytest.fixture()
28+
def receiver(self, backend_new_profile):
29+
return backend_new_profile("receiver")
30+
31+
def _assert_outgoing_status(self, node, message_id, expected_status="delivered"):
32+
# The status field is written just before the message.delivered signal
33+
# fires (same loop in markDeliveredMessages), so once we've seen the
34+
# signal a single read is enough — no polling needed.
35+
message = node.wakuext_service.message_by_message_id(message_id)
36+
actual_status = message.get("outgoingStatus", "")
37+
assert actual_status == expected_status, f"Message {message_id} outgoing status was '{actual_status}', expected '{expected_status}'"
38+
39+
def test_delivery_confirmation_online(self, sender, receiver):
40+
messenger.make_contacts(sender, receiver)
41+
42+
message_text = f"delivery_{uuid4()}"
43+
response = sender.wakuext_service.send_one_to_one_message(receiver.public_key, message_text)
44+
message_id = response.get("messages", [])[0].get("id", "")
45+
assert message_id, "Sender did not get a message id back"
46+
47+
# `message_id` is only known after the send, so wait post-hoc and scan
48+
# from the beginning to avoid matching delivery signals from make_contacts.
49+
with sender.expect_signal(SignalType.MESSAGE_DELIVERED, pattern=message_id, timeout=120, start="beginning"):
50+
pass
51+
52+
self._assert_outgoing_status(sender, message_id, "delivered")
53+
54+
def test_delivery_confirmation_after_recipient_offline(self, sender, receiver):
55+
messenger.make_contacts(sender, receiver)
56+
57+
message_text = f"delivery_offline_{uuid4()}"
58+
59+
# The recipient is offline when the message is sent; confirmation must
60+
# still arrive once it reconnects and the message is delivered.
61+
with messenger.node_pause(receiver):
62+
response = sender.wakuext_service.send_one_to_one_message(receiver.public_key, message_text)
63+
message_id = response.get("messages", [])[0].get("id", "")
64+
assert message_id, "Sender did not get a message id back"
65+
sleep(30)
66+
67+
receiver.wait_for_online(timeout=60)
68+
69+
with receiver.expect_signal(SignalType.MESSAGES_NEW, pattern=message_id, timeout=120, start="beginning"):
70+
pass
71+
72+
with sender.expect_signal(SignalType.MESSAGE_DELIVERED, pattern=message_id, timeout=120, start="beginning"):
73+
pass
74+
75+
self._assert_outgoing_status(sender, message_id, "delivered")
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import logging
2+
from time import sleep
3+
from uuid import uuid4
4+
5+
import pytest
6+
7+
from clients.signals import SignalType
8+
from resources.enums import StatusType
9+
from steps import messenger
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
@pytest.mark.rpc
15+
@pytest.mark.wakuext
16+
class TestEphemeralMessages:
17+
"""Regression coverage for ephemeral messages (issue #7472).
18+
19+
Ephemeral messages are published with the Waku ephemeral flag and are not
20+
stored by the store node. A receiver that was offline when an ephemeral
21+
message was sent must never receive it, even though a regular (persisted)
22+
message sent in the same window is backfilled on reconnect.
23+
"""
24+
25+
@pytest.fixture()
26+
def sender(self, backend_new_profile):
27+
return backend_new_profile("sender")
28+
29+
@pytest.fixture()
30+
def receiver(self, backend_new_profile):
31+
return backend_new_profile("receiver")
32+
33+
def test_ephemeral_status_not_backfilled_while_persisted_message_is(self, sender, receiver):
34+
messenger.make_contacts(sender, receiver)
35+
36+
ephemeral_text = f"ephemeral_{uuid4()}"
37+
control_text = f"persisted_{uuid4()}"
38+
39+
# Receiver is offline while both an ephemeral status update and a normal
40+
# 1:1 message are published.
41+
with messenger.node_pause(receiver):
42+
# Pause long enough for the libp2p connection to time out and drop,
43+
# so relayed messages are genuinely missed (not just buffered at the
44+
# socket and replayed on resume) and must be recovered from the store.
45+
sleep(60)
46+
sender.wakuext_service.set_user_status(StatusType.AUTOMATIC.value, ephemeral_text)
47+
response = sender.wakuext_service.send_one_to_one_message(receiver.public_key, control_text)
48+
control_id = response.get("messages", [])[0].get("id", "")
49+
assert control_id, "Sender did not get a control message id back"
50+
sleep(10)
51+
52+
receiver.wait_for_online(timeout=60)
53+
54+
# The persisted control message must be delivered once the receiver is
55+
# back online, proving it has reconnected and caught up.
56+
with receiver.expect_signal(SignalType.MESSAGES_NEW, pattern=control_id, timeout=120, start="beginning"):
57+
pass
58+
59+
# Grace period so any (incorrectly) persisted ephemeral message would
60+
# have had time to arrive as well.
61+
sleep(10)
62+
63+
status_updates = receiver.wakuext_service.status_updates().get("statusUpdates", []) or []
64+
ephemeral_texts = [s.get("text") for s in status_updates]
65+
assert ephemeral_text not in ephemeral_texts, "Ephemeral status update was unexpectedly delivered to an offline receiver"
66+
67+
def test_ephemeral_status_delivered_live_when_online(self, sender, receiver):
68+
# Sanity check: while the receiver is online, the ephemeral status is
69+
# relayed in real time. Ephemerality only affects store persistence.
70+
messenger.make_contacts(sender, receiver)
71+
72+
ephemeral_text = f"ephemeral_live_{uuid4()}"
73+
with receiver.expect_signal(SignalType.MESSAGES_NEW, pattern=ephemeral_text, timeout=60, start="beginning"):
74+
sender.wakuext_service.set_user_status(StatusType.AUTOMATIC.value, ephemeral_text)
75+
76+
status_updates = receiver.wakuext_service.status_updates().get("statusUpdates", []) or []
77+
matching = [s for s in status_updates if s.get("text") == ephemeral_text]
78+
assert matching, "Ephemeral status update was not relayed to an online receiver"

0 commit comments

Comments
 (0)