Skip to content

Commit eeb6874

Browse files
committed
adding tests for http authentication and SSL
1 parent 9dfc1cd commit eeb6874

File tree

9 files changed

+153
-51
lines changed

9 files changed

+153
-51
lines changed

robot_mcp_server/CMakeLists.txt

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,19 @@ install(DIRECTORY include/
9191
DESTINATION include
9292
)
9393

94-
# Install test configuration files
95-
install(DIRECTORY test/launch_tests/
96-
DESTINATION share/${PROJECT_NAME}/test/launch_tests
97-
FILES_MATCHING
98-
PATTERN "*.yaml"
99-
)
100-
10194
if(BUILD_TESTING)
10295
# Find robot_mcp_test package
10396
find_package(robot_mcp_test REQUIRED)
10497
find_package(launch_testing_ament_cmake REQUIRED)
10598

99+
# Install test configuration files and test utilities
100+
install(DIRECTORY test/launch_tests/
101+
DESTINATION share/${PROJECT_NAME}/test/launch_tests
102+
FILES_MATCHING
103+
PATTERN "*.yaml"
104+
PATTERN "test_utils.py"
105+
)
106+
106107
# Include the custom test function
107108
include(${robot_mcp_test_DIR}/add_robot_mcp_test.cmake)
108109

@@ -120,26 +121,12 @@ if(BUILD_TESTING)
120121
${PROJECT_NAME}
121122
)
122123

123-
# Add launch tests
124-
add_launch_test(
125-
test/launch_tests/test_complete_config.py
126-
)
127-
128-
add_launch_test(
129-
test/launch_tests/test_minimal_config.py
130-
)
131-
132-
add_launch_test(
133-
test/launch_tests/test_http_integration.py
134-
)
135-
136-
add_launch_test(
137-
test/launch_tests/test_authentication.py
138-
)
139-
140-
add_launch_test(
141-
test/launch_tests/test_https.py
142-
)
124+
# Add launch tests with test_utils support
125+
add_robot_mcp_launch_test(test/launch_tests/test_complete_config.py)
126+
add_robot_mcp_launch_test(test/launch_tests/test_minimal_config.py)
127+
add_robot_mcp_launch_test(test/launch_tests/test_http_integration.py)
128+
add_robot_mcp_launch_test(test/launch_tests/test_authentication.py)
129+
add_robot_mcp_launch_test(test/launch_tests/test_https.py)
143130
endif()
144131

145132
ament_export_targets(${PROJECT_NAME}Targets HAS_LIBRARY_TARGET)

