switchgear_service/discovery/
service.rs

1use crate::api::discovery::DiscoveryBackendStore;
2use crate::api::service::StatusCode;
3use crate::axum::auth::BearerTokenAuthLayer;
4use crate::discovery::auth::DiscoveryBearerTokenValidator;
5use crate::discovery::handler::DiscoveryHandlers;
6use crate::discovery::state::DiscoveryState;
7use axum::routing::{delete, get, patch, post, put};
8use axum::Router;
9
10#[derive(Debug)]
11pub struct DiscoveryService;
12
13impl DiscoveryService {
14    pub fn router<S>(state: DiscoveryState<S>) -> Router
15    where
16        S: DiscoveryBackendStore + Clone + Send + Sync + 'static,
17    {
18        Router::new()
19            .route(
20                "/discovery/{addr_variant}/{addr_value}",
21                get(DiscoveryHandlers::get_backend),
22            )
23            .route(
24                "/discovery/{addr_variant}/{addr_value}",
25                put(DiscoveryHandlers::put_backend),
26            )
27            .route(
28                "/discovery/{addr_variant}/{addr_value}",
29                patch(DiscoveryHandlers::patch_backend),
30            )
31            .route(
32                "/discovery/{addr_variant}/{addr_value}",
33                delete(DiscoveryHandlers::delete_backend),
34            )
35            .route("/discovery", get(DiscoveryHandlers::get_backends))
36            .route("/discovery", post(DiscoveryHandlers::post_backend))
37            .layer(BearerTokenAuthLayer::new(
38                DiscoveryBearerTokenValidator::new(state.auth_authority().clone()),
39                "discovery",
40            ))
41            .route("/health", get(Self::health_check_handler))
42            .with_state(state)
43    }
44
45    async fn health_check_handler() -> StatusCode {
46        StatusCode::OK
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use crate::api::discovery::{
53        DiscoveryBackend, DiscoveryBackendAddress, DiscoveryBackendImplementation,
54        DiscoveryBackendPatchSparse, DiscoveryBackendSparse,
55    };
56    use crate::components::discovery::memory::MemoryDiscoveryBackendStore;
57    use crate::discovery::auth::{DiscoveryAudience, DiscoveryClaims};
58    use crate::discovery::service::DiscoveryService;
59    use crate::discovery::state::DiscoveryState;
60    use axum::http::StatusCode;
61    use axum_test::TestServer;
62    use jsonwebtoken::{encode, Algorithm, DecodingKey, EncodingKey, Header};
63    use p256::ecdsa::SigningKey;
64    use p256::pkcs8::EncodePrivateKey;
65    use p256::pkcs8::EncodePublicKey;
66    use rand::thread_rng;
67    use std::time::{SystemTime, UNIX_EPOCH};
68
69    fn create_test_backend(partition: &str, address: &str) -> DiscoveryBackend {
70        DiscoveryBackend {
71            address: DiscoveryBackendAddress::Url(format!("https://{address}").parse().unwrap()),
72            backend: DiscoveryBackendSparse {
73                name: None,
74                partitions: [partition.to_string()].into(),
75                weight: 100,
76                enabled: true,
77                implementation: DiscoveryBackendImplementation::RemoteHttp,
78            },
79        }
80    }
81
82    struct TestServerWithAuthorization {
83        server: TestServer,
84        authorization: String,
85    }
86
87    async fn setup_test_server() -> TestServerWithAuthorization {
88        let mut rng = thread_rng();
89        let private_key = SigningKey::random(&mut rng);
90        let public_key = *private_key.verifying_key();
91
92        let private_key = private_key
93            .to_pkcs8_pem(p256::pkcs8::LineEnding::default())
94            .unwrap();
95        let encoding_key = EncodingKey::from_ec_pem(private_key.as_bytes()).unwrap();
96
97        let public_key = public_key
98            .to_public_key_pem(p256::pkcs8::LineEnding::default())
99            .unwrap();
100        let decoding_key = DecodingKey::from_ec_pem(public_key.as_bytes()).unwrap();
101
102        let store = MemoryDiscoveryBackendStore::new();
103        let state = DiscoveryState::new(store, decoding_key);
104
105        let header = Header::new(Algorithm::ES256);
106        let claims = DiscoveryClaims {
107            aud: DiscoveryAudience::Discovery,
108            exp: (SystemTime::now()
109                .duration_since(UNIX_EPOCH)
110                .unwrap()
111                .as_secs()
112                + 3600) as usize,
113        };
114        let authorization = encode(&header, &claims, &encoding_key).unwrap();
115
116        let app = DiscoveryService::router(state);
117        TestServerWithAuthorization {
118            server: TestServer::new(app).unwrap(),
119            authorization,
120        }
121    }
122
123    #[tokio::test]
124    async fn health_check_when_called_then_returns_ok() {
125        let server = setup_test_server().await;
126
127        let response = server.server.get("/health").await;
128
129        assert_eq!(response.status_code(), StatusCode::OK);
130        // Health check returns empty body with 200 status
131        assert_eq!(response.text(), "");
132    }
133
134    #[tokio::test]
135    async fn get_backends_when_empty_then_returns_empty_list() {
136        let server = setup_test_server().await;
137
138        let response = server
139            .server
140            .get("/discovery")
141            .authorization_bearer(server.authorization.clone())
142            .await;
143
144        assert_eq!(response.status_code(), StatusCode::OK);
145        let backends: Vec<DiscoveryBackend> = response.json();
146        assert!(backends.is_empty());
147
148        // Verify cache headers
149        assert_eq!(
150            response.header("cache-control"),
151            "no-store, no-cache, must-revalidate"
152        );
153        assert_eq!(response.header("expires"), "Thu, 01 Jan 1970 00:00:00 GMT");
154        assert_eq!(response.header("pragma"), "no-cache");
155    }
156
157    #[tokio::test]
158    async fn post_backend_when_new_then_creates_and_returns_location() {
159        let server = setup_test_server().await;
160        let backend = create_test_backend("default", "192.168.1.1:8080");
161
162        let response = server
163            .server
164            .post("/discovery")
165            .authorization_bearer(server.authorization.clone())
166            .json(&backend)
167            .await;
168
169        assert_eq!(response.status_code(), StatusCode::CREATED);
170        let location = response.header("location");
171        assert!(location.to_str().unwrap().contains("url/"));
172    }
173
174    #[tokio::test]
175    async fn post_backend_when_duplicate_then_returns_conflict() {
176        let server = setup_test_server().await;
177        let backend = create_test_backend("default", "192.168.1.1:8080");
178
179        // First POST should succeed
180        let response1 = server
181            .server
182            .post("/discovery")
183            .authorization_bearer(server.authorization.clone())
184            .json(&backend)
185            .await;
186        assert_eq!(response1.status_code(), StatusCode::CREATED);
187
188        // Second POST with same address should conflict
189        let response2 = server
190            .server
191            .post("/discovery")
192            .authorization_bearer(server.authorization.clone())
193            .json(&backend)
194            .await;
195        assert_eq!(response2.status_code(), StatusCode::CONFLICT);
196    }
197
198    #[tokio::test]
199    async fn get_backend_when_exists_then_returns_backend() {
200        let server = setup_test_server().await;
201        let backend = create_test_backend("default", "192.168.1.1:8080");
202
203        // First create the backend
204        let response = server
205            .server
206            .post("/discovery")
207            .authorization_bearer(server.authorization.clone())
208            .json(&backend)
209            .await;
210
211        let location = response.header("location");
212        let location = location.to_str().unwrap();
213
214        // Then retrieve it
215        let response = server
216            .server
217            .get(format!("/discovery/{location}").as_str())
218            .authorization_bearer(server.authorization.clone())
219            .await;
220
221        assert_eq!(response.status_code(), StatusCode::OK);
222        let retrieved: DiscoveryBackend = response.json();
223        assert_eq!(
224            retrieved.backend.implementation,
225            DiscoveryBackendImplementation::RemoteHttp
226        );
227        assert_eq!(retrieved.address, backend.address);
228    }
229
230    #[tokio::test]
231    async fn get_backend_when_not_exists_then_returns_not_found() {
232        let server = setup_test_server().await;
233
234        let response = server
235            .server
236            .get("/discovery/default/inet/MTkyLjE2OC4xLjE6ODA4MA")
237            .authorization_bearer(server.authorization.clone())
238            .await;
239
240        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
241    }
242
243    #[tokio::test]
244    async fn put_backend_when_new_then_created() {
245        let server = setup_test_server().await;
246        let backend = create_test_backend("default", "192.168.1.1:8080");
247
248        let response = server
249            .server
250            .put("/discovery/url/aHR0cHM6Ly8xOTIuMTY4LjEuMTo4MDgwLw")
251            .authorization_bearer(server.authorization.clone())
252            .json(&backend.backend)
253            .await;
254
255        assert_eq!(response.status_code(), StatusCode::CREATED);
256    }
257
258    #[tokio::test]
259    async fn put_backend_when_exists_then_updates_no_content() {
260        let server = setup_test_server().await;
261        let mut backend = create_test_backend("default", "192.168.1.1:8080");
262
263        // Create initial backend
264        let response = server
265            .server
266            .post("/discovery")
267            .authorization_bearer(server.authorization.clone())
268            .json(&backend)
269            .await;
270
271        let location = response.header("location");
272        let location = location.to_str().unwrap();
273
274        // Update with PUT
275        backend.backend.weight = 200;
276        let response = server
277            .server
278            .put(&format!("/discovery/{location}"))
279            .authorization_bearer(server.authorization.clone())
280            .json(&backend.backend)
281            .await;
282
283        assert_eq!(response.status_code(), StatusCode::NO_CONTENT);
284
285        // Verify the update
286        let get_response = server
287            .server
288            .get(&format!("/discovery/{location}"))
289            .authorization_bearer(server.authorization.clone())
290            .await;
291        let updated: DiscoveryBackend = get_response.json();
292        assert_eq!(updated.backend.weight, 200);
293    }
294
295    #[tokio::test]
296    async fn patch_backend_then_no_content() {
297        let server = setup_test_server().await;
298        let mut backend = create_test_backend("default", "192.168.1.1:8080");
299
300        // Create initial backend
301        let response = server
302            .server
303            .post("/discovery")
304            .authorization_bearer(server.authorization.clone())
305            .json(&backend)
306            .await;
307
308        let location = response.header("location");
309        let location = location.to_str().unwrap();
310
311        let patch = DiscoveryBackendPatchSparse {
312            name: None,
313            partitions: None,
314            weight: Some(200),
315            enabled: None,
316        };
317        // Update with PATCH
318        backend.backend.weight = 200;
319        let response = server
320            .server
321            .patch(&format!("/discovery/{location}"))
322            .authorization_bearer(server.authorization.clone())
323            .json(&patch)
324            .await;
325
326        assert_eq!(response.status_code(), StatusCode::NO_CONTENT);
327
328        // Verify the update
329        let get_response = server
330            .server
331            .get(&format!("/discovery/{location}"))
332            .authorization_bearer(server.authorization.clone())
333            .await;
334        let updated: DiscoveryBackend = get_response.json();
335        assert_eq!(updated.backend.weight, 200);
336    }
337
338    #[tokio::test]
339    async fn patch_missing_backend_then_not_found() {
340        let server = setup_test_server().await;
341        let mut backend = create_test_backend("default", "192.168.1.1:8080");
342
343        let location = backend.address.encoded();
344
345        let patch = DiscoveryBackendPatchSparse {
346            name: None,
347            partitions: None,
348            weight: Some(200),
349            enabled: None,
350        };
351        // Update with PATCH
352        backend.backend.weight = 200;
353        let response = server
354            .server
355            .patch(&format!("/discovery/{location}"))
356            .authorization_bearer(server.authorization.clone())
357            .json(&patch)
358            .await;
359
360        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
361    }
362
363    #[tokio::test]
364    async fn delete_backend_when_exists_then_removes_and_returns_backend() {
365        let server = setup_test_server().await;
366        let backend = create_test_backend("default", "192.168.1.1:8080");
367
368        // Create backend
369        let response = server
370            .server
371            .post("/discovery")
372            .authorization_bearer(server.authorization.clone())
373            .json(&backend)
374            .await;
375        let location = response.header("location");
376        let location = location.to_str().unwrap();
377
378        // Delete backend
379        let response = server
380            .server
381            .delete(&format!("/discovery/{location}"))
382            .authorization_bearer(server.authorization.clone())
383            .await;
384        eprintln!("location: {location}");
385
386        assert_eq!(response.status_code(), StatusCode::NO_CONTENT);
387        // Delete returns empty body, no JSON to parse
388
389        // Verify it's gone
390        let get_response = server
391            .server
392            .get(&format!("/discovery/{location}"))
393            .authorization_bearer(server.authorization.clone())
394            .await;
395        assert_eq!(get_response.status_code(), StatusCode::NOT_FOUND);
396    }
397
398    #[tokio::test]
399    async fn delete_backend_when_not_exists_then_returns_not_found() {
400        let server = setup_test_server().await;
401
402        let response = server
403            .server
404            .delete("/discovery/default/inet/MTkyLjE2OC4xLjE6ODA4MA")
405            .authorization_bearer(server.authorization.clone())
406            .await;
407
408        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
409    }
410
411    #[tokio::test]
412    async fn get_backends_when_multiple_exist_then_returns_all() {
413        let server = setup_test_server().await;
414        let backend1 = create_test_backend("default", "192.168.1.1:8080");
415        let backend2 = create_test_backend("default", "192.168.1.2:8080");
416
417        // Create multiple backends
418        server
419            .server
420            .post("/discovery")
421            .authorization_bearer(server.authorization.clone())
422            .json(&backend1)
423            .await;
424        server
425            .server
426            .post("/discovery")
427            .authorization_bearer(server.authorization.clone())
428            .json(&backend2)
429            .await;
430
431        // Get all backends
432        let response = server
433            .server
434            .get("/discovery")
435            .authorization_bearer(server.authorization.clone())
436            .await;
437
438        assert_eq!(response.status_code(), StatusCode::OK);
439        let backends: Vec<DiscoveryBackend> = response.json();
440        assert_eq!(backends.len(), 2);
441    }
442
443    #[tokio::test]
444    async fn api_when_invalid_json_then_returns_bad_request() {
445        let server = setup_test_server().await;
446
447        let response = server
448            .server
449            .post("/discovery")
450            .authorization_bearer(server.authorization.clone())
451            .text("invalid json")
452            .await;
453
454        assert_eq!(response.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
455    }
456
457    #[tokio::test]
458    async fn api_when_invalid_address_encoding_then_returns_bad_request() {
459        let server = setup_test_server().await;
460
461        let response = server
462            .server
463            .get("/discovery/default/inet/invalid_base64")
464            .authorization_bearer(server.authorization.clone())
465            .await;
466
467        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
468    }
469
470    #[tokio::test]
471    async fn api_when_unsupported_variant_then_returns_bad_request() {
472        let server = setup_test_server().await;
473
474        let response = server
475            .server
476            .get("/discovery/default/unsupported/dGVzdA")
477            .authorization_bearer(server.authorization.clone())
478            .await;
479
480        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
481    }
482
483    #[tokio::test]
484    async fn unauthorized() {
485        let server = setup_test_server().await;
486        let backend = create_test_backend("default", "192.168.1.1:8080");
487
488        let response = server.server.post("/discovery").json(&backend).await;
489
490        assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
491
492        let response = server
493            .server
494            .get("/discovery/default/inet/MTkyLjE2OC4xLjE6ODA4MA")
495            .await;
496
497        assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
498
499        let response = server
500            .server
501            .put("/discovery/default/inet/MTkyLjE2OC4xLjE6ODA4MA")
502            .json(&backend)
503            .await;
504
505        assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
506
507        let response = server.server.delete("/discovery/default").await;
508
509        assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
510    }
511}