diff --git a/pytest.ini b/pytest.ini index 313afa0a..a876cae5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,3 +5,4 @@ log_cli = True log_file = log/test.log log_cli_format = %(asctime)s %(name)s %(levelname)s %(message)s log_file_format = %(asctime)s %(name)s %(levelname)s %(message)s +timeout = 300 diff --git a/requirements.txt b/requirements.txt index 38e3913a..bab89313 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,10 @@ pre-commit pyright pytest pytest-instafail +pytest-timeout pytest-xdist pytest-rerunfailures python-dotenv requests tenacity +typeguard \ No newline at end of file diff --git a/src/data_classes.py b/src/data_classes.py index 4266a55b..6527250e 100644 --- a/src/data_classes.py +++ b/src/data_classes.py @@ -1,13 +1,6 @@ from dataclasses import dataclass, field from marshmallow_dataclass import class_schema -from typing import Optional - - -@dataclass -class MessageRpcQuery: - payload: str - contentTopic: str - timestamp: Optional[int] = None +from typing import Optional, Union @dataclass @@ -15,9 +8,10 @@ class MessageRpcResponse: payload: str contentTopic: str version: Optional[int] - timestamp: int + timestamp: Optional[int] ephemeral: Optional[bool] - rateLimitProof: Optional[dict] = field(default_factory=dict) + meta: Optional[str] + rateLimitProof: Optional[Union[dict, str]] = field(default_factory=dict) rate_limit_proof: Optional[dict] = field(default_factory=dict) diff --git a/src/node/api_clients/base_client.py b/src/node/api_clients/base_client.py index d89ca9ed..277c40ac 100644 --- a/src/node/api_clients/base_client.py +++ b/src/node/api_clients/base_client.py @@ -11,7 +11,7 @@ class BaseClient(ABC): # useful when running tests in parallel, where occasional network-related errors such as # connection drops, timeouts, or temporary unavailability of a service can occur. Retrying # ensures that such intermittent issues don't cause the tests to fail outright. - @retry(stop=stop_after_delay(1), wait=wait_fixed(0.1), reraise=True) + @retry(stop=stop_after_delay(0.5), wait=wait_fixed(0.1), reraise=True) def make_request(self, method, url, headers=None, data=None): logger.debug("%s call: %s with payload: %s", method.upper(), url, data) response = requests.request(method.upper(), url, headers=headers, data=data) diff --git a/src/node/api_clients/rest.py b/src/node/api_clients/rest.py index 47ecbf79..68d43a81 100644 --- a/src/node/api_clients/rest.py +++ b/src/node/api_clients/rest.py @@ -24,7 +24,7 @@ class REST(BaseClient): return self.rest_call("post", "relay/v1/subscriptions", json.dumps(pubsub_topics)) def send_message(self, message, pubsub_topic): - return self.rest_call("post", f"relay/v1/messages/{quote(pubsub_topic, safe='')}", json.dumps(asdict(message))) + return self.rest_call("post", f"relay/v1/messages/{quote(pubsub_topic, safe='')}", json.dumps(message)) def get_messages(self, pubsub_topic): get_messages_response = self.rest_call("get", f"relay/v1/messages/{quote(pubsub_topic, safe='')}") diff --git a/src/node/api_clients/rpc.py b/src/node/api_clients/rpc.py index 2ed32c5e..7d79daa2 100644 --- a/src/node/api_clients/rpc.py +++ b/src/node/api_clients/rpc.py @@ -28,7 +28,7 @@ class RPC(BaseClient): return self.rpc_call("post_waku_v2_relay_v1_subscription", [pubsub_topics]) def send_message(self, message, pubsub_topic): - return self.rpc_call("post_waku_v2_relay_v1_message", [pubsub_topic, asdict(message)]) + return self.rpc_call("post_waku_v2_relay_v1_message", [pubsub_topic, message]) def get_messages(self, pubsub_topic): get_messages_response = self.rpc_call("get_waku_v2_relay_v1_messages", [pubsub_topic]) diff --git a/src/node/waku_node.py b/src/node/waku_node.py index a0946e71..c3c1d5ac 100644 --- a/src/node/waku_node.py +++ b/src/node/waku_node.py @@ -100,3 +100,21 @@ class WakuNode: def get_messages(self, pubsub_topic=DEFAULT_PUBSUBTOPIC): return self._api.get_messages(pubsub_topic) + + @property + def image(self): + return self._image_name + + def type(self): + if self.is_nwaku(): + return "nwaku" + elif self.is_gowaku(): + return "gowaku" + else: + raise Exception("Unknown node type!!!") + + def is_nwaku(self): + return "nwaku" in self.image + + def is_gowaku(self): + return "go-waku" in self.image diff --git a/src/steps/relay.py b/src/steps/relay.py index d45c7d1a..f284f963 100644 --- a/src/steps/relay.py +++ b/src/steps/relay.py @@ -1,7 +1,9 @@ import logging +import math from time import sleep, time import pytest import allure +from src.libs.common import to_base64 from src.data_classes import message_rpc_response_schema from src.env_vars import NODE_1, NODE_2 from src.node.waku_node import WakuNode @@ -13,30 +15,43 @@ logger = logging.getLogger(__name__) class StepsRelay: @pytest.fixture(scope="function", autouse=True) def setup_nodes(self, request): - self.node1 = WakuNode(NODE_1, request.cls.test_id) + self.node1 = WakuNode(NODE_1, "node1_" + request.cls.test_id) self.node1.start(relay="true", discv5_discovery="true", peer_exchange="true") enr_uri = self.node1.info()["enrUri"] - self.node2 = WakuNode(NODE_2, request.cls.test_id) + self.node2 = WakuNode(NODE_2, "node2_" + request.cls.test_id) self.node2.start(relay="true", discv5_discovery="true", discv5_bootstrap_node=enr_uri, peer_exchange="true") - self.test_pubsub_topic = "test" - self.test_content_topic = "/test/1/waku-relay" + self.test_pubsub_topic = "/waku/2/rs/18/1" + self.test_content_topic = "/test/1/waku-relay/proto" self.test_payload = "Relay works!!" self.node1.set_subscriptions([self.test_pubsub_topic]) self.node2.set_subscriptions([self.test_pubsub_topic]) - @allure.step - @retry(stop=stop_after_delay(20), wait=wait_fixed(0.5), reraise=True) - def check_published_message_reaches_peer_with_retry(self, message): - self.check_published_message_reaches_peer(message) + @pytest.fixture(scope="function", autouse=True) + @retry(stop=stop_after_delay(40), wait=wait_fixed(1), reraise=True) + def wait_for_network_to_warm_up(self): + message = {"payload": to_base64(self.test_payload), "contentTopic": self.test_content_topic, "timestamp": int(time() * 1e9)} + try: + self.check_published_message_reaches_peer(message) + except Exception as ex: + raise Exception(f"WARM UP FAILED WITH: {ex}") @allure.step def check_published_message_reaches_peer(self, message): - message.timestamp = int(time() * 1e9) self.node1.send_message(message, self.test_pubsub_topic) sleep(0.1) get_messages_response = self.node2.get_messages(self.test_pubsub_topic) logger.debug("Got reponse from remote peer %s", get_messages_response) + assert get_messages_response, "Peer node couldn't find any messages" received_message = message_rpc_response_schema.load(get_messages_response[0]) - assert received_message.payload == message.payload - assert received_message.contentTopic == message.contentTopic - assert received_message.timestamp == message.timestamp + assert ( + received_message.payload == message["payload"] + ), f'Incorrect payload. Published {message["payload"]} Received {received_message.payload}' + assert ( + received_message.contentTopic == message["contentTopic"] + ), f'Incorrect contentTopic. Published {message["contentTopic"]} Received {received_message.contentTopic}' + if "timestamp" in message and message["timestamp"]: + assert_fail_message = f'Incorrect timestamp. Published {message["timestamp"]} Received {received_message.timestamp}' + if isinstance(message["timestamp"], float): + assert math.isclose(float(received_message.timestamp), message["timestamp"], rel_tol=1e-9), assert_fail_message + else: + assert str(received_message.timestamp) == str(message["timestamp"]), assert_fail_message diff --git a/src/test_data.py b/src/test_data.py index 0a777099..d4a4159d 100644 --- a/src/test_data.py +++ b/src/test_data.py @@ -1,42 +1,84 @@ +from time import time +from datetime import datetime, timedelta + +NOW = datetime.now() + SAMPLE_INPUTS = [ - {"description": "A simple string.", "value": "Hello World!"}, - {"description": "An integer.", "value": "1234567890"}, - {"description": "A dictionary.", "value": '{"key": "value"}'}, - {"description": "Chinese characters.", "value": "这是一些中文"}, - {"description": "Emojis.", "value": "🚀🌟✨"}, - {"description": "Lorem ipsum text.", "value": "Lorem ipsum dolor sit amet"}, - {"description": "HTML content.", "value": "Hello"}, - {"description": "Cyrillic characters.", "value": "\u041f\u0440\u0438\u0432\u0435\u0442"}, - {"description": "Base64 encoded string.", "value": "Base64==dGVzdA=="}, - {"description": "Binary data.", "value": "d29ya2luZyB3aXRoIGJpbmFyeSBkYXRh: \x50\x51"}, - {"description": "Special characters with whitespace.", "value": "\t\nSpecial\tCharacters\n"}, - {"description": "Boolean false as a string.", "value": "False"}, - {"description": "A float number.", "value": "3.1415926535"}, - {"description": "A list.", "value": "[1, 2, 3, 4, 5]"}, - {"description": "Hexadecimal number as a string.", "value": "0xDEADBEEF"}, - {"description": "Email format.", "value": "user@example.com"}, - {"description": "URL format.", "value": "http://example.com"}, - {"description": "Date and time in ISO format.", "value": "2023-11-01T12:00:00Z"}, - {"description": "String with escaped quotes.", "value": '"Escaped" \\"quotes\\"'}, - {"description": "A regular expression.", "value": "Regular expression: ^[a-z0-9_-]{3,16}$"}, - {"description": "A very long string.", "value": "x" * 1000}, - {"description": "A JSON string.", "value": '{"name": "John", "age": 30, "city": "New York"}'}, - {"description": "A Unix path.", "value": "/usr/local/bin"}, - {"description": "A Windows path.", "value": "C:\\Windows\\System32"}, - {"description": "An SQL query.", "value": "SELECT * FROM users WHERE id = 1;"}, - {"description": "JavaScript code snippet.", "value": "function test() { console.log('Hello World'); }"}, - {"description": "A CSS snippet.", "value": "body { background-color: #fff; }"}, - {"description": "A Python one-liner.", "value": "print('Hello World')"}, - {"description": "An IP address.", "value": "192.168.1.1"}, - {"description": "A domain name.", "value": "www.example.com"}, - {"description": "A user agent string.", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}, - {"description": "A credit card number.", "value": "1234-5678-9012-3456"}, - {"description": "A phone number.", "value": "+1234567890"}, - {"description": "A UUID.", "value": "123e4567-e89b-12d3-a456-426614174000"}, - {"description": "A hashtag.", "value": "#helloWorld"}, - {"description": "A Twitter handle.", "value": "@username"}, - {"description": "A password.", "value": "P@ssw0rd!"}, - {"description": "A date in common format.", "value": "01/11/2023"}, - {"description": "A time string.", "value": "12:00:00"}, - {"description": "A mathematical equation.", "value": "E = mc^2"}, + {"description": "A simple string", "value": "Hello World!"}, + {"description": "An integer", "value": "1234567890"}, + {"description": "A dictionary", "value": '{"key": "value"}'}, + {"description": "Chinese characters", "value": "这是一些中文"}, + {"description": "Emojis", "value": "🚀🌟✨"}, + {"description": "Lorem ipsum text", "value": "Lorem ipsum dolor sit amet"}, + {"description": "HTML content", "value": "Hello"}, + {"description": "Cyrillic characters", "value": "\u041f\u0440\u0438\u0432\u0435\u0442"}, + {"description": "Base64 encoded string", "value": "Base64==dGVzdA=="}, + {"description": "Binary data", "value": "d29ya2luZyB3aXRoIGJpbmFyeSBkYXRh: \x50\x51"}, + {"description": "Special characters with whitespace", "value": "\t\nSpecial\tCharacters\n"}, + {"description": "Boolean false as a string", "value": "False"}, + {"description": "A float number", "value": "3.1415926535"}, + {"description": "A list", "value": "[1, 2, 3, 4, 5]"}, + {"description": "Hexadecimal number as a string", "value": "0xDEADBEEF"}, + {"description": "Email format", "value": "user@example.com"}, + {"description": "URL format", "value": "http://example.com"}, + {"description": "Date and time in ISO format", "value": "2023-11-01T12:00:00Z"}, + {"description": "String with escaped quotes", "value": '"Escaped" \\"quotes\\"'}, + {"description": "A regular expression", "value": "Regular expression: ^[a-z0-9_-]{3,16}$"}, + {"description": "A very long string", "value": "x" * 1000}, + {"description": "A JSON string", "value": '{"name": "John", "age": 30, "city": "New York"}'}, + {"description": "A Unix path", "value": "/usr/local/bin"}, + {"description": "A Windows path", "value": "C:\\Windows\\System32"}, + {"description": "An SQL query", "value": "SELECT * FROM users WHERE id = 1;"}, + {"description": "JavaScript code snippet", "value": "function test() { console.log('Hello World'); }"}, + {"description": "A CSS snippet", "value": "body { background-color: #fff; }"}, + {"description": "A Python one-liner", "value": "print('Hello World')"}, + {"description": "An IP address", "value": "192.168.1.1"}, + {"description": "A domain name", "value": "www.example.com"}, + {"description": "A user agent string", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}, + {"description": "A credit card number", "value": "1234-5678-9012-3456"}, + {"description": "A phone number", "value": "+1234567890"}, + {"description": "A UUID", "value": "123e4567-e89b-12d3-a456-426614174000"}, + {"description": "A hashtag", "value": "#helloWorld"}, + {"description": "A Twitter handle", "value": "@username"}, + {"description": "A password", "value": "P@ssw0rd!"}, + {"description": "A date in common format", "value": "01/11/2023"}, + {"description": "A time string", "value": "12:00:00"}, + {"description": "A mathematical equation", "value": "E = mc^2"}, +] + +INVALID_PAYLOADS = [ + {"description": "Unecoded text", "value": "Hello World!"}, + {"description": "A dictionary", "value": {"key": "YWFh"}}, + {"description": "An integer", "value": 1234567890}, + {"description": "A list", "value": ["YWFh"]}, + {"description": "A bool", "value": True}, +] + +INVALID_CONTENT_TOPICS = [ + {"description": "A dictionary", "value": {"key": "YWFh"}}, + {"description": "An integer", "value": 1234567890}, + {"description": "A list", "value": ["YWFh"]}, + {"description": "A bool", "value": True}, +] + + +SAMPLE_TIMESTAMPS = [ + {"description": "Now", "value": int(time() * 1e9), "valid_for": ["nwaku", "gowaku"]}, + { + "description": "Far future", + "value": int((NOW + timedelta(days=365 * 10)).timestamp() * 1e9), + "valid_for": ["nwaku", "gowaku"], + }, # 10 years from now + {"description": "Recent past", "value": int((NOW - timedelta(hours=1)).timestamp() * 1e9), "valid_for": ["nwaku", "gowaku"]}, # 1 hour ago + {"description": "Near future", "value": int((NOW + timedelta(hours=1)).timestamp() * 1e9), "valid_for": ["nwaku", "gowaku"]}, # 1 hour ahead + {"description": "Positive number", "value": 1, "valid_for": ["nwaku", "gowaku"]}, + {"description": "Negative number", "value": -1, "valid_for": ["nwaku", "gowaku"]}, + {"description": "DST change", "value": int(datetime(2020, 3, 8, 2, 0, 0).timestamp() * 1e9), "valid_for": ["nwaku", "gowaku"]}, # DST starts + {"description": "Timestamp as string number", "value": str(int(time() * 1e9)), "valid_for": ["gowaku"]}, + {"description": "Invalid large number", "value": 2**63, "valid_for": []}, + {"description": "Float number", "value": float(time() * 1e9), "valid_for": ["gowaku"]}, + {"description": "Array instead of timestamp", "value": [int(time() * 1e9)], "valid_for": []}, + {"description": "Object instead of timestamp", "value": {"time": int(time() * 1e9)}, "valid_for": []}, + {"description": "ISO 8601 timestamp", "value": "2023-12-26T10:58:51", "valid_for": []}, + {"description": "Missing", "value": None, "valid_for": ["gowaku"]}, ] diff --git a/tests/conftest.py b/tests/conftest.py index e12f0420..a1bb1046 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,7 +48,7 @@ def test_setup(request, test_id): @pytest.fixture(scope="function", autouse=True) def attach_logs_on_fail(request): yield - if request.node.rep_call.failed: + if hasattr(request.node, "rep_call") and request.node.rep_call.failed: logger.debug("Test failed, attempting to attach logs to the allure reports") for file in glob.glob(os.path.join(env_vars.LOG_DIR, request.cls.test_id + "*")): attach_allure_file(file) @@ -58,5 +58,12 @@ def attach_logs_on_fail(request): def close_open_nodes(): DS.waku_nodes = [] yield + crashed_containers = [] for node in DS.waku_nodes: - node.stop() + try: + node.stop() + except Exception as ex: + if "No such container" in str(ex): + crashed_containers.append(node.image) + logger.error("Failed to stop container because of error %s", ex) + assert not crashed_containers, f"Containers {crashed_containers} crashed during the test!!!" diff --git a/tests/relay/test_publish.py b/tests/relay/test_publish.py index f8504eb8..596037b7 100644 --- a/tests/relay/test_publish.py +++ b/tests/relay/test_publish.py @@ -1,45 +1,113 @@ import logging - +from time import time from src.libs.common import to_base64 -from src.data_classes import MessageRpcQuery from src.steps.relay import StepsRelay -from src.test_data import SAMPLE_INPUTS +from src.test_data import INVALID_CONTENT_TOPICS, INVALID_PAYLOADS, SAMPLE_INPUTS, SAMPLE_TIMESTAMPS logger = logging.getLogger(__name__) class TestRelayPublish(StepsRelay): - def test_publish_with_various_payloads(self): + def test_publish_with_valid_payloads(self): failed_payloads = [] - for index, payload in enumerate(SAMPLE_INPUTS): + for payload in SAMPLE_INPUTS: logger.debug("Running test with payload %s", payload["description"]) - message = MessageRpcQuery(payload=to_base64(payload["value"]), contentTopic=self.test_content_topic) + message = {"payload": to_base64(payload["value"]), "contentTopic": self.test_content_topic, "timestamp": int(time() * 1e9)} try: - # The node may require a warmup period for message processing to stabilize. - # Therefore, we use the retry function for the first payload to account for this warmup. - if index == 0: - self.check_published_message_reaches_peer_with_retry(message) - else: - self.check_published_message_reaches_peer(message) + self.check_published_message_reaches_peer(message) except Exception as e: logger.error("Payload %s failed: %s", payload["description"], str(e)) failed_payloads.append(payload["description"]) - assert not failed_payloads, f"Payloads failed: {failed_payloads}" + def test_publish_with_invalid_payloads(self): + success_payloads = [] + for payload in INVALID_PAYLOADS: + logger.debug("Running test with payload %s", payload["description"]) + message = {"payload": payload["value"], "contentTopic": self.test_content_topic, "timestamp": int(time() * 1e9)} + try: + self.node1.send_message(message, self.test_pubsub_topic) + success_payloads.append(payload) + except Exception as ex: + assert "Bad Request" in str(ex) + assert not success_payloads, f"Invalid Payloads that didn't failed: {success_payloads}" + + def test_publish_with_missing_payload(self): + message = {"contentTopic": self.test_content_topic, "timestamp": int(time() * 1e9)} + try: + self.node1.send_message(message, self.test_pubsub_topic) + raise AssertionError("Publish with missing payload worked!!!") + except Exception as ex: + if self.node1.is_nwaku(): + assert "Bad Request" in str(ex) + elif self.node1.is_gowaku(): + assert "Internal Server Error" in str(ex) + else: + raise Exception("Not implemented") + def test_publish_with_various_content_topics(self): failed_content_topics = [] - for index, content_topic in enumerate(SAMPLE_INPUTS): + for content_topic in SAMPLE_INPUTS: logger.debug("Running test with content topic %s", content_topic["description"]) - message = MessageRpcQuery(payload=to_base64(self.test_payload), contentTopic=content_topic["value"]) + message = {"payload": to_base64(self.test_payload), "contentTopic": content_topic["value"], "timestamp": int(time() * 1e9)} try: - # The node may require a warmup period for message processing to stabilize. - # Therefore, we use the retry function for the first payload to account for this warmup. - if index == 0: - self.check_published_message_reaches_peer_with_retry(message) - else: - self.check_published_message_reaches_peer(message) + self.check_published_message_reaches_peer(message) except Exception as e: - logger.error("ContentTopic %s failed: %s", {content_topic["description"]}, {str(e)}) + logger.error("ContentTopic %s failed: %s", content_topic["description"], str(e)) failed_content_topics.append(content_topic) assert not failed_content_topics, f"ContentTopics failed: {failed_content_topics}" + + def test_publish_with_invalid_content_topics(self): + success_content_topics = [] + for content_topic in INVALID_CONTENT_TOPICS: + logger.debug("Running test with contetn topic %s", content_topic["description"]) + message = {"payload": to_base64(self.test_payload), "contentTopic": content_topic["value"], "timestamp": int(time() * 1e9)} + try: + self.node1.send_message(message, self.test_pubsub_topic) + success_content_topics.append(content_topic) + except Exception as ex: + assert "Bad Request" in str(ex) + assert not success_content_topics, f"Invalid Content topics that didn't failed: {success_content_topics}" + + def test_publish_with_missing_content_topic(self): + message = {"payload": to_base64(self.test_payload), "timestamp": int(time() * 1e9)} + try: + self.node1.send_message(message, self.test_pubsub_topic) + raise AssertionError("Publish with missing content_topic worked!!!") + except Exception as ex: + if self.node1.is_nwaku(): + assert "Bad Request" in str(ex) + elif self.node1.is_gowaku(): + assert "Internal Server Error" in str(ex) + else: + raise Exception("Not implemented") + + def test_publish_with_valid_timestamps(self): + failed_timestamps = [] + for timestamp in SAMPLE_TIMESTAMPS: + if self.node1.type() in timestamp["valid_for"]: + logger.debug("Running test with timestamp %s", timestamp["description"]) + message = {"payload": to_base64(self.test_payload), "contentTopic": self.test_content_topic, "timestamp": timestamp["value"]} + try: + self.check_published_message_reaches_peer(message) + except Exception as ex: + logger.error("Timestamp %s failed: %s", timestamp["description"], str(ex)) + failed_timestamps.append(timestamp) + assert not failed_timestamps, f"Timestamps failed: {failed_timestamps}" + + def test_publish_with_invalid_timestamps(self): + success_timestamps = [] + for timestamp in SAMPLE_TIMESTAMPS: + if self.node1.type() not in timestamp["valid_for"]: + logger.debug("Running test with timestamp %s", timestamp["description"]) + message = {"payload": to_base64(self.test_payload), "contentTopic": self.test_content_topic, "timestamp": timestamp["value"]} + try: + self.check_published_message_reaches_peer(message) + success_timestamps.append(timestamp) + except Exception as e: + pass + assert not success_timestamps, f"Invalid Timestamps that didn't failed: {success_timestamps}" + + def test_publish_with_no_timestamp(self): + message = {"payload": to_base64(self.test_payload), "contentTopic": self.test_content_topic} + self.check_published_message_reaches_peer(message)