robot_mcp_server/src/robot_mcp_server_node.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,12 @@ CallbackReturn MCPServerNode::on_deactivate(const rclcpp_lifecycle::State & /*st
149149

150150
// Stop bond heartbeat
151151
if (bond_) {
152-
bond_->breakBond();
152+
try {
153+
bond_->breakBond();
154+
} catch (const std::exception & e) {
155+
// Bond breaking may fail if RCL context is already shut down during SIGINT
156+
RCLCPP_DEBUG(get_logger(), "Bond break failed (context may be shut down): %s", e.what());
157+
}
153158
bond_.reset();
154159
RCLCPP_INFO(get_logger(), "Bond heartbeat stopped");
155160
}

robot_mcp_server/test/launch_tests/test_authentication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def generate_test_description():
3939
namespace="",
4040
parameters=[
4141
{
42-
"server.host": "127.0.0.1",
42+
"server.host": "127.0.0.2",
4343
"server.port": 18081,
4444
"server.api_key": test_api_key,
4545
"server.enable_https": False,
@@ -71,7 +71,7 @@ def generate_test_description():
7171
class TestAuthentication(unittest.TestCase):
7272
"""Active tests for authentication."""
7373

74-
BASE_URL = "http://127.0.0.1:18081"
74+
BASE_URL = "http://127.0.0.2:18081"
7575
VALID_API_KEY = "test_secret_key_12345"
7676

7777
@classmethod

robot_mcp_server/test/launch_tests/test_http_integration.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
"""Integration test for HTTP server and MCP endpoint."""
1717

18-
import time
1918
import unittest
2019
import requests
2120

@@ -26,6 +25,8 @@
2625

2726
import pytest
2827

28+
from test_utils import wait_for_server
29+
2930

3031
def generate_test_description():
3132
"""Launch the MCP server with lifecycle manager."""
@@ -73,12 +74,14 @@ class TestHTTPIntegration(unittest.TestCase):
7374

7475
BASE_URL = "http://127.0.0.1:18080"
7576

77+
@classmethod
78+
def setUpClass(cls):
79+
"""Wait for server to be ready."""
80+
wait_for_server(cls.BASE_URL)
81+
7682
def test_server_responds_to_valid_jsonrpc_request(self):
7783
"""Test server responds with valid JSON-RPC response."""
7884

79-
# Give server time to start
80-
time.sleep(1.0)
81-
8285
# Send valid JSON-RPC request
8386
payload = {
8487
"jsonrpc": "2.0",
@@ -113,9 +116,6 @@ def test_server_responds_to_valid_jsonrpc_request(self):
113116

114117
def test_server_rejects_invalid_json(self):
115118
"""Test server returns error for malformed JSON."""
116-
117-
time.sleep(1.0)
118-
119119
# Send invalid JSON
120120
response = requests.post(
121121
f"{self.BASE_URL}/mcp",
@@ -133,9 +133,6 @@ def test_server_rejects_invalid_json(self):
133133

134134
def test_server_rejects_invalid_jsonrpc_structure(self):
135135
"""Test server returns error for invalid JSON-RPC structure."""
136-
137-
time.sleep(1.0)
138-
139136
# Send JSON missing required fields
140137
payload = {
141138
"method": "test",
@@ -159,9 +156,6 @@ def test_server_rejects_invalid_jsonrpc_structure(self):
159156

160157
def test_server_handles_cors_preflight(self):
161158
"""Test server handles CORS preflight OPTIONS request."""
162-
163-
time.sleep(1.0)
164-
165159
# Send OPTIONS request
166160
response = requests.options(
167161
f"{self.BASE_URL}/mcp",
@@ -181,9 +175,6 @@ def test_server_handles_cors_preflight(self):
181175

182176
def test_server_handles_multiple_requests(self):
183177
"""Test server can handle multiple concurrent requests."""
184-
185-
time.sleep(1.0)
186-
187178
# Send multiple requests with different IDs
188179
responses = []
189180
for i in range(5):
@@ -205,9 +196,6 @@ def test_server_handles_multiple_requests(self):
205196

206197
def test_server_responds_with_null_id(self):
207198
"""Test server handles requests with null id."""
208-
209-
time.sleep(1.0)
210-
211199
payload = {"jsonrpc": "2.0", "method": "test", "id": None}
212200

213201
response = requests.post(

robot_mcp_server/test/launch_tests/test_https.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import os
1919
import shutil
2020
import subprocess
21-
import time
2221
import unittest
2322

2423
import launch
@@ -28,6 +27,8 @@
2827
import pytest
2928
import requests
3029

30+
from test_utils import wait_for_server
31+
3132

3233
def generate_test_certificates():
3334
"""Generate self-signed certificates for testing."""
@@ -118,7 +119,7 @@ class TestHTTPS(unittest.TestCase):
118119
@classmethod
119120
def setUpClass(cls):
120121
"""Wait for server to be ready."""
121-
time.sleep(5)
122+
wait_for_server(cls.BASE_URL, verify=False)
122123

123124
@classmethod
124125
def tearDownClass(cls):
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2025-present WATonomous. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Shared utilities for launch tests."""
17+
18+
import time
19+
import requests
20+
21+
22+
def wait_for_server(
23+
base_url,
24+
max_attempts=50,
25+
timeout=0.5,
26+
verify=True,
27+
headers=None,
28+
):
29+
"""Poll server until ready with exponential backoff.
30+
31+
Args:
32+
base_url: Base URL of the server (e.g., "http://127.0.0.1:8080")
33+
max_attempts: Maximum number of connection attempts
34+
timeout: Timeout in seconds for each request
35+
verify: Whether to verify SSL certificates (False for self-signed certs)
36+
headers: Optional headers to send with the request (e.g., for auth)
37+
38+
Raises:
39+
RuntimeError: If server does not respond after max_attempts
40+
41+
"""
42+
for attempt in range(max_attempts):
43+
try:
44+
response = requests.post(
45+
f"{base_url}/mcp",
46+
json={"jsonrpc": "2.0", "method": "ping", "id": 0},
47+
timeout=timeout,
48+
verify=verify,
49+
headers=headers or {},
50+
)
51+
# Server is responding, we're ready
52+
return
53+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
54+
if attempt == max_attempts - 1:
55+
raise RuntimeError(
56+
f"Server at {base_url} did not start after {max_attempts * 0.1}s"
57+
)
58+
time.sleep(0.1)

robot_mcp_test/DEVELOPING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,8 @@ The `add_robot_mcp_test()` function automatically:
3636
- Links ROS dependencies (rclcpp, rclcpp_lifecycle)
3737
- Configures ament test registration
3838
- Sets up JUnit XML output
39+
40+
The `add_robot_mcp_launch_test()` function automatically:
41+
- Links up test utility files within `test/launch_tests` directory of a project
42+
- Run the ROS `add_launch_test` function
43+

robot_mcp_test/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ TEST_CASE_METHOD(robot_mcp::test::TestExecutorFixture, "My ROS Test", "[ros]") {
3535

3636
## CMake Usage
3737

38+
### Catch2 Unit Tests
39+
3840
```cmake
3941
find_package(robot_mcp_test REQUIRED)
4042
@@ -45,3 +47,44 @@ add_robot_mcp_test(my_test
4547
std_msgs::std_msgs
4648
)
4749
```
50+
51+
### Launch Tests
52+
53+
```cmake
54+
find_package(robot_mcp_test REQUIRED)
55+
find_package(launch_testing_ament_cmake REQUIRED)
56+
57+
add_robot_mcp_launch_test(test/launch_tests/test_example.py)
58+
```
59+
60+
The `add_robot_mcp_launch_test()` macro automatically:
61+
- Wraps `add_launch_test()` from launch_testing_ament_cmake
62+
- Adds `test/launch_tests` to `PYTHONPATH` for importing shared utilities
63+
- Allows test files to import from `test_utils.py` for common test helpers
64+
65+
**Example test_utils.py pattern:**
66+
67+
```python
68+
# test/launch_tests/test_utils.py
69+
import time
70+
import requests
71+
72+
def wait_for_server(base_url, max_attempts=50):
73+
"""Poll server until ready."""
74+
for attempt in range(max_attempts):
75+
try:
76+
requests.post(f"{base_url}/health", timeout=0.5)
77+
return
78+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
79+
if attempt == max_attempts - 1:
80+
raise RuntimeError("Server did not start")
81+
time.sleep(0.1)
82+
83+
# test/launch_tests/test_my_server.py
84+
from test_utils import wait_for_server
85+
86+
class TestMyServer(unittest.TestCase):
87+
@classmethod
88+
def setUpClass(cls):
89+
wait_for_server("http://localhost:8080")
90+
```

robot_mcp_test/cmake/add_robot_mcp_test.cmake

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,18 @@ function(add_robot_mcp_test TEST_NAME TEST_SOURCE)
7171
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
7272
)
7373
endfunction()
74+
75+
#
76+
# Launch Test Function with test_utils support
77+
# Wraps add_launch_test to automatically add test/launch_tests to PYTHONPATH
78+
#
79+
# Usage:
80+
# add_robot_mcp_launch_test(test/launch_tests/test_example.py)
81+
#
82+
macro(add_robot_mcp_launch_test filename)
83+
add_launch_test(
84+
${filename}
85+
ENV PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}/test/launch_tests:$ENV{PYTHONPATH}
86+
${ARGN}
87+
)
88+
endmacro()

0 commit comments

Comments
 (0)