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

1import json 

2from datetime import timedelta 

3from pathlib import Path 

4 

5import pytest 

6from django.utils import timezone 

7from jwcrypto.jwk import JWK 

8from jwcrypto.jwt import JWT 

9 

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) 

17 

18 

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 } 

27 

28 

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 

46 

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()}"} 

52 

53 

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 

73 

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() 

81 

82 

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 

109 

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() 

117 

118 

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 ] 

128 

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" 

141 

142 

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 

158 

159 

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 

180 

181 

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() 

187 

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 ) 

209 

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) 

215 

216 

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() 

222 

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()}"} 

240 

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 ) 

253 

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 ) 

265 

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 ) 

277 

278 response = client.post( 

279 "/api/v1/cleanup-ephemeral", 

280 **host_auth_headers, 

281 ) 

282 

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() 

289 

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 

298 

299 

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() 

324 

325 

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) 

332 

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 

340 

341 # Test valid download for Linux (aarch64) 

342 response = client.get("/api/v1/nebula/download/Linux/aarch64/nebula") 

343 assert response.status_code == 200 

344 

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 

348 

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) 

353 

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) 

358 

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 ) 

365 

366 # Test invalid binary name 

367 response = client.get("/api/v1/nebula/download/Linux/x86_64/invalid") 

368 assert response.status_code == 400 

369 

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)