Coverage for src/meshadmin/server/networks/tests/test_api.py: 100%
150 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
1import json
2from datetime import timedelta
3from pathlib import Path
5import pytest
6from django.utils import timezone
7from jwcrypto.jwk import JWK
8from jwcrypto.jwt import JWT
10from meshadmin.common import schemas
11from meshadmin.common.utils import create_keys
12from meshadmin.server.networks.models import Host, Template
13from meshadmin.server.networks.services import (
14 create_template,
15 generate_enrollment_token,
16)
19@pytest.fixture
20def keycloak_key():
21 key = JWK.generate(kty="RSA", size=2048, kid="test-key-id")
22 return {
23 "private_key": key,
24 "public_key": json.loads(key.export_public()),
25 "kid": "test-key-id",
26 }
29@pytest.fixture
30def keycloak_auth_headers(mocker, keycloak_key, settings):
31 jwt_token = JWT(
32 header={"alg": "RS256", "kid": keycloak_key["kid"]},
33 claims={
34 "exp": 9999999999,
35 "iat": 1741328648,
36 "iss": settings.KEYCLOAK_ISSUER,
37 "azp": settings.KEYCLOAK_ADMIN_CLIENT,
38 "typ": "Bearer",
39 "email": "test@example.com",
40 },
41 )
42 jwt_token.make_signed_token(keycloak_key["private_key"])
43 mock_get = mocker.patch("requests.get")
44 mock_get.return_value.json.return_value = {"keys": [keycloak_key["public_key"]]}
45 mock_get.return_value.raise_for_status.return_value = None
47 mocker.patch(
48 "meshadmin.server.networks.api.KeycloakAuthBearer.get_keycloak_public_key",
49 return_value={"keys": [keycloak_key["public_key"]]},
50 )
51 return {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}
54def test_template_endpoints(db, client, test_network, keycloak_auth_headers):
55 network = test_network(name="test_network", cidr="100.100.64.0/24")
56 template_data = schemas.TemplateCreate(
57 name="test_template",
58 network_name=network.name,
59 is_lighthouse=True,
60 is_relay=True,
61 use_relay=False,
62 )
63 response = client.post(
64 "/api/v1/templates",
65 data=template_data.model_dump_json(),
66 content_type="application/json",
67 **keycloak_auth_headers,
68 )
69 assert response.status_code == 200
70 response_data = response.json()
71 assert response_data["name"] == "test_template"
72 assert "enrollment_key" in response_data
74 # Delete template
75 response = client.delete(
76 "/api/v1/templates/test_template",
77 **keycloak_auth_headers,
78 )
79 assert response.status_code == 200
80 assert not Template.objects.filter(name="test_template").exists()
83def test_host_endpoints(client, test_network, keycloak_auth_headers):
84 network = test_network(name="test_network", cidr="100.100.64.0/24")
85 # First create and enroll a host
86 template = create_template(
87 "host_template",
88 network.name,
89 is_lighthouse=False,
90 is_relay=False,
91 )
92 token = generate_enrollment_token(template)
93 auth_key = JWK.generate(kty="RSA", size=2048)
94 _, public_net_key = create_keys()
95 enrollment_data = schemas.ClientEnrollment(
96 enrollment_key=token,
97 public_net_key=public_net_key,
98 public_auth_key=auth_key.export_public(),
99 preferred_hostname="test-host",
100 public_ip="127.0.0.1",
101 enroll_on_existence=False,
102 )
103 response = client.post(
104 "/api/v1/enroll",
105 data=enrollment_data.model_dump_json(),
106 content_type="application/json",
107 )
108 assert response.status_code == 200
110 # Delete host
111 response = client.delete(
112 "/api/v1/hosts/test-host",
113 **keycloak_auth_headers,
114 )
115 assert response.status_code == 200
116 assert not Host.objects.filter(name="test-host").exists()
119def test_unauthorized_access(db, client):
120 endpoints = [
121 ("POST", "/api/v1/networks"),
122 ("GET", "/api/v1/networks"),
123 ("DELETE", "/api/v1/networks/test"),
124 ("POST", "/api/v1/templates"),
125 ("DELETE", "/api/v1/templates/test"),
126 ("DELETE", "/api/v1/hosts/test"),
127 ]
129 for method, endpoint in endpoints:
130 if method == "POST":
131 response = client.post(
132 endpoint,
133 data="{}",
134 content_type="application/json",
135 )
136 else:
137 response = (
138 client.get(endpoint) if method == "GET" else client.delete(endpoint)
139 )
140 assert response.status_code == 401, f"{method} {endpoint} should require auth"
143def test_wrong_client_id(db, client, keycloak_key, settings):
144 jwt_token = JWT(
145 header={"alg": "RS256", "kid": keycloak_key["kid"]},
146 claims={
147 "exp": 9999999999,
148 "iat": 1741328648,
149 "iss": settings.KEYCLOAK_ISSUER,
150 "azp": "wrong-client", # Not admin-cli
151 "typ": "Bearer",
152 },
153 )
154 jwt_token.make_signed_token(keycloak_key["private_key"])
155 headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}
156 response = client.get("/api/v1/networks", **headers)
157 assert response.status_code == 401
160def test_wrong_signature(db, client, keycloak_key, mocker):
161 different_key = JWK.generate(kty="RSA", size=2048)
162 jwt_token = JWT(
163 header={"alg": "RS256", "kid": keycloak_key["kid"]},
164 claims={
165 "exp": 9999999999,
166 "iat": 1741328648,
167 "iss": "http://localhost:8080/realms/meshadmin",
168 "azp": "admin-cli",
169 "typ": "Bearer",
170 },
171 )
172 jwt_token.make_signed_token(different_key)
173 mock_response = mocker.Mock()
174 mock_response.json.return_value = {"keys": [keycloak_key["public_key"]]}
175 mock_response.raise_for_status.return_value = None
176 mocker.patch("requests.get", return_value=mock_response)
177 headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}
178 response = client.get("/api/v1/networks", **headers)
179 assert response.status_code == 401
182def test_get_config(db, client, test_network):
183 network = test_network(name="test_network", cidr="10.0.0.0/24")
184 auth_key = JWK.generate(kty="RSA", size=2048)
185 public_auth_key = auth_key.export_public()
186 _, public_key = create_keys()
188 host = Host.objects.create(
189 network=network,
190 name="test-host",
191 assigned_ip="10.0.0.1",
192 public_key=public_key,
193 public_auth_key=public_auth_key,
194 public_auth_kid=auth_key.thumbprint(),
195 )
196 jwt_token = JWT(
197 header={"alg": "RS256", "kid": auth_key.thumbprint()},
198 claims={
199 "exp": 9999999999,
200 "kid": auth_key.thumbprint(),
201 },
202 )
203 jwt_token.make_signed_token(auth_key)
204 host_auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}
205 response = client.get(
206 "/api/v1/config",
207 **host_auth_headers,
208 )
210 assert response.status_code == 200
211 assert response["Content-Type"] == "text/yaml"
212 host.refresh_from_db()
213 assert host.last_config_refresh is not None
214 assert host.last_config_refresh > timezone.now() - timedelta(minutes=1)
217def test_cleanup_ephemeral_hosts(db, client, test_network):
218 network = test_network(name="test_network", cidr="10.0.0.0/24")
219 auth_key = JWK.generate(kty="RSA", size=2048)
220 public_auth_key = auth_key.export_public()
221 _, public_key = create_keys()
223 Host.objects.create(
224 network=network,
225 name="requesting-host",
226 assigned_ip="10.0.0.10",
227 public_key=public_key,
228 public_auth_key=public_auth_key,
229 public_auth_kid=auth_key.thumbprint(),
230 )
231 jwt_token = JWT(
232 header={"alg": "RS256", "kid": auth_key.thumbprint()},
233 claims={
234 "exp": 9999999999,
235 "kid": auth_key.thumbprint(),
236 },
237 )
238 jwt_token.make_signed_token(auth_key)
239 host_auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}
241 # Create some ephemeral hosts
242 # 1. A stale host (last_config_refresh > 10 minutes ago)
243 stale_host = Host.objects.create(
244 network=network,
245 name="stale-host",
246 assigned_ip="10.0.0.1",
247 public_key="test-key-1",
248 public_auth_key="{}",
249 public_auth_kid="test-kid-1",
250 is_ephemeral=True,
251 last_config_refresh=timezone.now() - timedelta(minutes=15),
252 )
254 # 2. A recently seen ephemeral host (should not be removed)
255 recent_host = Host.objects.create(
256 network=network,
257 name="recent-host",
258 assigned_ip="10.0.0.2",
259 public_key="test-key-2",
260 public_auth_key="{}",
261 public_auth_kid="test-kid-2",
262 is_ephemeral=True,
263 last_config_refresh=timezone.now() - timedelta(minutes=5),
264 )
266 # 3. A non-ephemeral host (should not be removed regardless of last_config_refresh)
267 non_ephemeral_host = Host.objects.create(
268 network=network,
269 name="non-ephemeral-host",
270 assigned_ip="10.0.0.3",
271 public_key="test-key-3",
272 public_auth_key="{}",
273 public_auth_kid="test-kid-3",
274 is_ephemeral=False,
275 last_config_refresh=timezone.now() - timedelta(minutes=30),
276 )
278 response = client.post(
279 "/api/v1/cleanup-ephemeral",
280 **host_auth_headers,
281 )
283 assert response.status_code == 200
284 result = response.json()
285 assert result["removed_count"] == 1
286 assert not Host.objects.filter(id=stale_host.id).exists()
287 assert Host.objects.filter(id=recent_host.id).exists()
288 assert Host.objects.filter(id=non_ephemeral_host.id).exists()
290 # Call the endpoint again - should remove no hosts
291 response = client.post(
292 "/api/v1/cleanup-ephemeral",
293 **host_auth_headers,
294 )
295 assert response.status_code == 200
296 result = response.json()
297 assert result["removed_count"] == 0
300def test_enrollment_api_with_jwt(test_network, client):
301 network = test_network(name="testnet", cidr="10.0.0.0/24")
302 template = create_template(
303 "jwt_template",
304 network.name,
305 )
306 token = generate_enrollment_token(template)
307 auth_key = JWK.generate(kty="RSA", size=2048)
308 _, public_net_key = create_keys()
309 enrollment_data = schemas.ClientEnrollment(
310 enrollment_key=token,
311 public_net_key=public_net_key,
312 public_auth_key=auth_key.export_public(),
313 preferred_hostname="test-host",
314 public_ip="127.0.0.1",
315 enroll_on_existence=False,
316 )
317 response = client.post(
318 "/api/v1/enroll",
319 data=enrollment_data.model_dump_json(),
320 content_type="application/json",
321 )
322 assert response.status_code == 200
323 assert Host.objects.filter(name="test-host").exists()
326def test_download_nebula_binary(client, mocker):
327 mock_content = b"mock binary content"
328 mock_file = mocker.mock_open(read_data=mock_content)
329 mocker.patch("builtins.open", mock_file)
330 mocker.patch("meshadmin.server.assets.asset_path")
331 mocker.patch.object(Path, "exists", return_value=True)
333 # Test valid download for Linux (x86_64)
334 response = client.get("/api/v1/nebula/download/Linux/x86_64/nebula")
335 assert response.status_code == 200
336 assert response["Content-Type"] == "application/octet-stream"
337 assert response["Content-Disposition"] == 'attachment; filename="nebula"'
338 content = b"".join(response.streaming_content)
339 assert content == mock_content
341 # Test valid download for Linux (aarch64)
342 response = client.get("/api/v1/nebula/download/Linux/aarch64/nebula")
343 assert response.status_code == 200
345 # Test valid download for Darwin (arm64)
346 response = client.get("/api/v1/nebula/download/Darwin/arm64/nebula-cert")
347 assert response.status_code == 200
349 # Test invalid OS
350 response = client.get("/api/v1/nebula/download/Windows/x86_64/nebula")
351 assert response.status_code == 400
352 assert "Only Linux and Darwin are supported" in str(response.content)
354 # Test invalid architecture for Darwin
355 response = client.get("/api/v1/nebula/download/Darwin/x86_64/nebula")
356 assert response.status_code == 400
357 assert "Supported architectures for Darwin: ['arm64']" in str(response.content)
359 # Test invalid architecture for Linux
360 response = client.get("/api/v1/nebula/download/Linux/arm64/nebula")
361 assert response.status_code == 400
362 assert "Supported architectures for Linux: ['aarch64', 'x86_64']" in str(
363 response.content
364 )
366 # Test invalid binary name
367 response = client.get("/api/v1/nebula/download/Linux/x86_64/invalid")
368 assert response.status_code == 400
370 # Test binary not found
371 mocker.patch.object(Path, "exists", return_value=False)
372 response = client.get("/api/v1/nebula/download/Linux/x86_64/nebula")
373 assert response.status_code == 404
374 assert "Binary not found" in str(response.content)