Skip to main content

acme_proxy/ipam/netbox/
client.rs

1//! The production [`NetboxApi`]: NetBox's REST API over HTTP/1.1.
2//!
3//! Five queries, of which one always runs and four are gated by
4//! [`Source`](crate::ipam::Source):
5//!
6//! | Query | Source |
7//! | --- | --- |
8//! | `ipam/ip-addresses/?address=…` | always |
9//! | `dcim/devices/{id}/`, `virtualization/virtual-machines/{id}/` | `device` |
10//! | `ipam/ip-addresses/?device_id=…&role=…` | `vip` |
11//! | `ipam/fhrp-group-assignments/?interface_type=…&interface_id=…` | `fhrp` |
12//! | `ipam/ip-addresses/?fhrpgroup_id=…` | `fhrp` |
13//!
14//! The transport, the body cap, the TLS policy and the error shape all live in
15//! [`ipam::http`](crate::ipam::http), which both backends share. What is here
16//! is NetBox's own paths, filters and wire shapes.
17
18use std::net::IpAddr;
19
20use async_trait::async_trait;
21use serde::Deserialize;
22use serde_json::{Map, Value};
23use tracing::debug;
24use url::form_urlencoded::Serializer;
25
26use super::{AssignedKind, AssignedRef, NetboxApi, NetboxIp};
27use crate::config::NetboxConfig;
28use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
29
30/// NetBox's own labels for the two interface types an address can hang off.
31pub(super) const DEVICE_INTERFACE: &str = "dcim.interface";
32pub(super) const VM_INTERFACE: &str = "virtualization.vminterface";
33
34/// A NetBox REST client.
35#[derive(Debug)]
36pub struct NetboxClient {
37    api: JsonApi,
38}
39
40impl NetboxClient {
41    /// Validates the URL and builds the TLS configuration. No network yet.
42    pub fn new(cfg: &NetboxConfig, outbound: crate::http_client::Outbound) -> anyhow::Result<Self> {
43        anyhow::ensure!(
44            !cfg.url.trim().is_empty(),
45            "ipam.backend is `netbox` but ipam.netbox.url is empty; give the base URL of the \
46             NetBox instance"
47        );
48        anyhow::ensure!(
49            !cfg.token.trim().is_empty(),
50            "ipam.backend is `netbox` but ipam.netbox.token is empty; supply a NetBox API \
51             token, preferably through ACME_PROXY_IPAM__NETBOX__TOKEN"
52        );
53
54        Ok(Self {
55            api: JsonApi::new(
56                &cfg.url,
57                "ipam.netbox.url",
58                vec![(hyper::header::AUTHORIZATION, format!("Token {}", cfg.token))],
59                tls_config(
60                    &cfg.ca_cert_path,
61                    cfg.insecure_skip_verify,
62                    "ipam.netbox.ca_cert_path",
63                )?,
64                outbound,
65            )?,
66        })
67    }
68
69    /// A `GET` whose every failure is a failure — NetBox has no status this
70    /// backend reads as an answer.
71    async fn get(&self, path_and_query: &str) -> Result<Value, String> {
72        self.api
73            .get(path_and_query)
74            .await
75            .map_err(|error: JsonApiError| error.message)
76    }
77
78    /// The address list endpoint, with a pre-built query string.
79    async fn addresses(&self, query: &str, what: &str) -> Result<Vec<NetboxIp>, String> {
80        let body = self
81            .get(&format!("/api/ipam/ip-addresses/?{query}"))
82            .await?;
83        let parsed: IpListResponse = serde_json::from_value(body)
84            .map_err(|error| format!("unexpected {what} response: {error}"))?;
85        Ok(parsed.results.into_iter().map(NetboxIp::from).collect())
86    }
87}
88
89// ------------------------------------------------------- the wire shapes
90
91/// The `ipam/ip-addresses` list response, reduced to what is read.
92#[derive(Debug, Deserialize)]
93struct IpListResponse {
94    #[serde(default)]
95    results: Vec<IpResult>,
96}
97
98#[derive(Debug, Deserialize)]
99struct IpResult {
100    #[serde(default)]
101    dns_name: String,
102    #[serde(default)]
103    custom_fields: Map<String, Value>,
104    #[serde(default)]
105    assigned_object_type: Option<String>,
106    #[serde(default)]
107    assigned_object: Option<Value>,
108    /// NetBox renders a choice field as `{"value": "vrrp", "label": "VRRP"}`.
109    #[serde(default)]
110    role: Option<Choice>,
111}
112
113/// One NetBox choice field. Only `value` is read — `label` is display text and
114/// is localized, so comparing against it would break on a translated instance.
115#[derive(Debug, Deserialize)]
116struct Choice {
117    #[serde(default)]
118    value: String,
119}
120
121/// A detail response, of which only the custom fields matter.
122#[derive(Debug, Deserialize)]
123struct ObjectResponse {
124    #[serde(default)]
125    custom_fields: Map<String, Value>,
126}
127
128/// The `ipam/fhrp-group-assignments` list response.
129///
130/// One row per (interface, group) pair. Filtering it by `interface_id` is the
131/// membership question, and the only way this backend ever learns a group id.
132#[derive(Debug, Deserialize)]
133struct AssignmentListResponse {
134    #[serde(default)]
135    results: Vec<AssignmentResult>,
136}
137
138#[derive(Debug, Deserialize)]
139struct AssignmentResult {
140    #[serde(default)]
141    group: Option<Nested>,
142}
143
144/// Any nested NetBox object, of which only the id is read.
145#[derive(Debug, Deserialize)]
146struct Nested {
147    id: u64,
148}
149
150impl From<IpResult> for NetboxIp {
151    fn from(result: IpResult) -> Self {
152        Self {
153            dns_name: result.dns_name,
154            custom_fields: result.custom_fields,
155            assigned: assigned_ref(
156                result.assigned_object_type.as_deref(),
157                result.assigned_object.as_ref(),
158            ),
159            role: result.role.map(|choice| choice.value),
160        }
161    }
162}
163
164/// Digs the device or VM — and the interface — out of an address's assignment.
165///
166/// NetBox nests it: an address points at an *interface*, and the interface
167/// carries the device (or virtual machine) it belongs to. The brief serializer
168/// the list endpoint uses includes that nested object, so both ids are known
169/// without an extra request — which is what keeps the FHRP membership query
170/// down to one round trip.
171///
172/// An address assigned to an FHRP group itself (`ipam.fhrpgroup`) yields
173/// `None`, and rightly so: a client connecting *from* the VIP already got that
174/// object's own names out of the first query, and there is no device to scope
175/// anything else to.
176fn assigned_ref(typ: Option<&str>, object: Option<&Value>) -> Option<AssignedRef> {
177    let (kind, field) = match typ? {
178        DEVICE_INTERFACE => (AssignedKind::Device, "device"),
179        VM_INTERFACE => (AssignedKind::VirtualMachine, "virtual_machine"),
180        other => {
181            debug!(
182                event = "ipam_netbox_assignment_ignored",
183                outcome = "advisory",
184                assigned_object_type = other,
185                "address is assigned to an object with no machine behind it"
186            );
187            return None;
188        }
189    };
190
191    let object = object?;
192    let interface_id = object.get("id")?.as_u64()?;
193    let id = object.get(field)?.get("id")?.as_u64()?;
194    Some(AssignedRef {
195        kind,
196        id,
197        interface_id,
198    })
199}
200
201#[async_trait]
202impl NetboxApi for NetboxClient {
203    async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
204        let query = Serializer::new(String::new())
205            .append_pair("address", &ip.to_string())
206            .finish();
207        self.addresses(&query, "ip-addresses").await
208    }
209
210    async fn object_custom_fields(
211        &self,
212        reference: &AssignedRef,
213    ) -> Result<Map<String, Value>, String> {
214        let path = match reference.kind {
215            AssignedKind::Device => format!("/api/dcim/devices/{}/", reference.id),
216            AssignedKind::VirtualMachine => {
217                format!("/api/virtualization/virtual-machines/{}/", reference.id)
218            }
219        };
220
221        let body = self.get(&path).await?;
222        let parsed: ObjectResponse = serde_json::from_value(body)
223            .map_err(|error| format!("unexpected response for {path}: {error}"))?;
224
225        Ok(parsed.custom_fields)
226    }
227
228    async fn shared_addresses(
229        &self,
230        reference: &AssignedRef,
231        roles: &[String],
232    ) -> Result<Vec<NetboxIp>, String> {
233        // `role` is a multiple-choice filter, so it is repeated rather than
234        // comma-joined; NetBox ORs the values. Built and finished inside its
235        // own scope: `Serializer` is not `Send`, so holding one across the
236        // await below would make this future unspawnable.
237        let query = {
238            let mut query = Serializer::new(String::new());
239            query.append_pair(reference.kind.owner_filter(), &reference.id.to_string());
240            for role in roles {
241                query.append_pair("role", role);
242            }
243            query.finish()
244        };
245        self.addresses(&query, "service-address").await
246    }
247
248    async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String> {
249        let query = Serializer::new(String::new())
250            .append_pair("interface_type", reference.kind.interface_type())
251            .append_pair("interface_id", &reference.interface_id.to_string())
252            .finish();
253
254        let body = self
255            .get(&format!("/api/ipam/fhrp-group-assignments/?{query}"))
256            .await?;
257        let parsed: AssignmentListResponse = serde_json::from_value(body)
258            .map_err(|error| format!("unexpected fhrp-group-assignments response: {error}"))?;
259
260        Ok(parsed
261            .results
262            .into_iter()
263            .filter_map(|assignment| assignment.group.map(|group| group.id))
264            .collect())
265    }
266
267    async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String> {
268        // Also a multiple-choice filter, so every group resolves in one query
269        // however many the interface belongs to. Scoped for the same `Send`
270        // reason as `shared_addresses` above.
271        let query = {
272            let mut query = Serializer::new(String::new());
273            for id in group_ids {
274                query.append_pair("fhrpgroup_id", &id.to_string());
275            }
276            query.finish()
277        };
278        self.addresses(&query, "fhrp-group-address").await
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::ipam::http::testing::{
286        closed_port, ok, serve_many, serve_once, serve_once_tls, status, test_resolver,
287    };
288    use serde_json::json;
289
290    fn config(url: &str) -> NetboxConfig {
291        NetboxConfig {
292            url: url.to_string(),
293            token: "t0ken".to_string(),
294            ..NetboxConfig::default()
295        }
296    }
297
298    // ------------------------------------------------------ startup checks
299
300    #[test]
301    fn an_empty_url_is_a_startup_error() {
302        let error = NetboxClient::new(
303            &config("  "),
304            crate::testutil::outbound_with(test_resolver()),
305        )
306        .unwrap_err()
307        .to_string();
308        assert!(error.contains("ipam.netbox.url"), "{error}");
309    }
310
311    #[test]
312    fn an_empty_token_is_a_startup_error() {
313        let cfg = NetboxConfig {
314            token: String::new(),
315            ..config("https://netbox.example.com")
316        };
317        let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
318            .unwrap_err()
319            .to_string();
320        assert!(error.contains("ipam.netbox.token"), "{error}");
321        assert!(error.contains("ACME_PROXY_IPAM__NETBOX__TOKEN"), "{error}");
322    }
323
324    #[test]
325    fn an_unparsable_url_is_a_startup_error() {
326        let error = NetboxClient::new(
327            &config("not a url"),
328            crate::testutil::outbound_with(test_resolver()),
329        )
330        .unwrap_err()
331        .to_string();
332        assert!(error.contains("ipam.netbox.url"), "{error}");
333    }
334
335    #[test]
336    fn a_missing_ca_certificate_is_a_startup_error() {
337        let cfg = NetboxConfig {
338            ca_cert_path: "/nonexistent/netbox-ca.pem".to_string(),
339            ..config("https://netbox.example.com")
340        };
341        let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
342            .unwrap_err()
343            .to_string();
344        assert!(error.contains("ipam.netbox.ca_cert_path"), "{error}");
345    }
346
347    #[test]
348    fn the_debug_impl_never_renders_the_token() {
349        let client = NetboxClient::new(
350            &config("https://netbox.example.com"),
351            crate::testutil::outbound_with(test_resolver()),
352        )
353        .unwrap();
354        let rendered = format!("{client:?}");
355        assert!(!rendered.contains("t0ken"), "{rendered}");
356    }
357
358    // ------------------------------------------------- the assignment digger
359
360    #[test]
361    fn a_device_interface_yields_its_device_and_its_interface() {
362        let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3, "name": "srv1" } });
363        assert_eq!(
364            assigned_ref(Some(DEVICE_INTERFACE), Some(&object)),
365            Some(AssignedRef {
366                kind: AssignedKind::Device,
367                id: 3,
368                interface_id: 7,
369            })
370        );
371    }
372
373    #[test]
374    fn a_vm_interface_yields_its_virtual_machine() {
375        let object = json!({ "id": 9, "virtual_machine": { "id": 11, "name": "vm1" } });
376        assert_eq!(
377            assigned_ref(Some(VM_INTERFACE), Some(&object)),
378            Some(AssignedRef {
379                kind: AssignedKind::VirtualMachine,
380                id: 11,
381                interface_id: 9,
382            })
383        );
384    }
385
386    #[test]
387    fn an_unassigned_or_unreadable_assignment_yields_nothing() {
388        let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3 } });
389        // No type at all, no object, a type with no machine behind it (which is
390        // what an FHRP-group VIP looks like), a device without an id, and an
391        // interface without one.
392        assert_eq!(assigned_ref(None, Some(&object)), None);
393        assert_eq!(assigned_ref(Some(DEVICE_INTERFACE), None), None);
394        assert_eq!(assigned_ref(Some("ipam.fhrpgroup"), Some(&object)), None);
395        assert_eq!(
396            assigned_ref(
397                Some(DEVICE_INTERFACE),
398                Some(&json!({ "id": 7, "device": {} }))
399            ),
400            None
401        );
402        assert_eq!(
403            assigned_ref(
404                Some(DEVICE_INTERFACE),
405                Some(&json!({ "device": { "id": 3 } }))
406            ),
407            None
408        );
409    }
410
411    /// The real client against a loopback listener. A stub `NetboxApi` proves
412    /// the policy; only this proves the request line, the `Host` header and the
413    /// `Authorization` header are what NetBox actually needs.
414    mod loopback {
415        use super::*;
416
417        fn client(port: u16) -> NetboxClient {
418            NetboxClient::new(
419                &config(&format!("http://127.0.0.1:{port}")),
420                crate::testutil::outbound_with(test_resolver()),
421            )
422            .unwrap()
423        }
424
425        fn on_device() -> AssignedRef {
426            AssignedRef {
427                kind: AssignedKind::Device,
428                id: 3,
429                interface_id: 7,
430            }
431        }
432
433        /// A realistic NetBox answer, nested assignment included.
434        fn one_address() -> Value {
435            json!({
436                "count": 1,
437                "results": [{
438                    "id": 12,
439                    "address": "10.0.0.5/24",
440                    "dns_name": "host.example.com",
441                    "custom_fields": { "acme_allowed_names": ["www.example.com"] },
442                    "assigned_object_type": "dcim.interface",
443                    "assigned_object_id": 7,
444                    "assigned_object": { "id": 7, "name": "eth0", "device": { "id": 3 } }
445                }]
446            })
447        }
448
449        #[tokio::test]
450        async fn queries_the_address_and_authenticates() {
451            let (port, server) = serve_once(ok(one_address())).await;
452
453            let objects = client(port)
454                .ip_addresses("10.0.0.5".parse().unwrap())
455                .await
456                .unwrap();
457
458            assert_eq!(objects.len(), 1);
459            assert_eq!(objects[0].dns_name, "host.example.com");
460            assert_eq!(
461                objects[0].custom_fields["acme_allowed_names"],
462                json!(["www.example.com"])
463            );
464            assert_eq!(objects[0].assigned, Some(on_device()));
465
466            let request = server.await.unwrap();
467            assert!(
468                request.starts_with("GET /api/ipam/ip-addresses/?address=10.0.0.5 HTTP/1.1"),
469                "{request}"
470            );
471            assert!(request.contains("authorization: Token t0ken"), "{request}");
472        }
473
474        /// An IPv6 address's colons must survive into the query.
475        #[tokio::test]
476        async fn an_ipv6_address_is_percent_encoded() {
477            let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
478
479            let objects = client(port)
480                .ip_addresses("2001:db8::5".parse().unwrap())
481                .await
482                .unwrap();
483            assert!(objects.is_empty());
484
485            let request = server.await.unwrap();
486            assert!(request.contains("address=2001%3Adb8%3A%3A5"), "{request}");
487        }
488
489        #[tokio::test]
490        async fn a_role_is_read_off_the_choice_object() {
491            let (port, _server) = serve_once(ok(json!({
492                "results": [{ "dns_name": "service.example.com",
493                              "role": { "value": "vrrp", "label": "VRRP" } }]
494            })))
495            .await;
496
497            let objects = client(port)
498                .shared_addresses(&on_device(), &[])
499                .await
500                .unwrap();
501            assert_eq!(objects[0].role.as_deref(), Some("vrrp"));
502        }
503
504        #[tokio::test]
505        async fn fetches_a_devices_custom_fields() {
506            let (port, server) = serve_once(ok(json!({
507                "id": 3,
508                "custom_fields": { "acme_allowed_names": ["machine.example.com"] }
509            })))
510            .await;
511
512            let fields = client(port)
513                .object_custom_fields(&on_device())
514                .await
515                .unwrap();
516
517            assert_eq!(fields["acme_allowed_names"], json!(["machine.example.com"]));
518
519            let request = server.await.unwrap();
520            assert!(
521                request.starts_with("GET /api/dcim/devices/3/ HTTP/1.1"),
522                "{request}"
523            );
524        }
525
526        #[tokio::test]
527        async fn fetches_a_virtual_machines_custom_fields() {
528            let (port, server) = serve_once(ok(json!({ "custom_fields": {} }))).await;
529
530            client(port)
531                .object_custom_fields(&AssignedRef {
532                    kind: AssignedKind::VirtualMachine,
533                    id: 11,
534                    interface_id: 9,
535                })
536                .await
537                .unwrap();
538
539            let request = server.await.unwrap();
540            assert!(
541                request.starts_with("GET /api/virtualization/virtual-machines/11/ HTTP/1.1"),
542                "{request}"
543            );
544        }
545
546        /// `role` is repeated, not comma-joined: NetBox's multiple-choice
547        /// filters OR their values, and a comma-joined one matches nothing.
548        #[tokio::test]
549        async fn service_addresses_are_scoped_to_the_device_and_the_roles() {
550            let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
551
552            client(port)
553                .shared_addresses(&on_device(), &["vip".to_string(), "vrrp".to_string()])
554                .await
555                .unwrap();
556
557            let request = server.await.unwrap();
558            assert!(
559                request.starts_with(
560                    "GET /api/ipam/ip-addresses/?device_id=3&role=vip&role=vrrp HTTP/1.1"
561                ),
562                "{request}"
563            );
564        }
565
566        #[tokio::test]
567        async fn service_addresses_of_a_virtual_machine_use_the_vm_filter() {
568            let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
569
570            client(port)
571                .shared_addresses(
572                    &AssignedRef {
573                        kind: AssignedKind::VirtualMachine,
574                        id: 11,
575                        interface_id: 9,
576                    },
577                    &["vrrp".to_string()],
578                )
579                .await
580                .unwrap();
581
582            let request = server.await.unwrap();
583            assert!(
584                request.contains("virtual_machine_id=11&role=vrrp"),
585                "{request}"
586            );
587        }
588
589        /// The membership query, and the whole security property of the `fhrp`
590        /// source: it is scoped to the client's **own** interface, never to a
591        /// group or a name.
592        #[tokio::test]
593        async fn membership_is_queried_by_the_clients_own_interface() {
594            let (port, server) = serve_once(ok(json!({
595                "results": [
596                    { "id": 1, "group": { "id": 41, "name": "vrrp-41" } },
597                    { "id": 2, "group": { "id": 42 } }
598                ]
599            })))
600            .await;
601
602            let groups = client(port).fhrp_groups(&on_device()).await.unwrap();
603            assert_eq!(groups, vec![41, 42]);
604
605            let request = server.await.unwrap();
606            assert!(
607                request.starts_with(
608                    "GET /api/ipam/fhrp-group-assignments/\
609                     ?interface_type=dcim.interface&interface_id=7 HTTP/1.1"
610                ),
611                "{request}"
612            );
613        }
614
615        #[tokio::test]
616        async fn membership_of_a_vm_interface_uses_the_vm_interface_type() {
617            let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
618
619            let groups = client(port)
620                .fhrp_groups(&AssignedRef {
621                    kind: AssignedKind::VirtualMachine,
622                    id: 11,
623                    interface_id: 9,
624                })
625                .await
626                .unwrap();
627            assert!(groups.is_empty());
628
629            let request = server.await.unwrap();
630            assert!(
631                request.contains("interface_type=virtualization.vminterface&interface_id=9"),
632                "{request}"
633            );
634        }
635
636        /// However many groups an interface belongs to, their addresses come
637        /// back in one query — `fhrpgroup_id` is a multiple-choice filter.
638        #[tokio::test]
639        async fn every_group_resolves_in_one_query() {
640            let (port, server) = serve_once(ok(json!({
641                "results": [{ "dns_name": "service.example.com" }]
642            })))
643            .await;
644
645            let objects = client(port).fhrp_group_addresses(&[41, 42]).await.unwrap();
646            assert_eq!(objects[0].dns_name, "service.example.com");
647
648            let request = server.await.unwrap();
649            assert!(
650                request.starts_with(
651                    "GET /api/ipam/ip-addresses/?fhrpgroup_id=41&fhrpgroup_id=42 HTTP/1.1"
652                ),
653                "{request}"
654            );
655        }
656
657        /// The two FHRP queries in sequence, over two connections, which is
658        /// what one lookup with `fhrp` on actually costs.
659        #[tokio::test]
660        async fn the_two_fhrp_queries_run_in_order() {
661            let (port, server) = serve_many(vec![
662                ok(json!({ "results": [{ "group": { "id": 41 } }] })),
663                ok(json!({ "results": [{ "dns_name": "service.example.com" }] })),
664            ])
665            .await;
666
667            let client = client(port);
668            let groups = client.fhrp_groups(&on_device()).await.unwrap();
669            let objects = client.fhrp_group_addresses(&groups).await.unwrap();
670            assert_eq!(objects[0].dns_name, "service.example.com");
671
672            let requests = server.await.unwrap();
673            assert!(requests.contains("fhrp-group-assignments"), "{requests}");
674            assert!(requests.contains("fhrpgroup_id=41"), "{requests}");
675        }
676
677        /// A subpath deployment keeps its prefix in the request line.
678        #[tokio::test]
679        async fn a_subpath_base_url_prefixes_the_api_path() {
680            let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
681            let cfg = config(&format!("http://127.0.0.1:{port}/netbox"));
682
683            NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
684                .unwrap()
685                .ip_addresses("10.0.0.5".parse().unwrap())
686                .await
687                .unwrap();
688
689            let request = server.await.unwrap();
690            assert!(
691                request.starts_with("GET /netbox/api/ipam/ip-addresses/?address="),
692                "{request}"
693            );
694        }
695
696        /// A refused token is the operator's problem, not the client's — it
697        /// must surface as an error the caller turns into a 500, never as an
698        /// empty answer that would read as "this address owns no names".
699        #[tokio::test]
700        async fn a_refused_token_is_reported_rather_than_parsed() {
701            let (port, _server) = serve_once(status(
702                401,
703                "Unauthorized",
704                r#"{"detail":"Invalid token header."}"#,
705            ))
706            .await;
707
708            let error = client(port)
709                .ip_addresses("10.0.0.5".parse().unwrap())
710                .await
711                .unwrap_err();
712            assert!(error.contains("401"), "{error}");
713            assert!(error.contains("Invalid token header"), "{error}");
714        }
715
716        /// Unlike phpIPAM, NetBox has no status this backend reads as an
717        /// answer: a 404 is a failure like any other.
718        #[tokio::test]
719        async fn a_404_is_a_failure_for_netbox() {
720            let (port, _server) = serve_once(status(404, "Not Found", "{}")).await;
721
722            let error = client(port)
723                .ip_addresses("10.0.0.5".parse().unwrap())
724                .await
725                .unwrap_err();
726            assert!(error.contains("404"), "{error}");
727        }
728
729        /// A body of the right shape but the wrong types is not an outage —
730        /// still an error, because a wrong answer must not read as "no names".
731        #[tokio::test]
732        async fn a_response_of_the_wrong_shape_is_an_error() {
733            let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
734
735            let error = client(port)
736                .ip_addresses("10.0.0.5".parse().unwrap())
737                .await
738                .unwrap_err();
739            assert!(
740                error.contains("unexpected ip-addresses response"),
741                "{error}"
742            );
743        }
744
745        #[tokio::test]
746        async fn a_malformed_assignment_list_is_an_error() {
747            let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
748
749            let error = client(port).fhrp_groups(&on_device()).await.unwrap_err();
750            assert!(
751                error.contains("unexpected fhrp-group-assignments response"),
752                "{error}"
753            );
754        }
755
756        #[tokio::test]
757        async fn a_malformed_device_response_is_an_error() {
758            let (port, _server) = serve_once(ok(json!({ "custom_fields": 7 }))).await;
759
760            let error = client(port)
761                .object_custom_fields(&on_device())
762                .await
763                .unwrap_err();
764            assert!(error.contains("unexpected response for"), "{error}");
765        }
766
767        #[tokio::test]
768        async fn a_closed_port_is_a_connect_error() {
769            let port = closed_port().await;
770
771            let error = client(port)
772                .ip_addresses("10.0.0.5".parse().unwrap())
773                .await
774                .unwrap_err();
775            assert!(error.contains("connecting to 127.0.0.1"), "{error}");
776        }
777    }
778
779    /// The proof that `insecure_skip_verify` does what it says: the same
780    /// self-signed server is unreachable with verification on and readable with
781    /// it off. Without this the switch would only be declarative.
782    mod tls {
783        use super::*;
784
785        fn https_config(port: u16, skip: bool) -> NetboxConfig {
786            NetboxConfig {
787                insecure_skip_verify: skip,
788                ..config(&format!("https://localhost:{port}"))
789            }
790        }
791
792        #[tokio::test]
793        async fn a_self_signed_netbox_is_refused_by_default() {
794            let port = serve_once_tls(json!({ "results": [] })).await;
795
796            let error = NetboxClient::new(
797                &https_config(port, false),
798                crate::testutil::outbound_with(test_resolver()),
799            )
800            .unwrap()
801            .ip_addresses("10.0.0.5".parse().unwrap())
802            .await
803            .unwrap_err();
804            assert!(error.contains("TLS handshake"), "{error}");
805        }
806
807        #[tokio::test]
808        async fn skipping_verification_reaches_the_same_netbox() {
809            let port = serve_once_tls(json!({
810                "results": [{ "dns_name": "host.example.com", "custom_fields": {} }]
811            }))
812            .await;
813
814            let objects = NetboxClient::new(
815                &https_config(port, true),
816                crate::testutil::outbound_with(test_resolver()),
817            )
818            .unwrap()
819            .ip_addresses("10.0.0.5".parse().unwrap())
820            .await
821            .expect("skip-verify must accept a self-signed certificate");
822            assert_eq!(objects[0].dns_name, "host.example.com");
823        }
824    }
825}