Skip to content

Commit 6f82273

Browse files
committed
add testing for api key authentication
1 parent 2eac874 commit 6f82273

File tree

3 files changed

+151
-3
lines changed

3 files changed

+151
-3
lines changed

robot_mcp_server/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ if(BUILD_TESTING)
132132
add_launch_test(
133133
test/launch_tests/test_http_integration.py
134134
)
135+
136+
add_launch_test(
137+
test/launch_tests/test_authentication.py
138+
)
135139
endif()
136140

137141
ament_export_targets(${PROJECT_NAME}Targets HAS_LIBRARY_TARGET)

robot_mcp_server/src/mcp_config/config_parser.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ ServerConfig ConfigParser::parseServerConfig(rclcpp_lifecycle::LifecycleNode::Sh
9191
config.bond_timeout = node->declare_parameter("server.bond_timeout", config.bond_timeout);
9292
config.bond_heartbeat_period = node->declare_parameter("server.bond_heartbeat_period", config.bond_heartbeat_period);
9393

94-
// Optional API key
95-
if (node->has_parameter("server.api_key")) {
96-
config.api_key = node->get_parameter("server.api_key").as_string();
94+
// Optional API key - declare with empty string default
95+
std::string api_key_str = node->declare_parameter("server.api_key", std::string(""));
96+
if (!api_key_str.empty()) {
97+
config.api_key = api_key_str;
9798
}
9899

99100
return config;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
"""Integration tests for MCP server authentication."""
17+
18+
import time
19+
import unittest
20+
21+
import launch
22+
import launch.actions
23+
import launch_ros.actions
24+
import launch_testing.actions
25+
import pytest
26+
import requests
27+
28+
29+
@pytest.mark.launch_test
30+
def generate_test_description():
31+
"""Launch the MCP server with authentication enabled."""
32+
# Test API key
33+
test_api_key = "test_secret_key_12345"
34+
35+
mcp_server_node = launch_ros.actions.LifecycleNode(
36+
package="robot_mcp_server",
37+
executable="robot_mcp_server_node",
38+
name="mcp_http_server",
39+
namespace="",
40+
parameters=[
41+
{
42+
"server.host": "127.0.0.1",
43+
"server.port": 18081,
44+
"server.api_key": test_api_key,
45+
"server.enable_https": False,
46+
}
47+
],
48+
output="screen",
49+
)
50+
51+
# Use nav2_lifecycle_manager to automatically activate the node
52+
lifecycle_manager = launch_ros.actions.Node(
53+
package="nav2_lifecycle_manager",
54+
executable="lifecycle_manager",
55+
name="test_lifecycle_manager",
56+
parameters=[
57+
{"autostart": True, "node_names": ["mcp_http_server"], "bond_timeout": 4.0}
58+
],
59+
output="screen",
60+
)
61+
62+
return launch.LaunchDescription(
63+
[
64+
mcp_server_node,
65+
lifecycle_manager,
66+
launch_testing.actions.ReadyToTest(),
67+
]
68+
)
69+
70+
71+
class TestAuthentication(unittest.TestCase):
72+
"""Active tests for authentication."""
73+
74+
BASE_URL = "http://127.0.0.1:18081"
75+
VALID_API_KEY = "test_secret_key_12345"
76+
77+
@classmethod
78+
def setUpClass(cls):
79+
"""Wait for server to be ready."""
80+
time.sleep(5)
81+
82+
def test_valid_api_key(self):
83+
"""Test request with valid API key succeeds."""
84+
payload = {"jsonrpc": "2.0", "method": "test", "id": 1}
85+
headers = {"Authorization": f"Bearer {self.VALID_API_KEY}"}
86+
87+
response = requests.post(f"{self.BASE_URL}/mcp", json=payload, headers=headers)
88+
89+
assert response.status_code == 200
90+
data = response.json()
91+
assert "result" in data or "message" in data
92+
93+
def test_invalid_api_key(self):
94+
"""Test request with invalid API key is rejected."""
95+
payload = {"jsonrpc": "2.0", "method": "test", "id": 1}
96+
headers = {"Authorization": "Bearer wrong_key"}
97+
98+
response = requests.post(f"{self.BASE_URL}/mcp", json=payload, headers=headers)
99+
100+
assert response.status_code == 401
101+
data = response.json()
102+
assert "error" in data
103+
104+
def test_missing_authorization_header(self):
105+
"""Test request without Authorization header is rejected."""
106+
payload = {"jsonrpc": "2.0", "method": "test", "id": 1}
107+
108+
response = requests.post(f"{self.BASE_URL}/mcp", json=payload)
109+
110+
assert response.status_code == 401
111+
data = response.json()
112+
assert "error" in data
113+
114+
def test_malformed_authorization_header(self):
115+
"""Test request with malformed Authorization header is rejected."""
116+
payload = {"jsonrpc": "2.0", "method": "test", "id": 1}
117+
headers = {"Authorization": "InvalidFormat"}
118+
119+
response = requests.post(f"{self.BASE_URL}/mcp", json=payload, headers=headers)
120+
121+
assert response.status_code == 401
122+
data = response.json()
123+
assert "error" in data
124+
125+
def test_authorization_header_without_bearer(self):
126+
"""Test request with Authorization header but no Bearer prefix is rejected."""
127+
payload = {"jsonrpc": "2.0", "method": "test", "id": 1}
128+
headers = {"Authorization": self.VALID_API_KEY}
129+
130+
response = requests.post(f"{self.BASE_URL}/mcp", json=payload, headers=headers)
131+
132+
assert response.status_code == 401
133+
data = response.json()
134+
assert "error" in data
135+
136+
137+
@launch_testing.post_shutdown_test()
138+
class TestAuthenticationShutdown(unittest.TestCase):
139+
"""Post-shutdown tests."""
140+
141+
def test_exit_codes(self, proc_info):
142+
"""Check that all processes exited cleanly."""
143+
launch_testing.asserts.assertExitCodes(proc_info)

0 commit comments

Comments
 (0)