Skip to main content

dnslib/vendors/pangolin/
service.rs

1//! Pangolin implementations of the vendor-neutral DNS service traits.
2//!
3//! Pangolin is a WireGuard reverse-proxy platform, not a traditional DNS server.
4//! The integration is **read-only**:
5//!   - `list_zones`   → GET /org/{orgId}/domains
6//!   - `list_records` → GET /org/{orgId}/domain/{domainId}/dns-records
7//!   - `get_settings` → GET /orgs  (org discovery)
8//!
9//! All write and non-DNS operations return `Error::Unsupported`.
10
11use std::collections::HashSet;
12
13use serde_json::Value;
14use tracing::instrument;
15
16use crate::control_plane::config::VendorKind;
17use crate::core::dns::capabilities::VendorCapabilities;
18use crate::core::dns::records::RecordData;
19use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
20use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
21use crate::core::dns::service::{
22    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
23    RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
24};
25use crate::core::error::{Error, Result};
26use crate::vendors::pangolin::client::PangolinClient;
27use crate::vendors::pangolin::mapping;
28use crate::vendors::pangolin::responses::PangolinDomain;
29
30// ─── PangolinClient helpers ───────────────────────────────────────────────────
31
32impl PangolinClient {
33    /// Fetch DNS records for a single Pangolin domain entry.
34    ///
35    /// When `name_filter` is Some, only records whose `base_domain` matches it
36    /// are included (used when a specific record name is requested within a zone).
37    async fn fetch_zone_records(
38        &self,
39        domain: &PangolinDomain,
40        name_filter: Option<&str>,
41        options: ListRecordsOptions,
42    ) -> Result<crate::core::dns::responses::ZoneRecords> {
43        use crate::core::dns::responses::ZoneRecords;
44
45        let records_data = self
46            .get(
47                &format!(
48                    "/org/{}/domain/{}/dns-records",
49                    self.org_id, domain.domain_id
50                ),
51                &[],
52            )
53            .await?;
54
55        let dns_records = mapping::parse_dns_records(&records_data)?;
56
57        let lookup_names = if options.use_local_ip {
58            dns_records
59                .iter()
60                .filter(|r| matches!(r.record_type.to_uppercase().as_str(), "A" | "AAAA"))
61                .map(|r| r.base_domain.clone())
62                .collect::<HashSet<_>>()
63                .into_iter()
64                .collect::<Vec<_>>()
65        } else {
66            Vec::new()
67        };
68        let resolved = mapping::resolve_local_candidates(&lookup_names).await;
69
70        let records: Vec<ZoneRecord> = dns_records
71            .iter()
72            .filter(|r| r.domain_id == domain.domain_id)
73            .filter(|r| {
74                name_filter
75                    .map(|n| r.base_domain.eq_ignore_ascii_case(n))
76                    .unwrap_or(true)
77            })
78            .map(|r| {
79                mapping::dns_record_to_zone_record(
80                    r,
81                    &domain.base_domain,
82                    resolved
83                        .get(&r.base_domain)
84                        .map(Vec::as_slice)
85                        .unwrap_or(&[]),
86                    options.use_local_ip,
87                )
88            })
89            .collect();
90
91        let zone_info = ZoneInfo {
92            id: Some(domain.domain_id.clone()),
93            name: domain.base_domain.clone(),
94            zone_type: format!("Pangolin/{}", domain.domain_type),
95            disabled: domain.failed || !domain.verified,
96            dnssec_status: None,
97        };
98
99        Ok(ZoneRecords {
100            zone: zone_info,
101            records,
102        })
103    }
104}
105
106// ─── DnsVendor ────────────────────────────────────────────────────────────────
107
108impl DnsVendor for PangolinClient {
109    fn kind(&self) -> VendorKind {
110        VendorKind::Pangolin
111    }
112
113    fn capabilities(&self) -> VendorCapabilities {
114        VendorCapabilities {
115            zones: true,
116            records: true,
117            cache: false,
118            access_lists: false,
119            settings: true,
120            zone_import: false,
121            zone_export: false,
122            logs: false,
123        }
124    }
125}
126
127// ─── ZoneRead ─────────────────────────────────────────────────────────────────
128
129impl ZoneRead for PangolinClient {
130    #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_zones"))]
131    async fn list_zones(&self, page: u32, per_page: u32) -> Result<Value> {
132        let limit = per_page.to_string();
133        let offset = ((page.saturating_sub(1)) * per_page).to_string();
134        self.get(
135            &format!("/org/{}/domains", self.org_id),
136            &[("limit", limit), ("offset", offset)],
137        )
138        .await
139    }
140
141    #[instrument(
142        skip(self, options),
143        fields(vendor = "pangolin", operation = "list_records")
144    )]
145    async fn list_records(
146        &self,
147        domain: &str,
148        zone: Option<&str>,
149        options: ListRecordsOptions,
150    ) -> Result<ListRecordsResponse> {
151        // Fetch all domains regardless — needed for both single-zone and all-zones paths.
152        let domains_data = self
153            .get(
154                &format!("/org/{}/domains", self.org_id),
155                &[("limit", "1000".to_string()), ("offset", "0".to_string())],
156            )
157            .await?;
158        let domains = mapping::parse_domains(&domains_data)?;
159
160        if let Some(zone_name) = zone {
161            // Zone explicitly specified — return records for that zone only.
162            let matching = domains
163                .iter()
164                .find(|d| d.base_domain.eq_ignore_ascii_case(zone_name))
165                .ok_or_else(|| {
166                    Error::api(format!("zone '{zone_name}' not found in Pangolin domains"))
167                })?;
168            // When all_subdomains is set, skip the name filter so the caller can
169            // filter the full zone record set for the target domain + its subdomains.
170            let name_filter = if options.all_subdomains {
171                None
172            } else {
173                Some(domain)
174            };
175            let zone_records = self
176                .fetch_zone_records(matching, name_filter, options)
177                .await?;
178            Ok(ListRecordsResponse {
179                zones: vec![zone_records],
180            })
181        } else {
182            // No zone specified — list records for every domain in the org.
183            let mut all_zones = Vec::with_capacity(domains.len());
184            for domain_entry in &domains {
185                let zone_records = self.fetch_zone_records(domain_entry, None, options).await?;
186                all_zones.push(zone_records);
187            }
188            Ok(ListRecordsResponse { zones: all_zones })
189        }
190    }
191}
192
193// ─── ZoneWrite (unsupported) ──────────────────────────────────────────────────
194
195impl ZoneWrite for PangolinClient {
196    #[instrument(skip(self), fields(vendor = "pangolin", operation = "create_zone"))]
197    async fn create_zone(&self, _zone: &str, _zone_type: &str) -> Result<Value> {
198        Err(Error::unsupported("Pangolin", "zone creation"))
199    }
200
201    #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_zone"))]
202    async fn delete_zone(&self, _zone: &str) -> Result<Value> {
203        Err(Error::unsupported("Pangolin", "zone deletion"))
204    }
205
206    #[instrument(skip(self), fields(vendor = "pangolin", operation = "enable_zone"))]
207    async fn enable_zone(&self, _zone: &str) -> Result<Value> {
208        Err(Error::unsupported("Pangolin", "zone enable"))
209    }
210
211    #[instrument(skip(self), fields(vendor = "pangolin", operation = "disable_zone"))]
212    async fn disable_zone(&self, _zone: &str) -> Result<Value> {
213        Err(Error::unsupported("Pangolin", "zone disable"))
214    }
215}
216
217// ─── RecordWrite (unsupported) ────────────────────────────────────────────────
218
219impl RecordWrite for PangolinClient {
220    #[instrument(
221        skip(self, _record),
222        fields(vendor = "pangolin", operation = "add_record")
223    )]
224    async fn add_record(
225        &self,
226        _zone: &str,
227        _domain: &str,
228        _ttl: u32,
229        _record: &RecordData,
230    ) -> Result<Value> {
231        Err(Error::unsupported("Pangolin", "record add"))
232    }
233
234    #[instrument(
235        skip(self, _type_params),
236        fields(vendor = "pangolin", operation = "delete_record")
237    )]
238    async fn delete_record(
239        &self,
240        _zone: &str,
241        _domain: &str,
242        _type_params: &[(&str, String)],
243    ) -> Result<Value> {
244        Err(Error::unsupported("Pangolin", "record delete"))
245    }
246}
247
248// ─── CacheRead / CacheWrite (unsupported) ─────────────────────────────────────
249
250impl CacheRead for PangolinClient {
251    #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_cache"))]
252    async fn list_cache(&self, _domain: &str) -> Result<Value> {
253        Err(Error::unsupported("Pangolin", "cache"))
254    }
255}
256
257impl CacheWrite for PangolinClient {
258    #[instrument(
259        skip(self),
260        fields(vendor = "pangolin", operation = "delete_cache_zone")
261    )]
262    async fn delete_cache_zone(&self, _domain: &str) -> Result<Value> {
263        Err(Error::unsupported("Pangolin", "cache"))
264    }
265
266    #[instrument(skip(self), fields(vendor = "pangolin", operation = "flush_cache"))]
267    async fn flush_cache(&self) -> Result<Value> {
268        Err(Error::unsupported("Pangolin", "cache"))
269    }
270}
271
272// ─── StatsRead (unsupported) ──────────────────────────────────────────────────
273
274impl StatsRead for PangolinClient {
275    #[instrument(skip(self), fields(vendor = "pangolin", operation = "get_stats"))]
276    async fn get_stats(&self, _stats_type: &str) -> Result<Value> {
277        Err(Error::unsupported("Pangolin", "stats"))
278    }
279}
280
281// ─── AccessListRead / AccessListWrite (unsupported) ───────────────────────────
282
283impl AccessListRead for PangolinClient {
284    #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_blocked"))]
285    async fn list_blocked(&self) -> Result<Value> {
286        Err(Error::unsupported("Pangolin", "access lists"))
287    }
288
289    #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_allowed"))]
290    async fn list_allowed(&self) -> Result<Value> {
291        Err(Error::unsupported("Pangolin", "access lists"))
292    }
293}
294
295impl AccessListWrite for PangolinClient {
296    #[instrument(skip(self), fields(vendor = "pangolin", operation = "add_blocked"))]
297    async fn add_blocked(&self, _domain: &str) -> Result<Value> {
298        Err(Error::unsupported("Pangolin", "access lists"))
299    }
300
301    #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_blocked"))]
302    async fn delete_blocked(&self, _domain: &str) -> Result<Value> {
303        Err(Error::unsupported("Pangolin", "access lists"))
304    }
305
306    #[instrument(skip(self), fields(vendor = "pangolin", operation = "add_allowed"))]
307    async fn add_allowed(&self, _domain: &str) -> Result<Value> {
308        Err(Error::unsupported("Pangolin", "access lists"))
309    }
310
311    #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_allowed"))]
312    async fn delete_allowed(&self, _domain: &str) -> Result<Value> {
313        Err(Error::unsupported("Pangolin", "access lists"))
314    }
315}
316
317// ─── ZoneImport / ZoneExport (unsupported) ───────────────────────────────────
318
319impl ZoneImport for PangolinClient {
320    #[instrument(
321        skip(self, _file_bytes),
322        fields(vendor = "pangolin", operation = "import_zone_file")
323    )]
324    async fn import_zone_file(
325        &self,
326        _zone: &str,
327        _file_name: String,
328        _file_bytes: Vec<u8>,
329        _overwrite: bool,
330        _overwrite_zone: bool,
331        _overwrite_soa_serial: bool,
332    ) -> Result<Value> {
333        Err(Error::unsupported("Pangolin", "zone import"))
334    }
335}
336
337impl ZoneExport for PangolinClient {
338    async fn export_zone_file<'a>(&'a self, _zone: &'a str) -> Result<String> {
339        Err(Error::unsupported("Pangolin", "zone export"))
340    }
341}
342
343// ─── SettingsRead → org discovery ─────────────────────────────────────────────
344
345impl SettingsRead for PangolinClient {
346    /// Returns the list of organizations visible to this API token.
347    /// Use this to discover the `org_id` value for your dnsync config.
348    /// SSH CA key fields are omitted from the output.
349    #[instrument(skip(self), fields(vendor = "pangolin", operation = "get_settings"))]
350    async fn get_settings(&self) -> Result<Value> {
351        let data = self
352            .get(
353                "/orgs",
354                &[("limit", "1000".to_string()), ("offset", "0".to_string())],
355            )
356            .await?;
357        Ok(redact_org_keys(data))
358    }
359}
360
361impl LogsRead for PangolinClient {
362    async fn get_logs(&self, _: LogsOptions) -> Result<Vec<LogLine>> {
363        Err(Error::unsupported("Pangolin", "logs"))
364    }
365}
366
367/// Remove sensitive SSH CA key fields from org objects before display.
368fn redact_org_keys(mut data: Value) -> Value {
369    if let Some(orgs) = data.get_mut("orgs").and_then(|o| o.as_array_mut()) {
370        for org in orgs.iter_mut() {
371            if let Some(obj) = org.as_object_mut() {
372                obj.remove("sshCaPrivateKey");
373                obj.remove("sshCaPublicKey");
374            }
375        }
376    }
377    data
378}
379
380// ─── Tests ────────────────────────────────────────────────────────────────────
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use serde_json::json;
386
387    use crate::vendors::pangolin::mapping::{
388        dns_record_to_zone_record, extract_subdomain, parse_dns_records, parse_domains,
389        parse_resources, resource_to_zone_record,
390    };
391    use crate::vendors::pangolin::responses::{PangolinDnsRecord, PangolinResource};
392
393    // ── extract_subdomain ─────────────────────────────────────────────────────
394
395    #[test]
396    fn apex_returns_at() {
397        assert_eq!(extract_subdomain("app.hankin.io", "app.hankin.io"), "@");
398    }
399
400    #[test]
401    fn single_label_subdomain() {
402        assert_eq!(
403            extract_subdomain("grafana.app.hankin.io", "app.hankin.io"),
404            "grafana"
405        );
406    }
407
408    #[test]
409    fn multi_label_subdomain() {
410        assert_eq!(
411            extract_subdomain("a.b.app.hankin.io", "app.hankin.io"),
412            "a.b"
413        );
414    }
415
416    #[test]
417    fn case_insensitive_stripping() {
418        assert_eq!(
419            extract_subdomain("Grafana.App.Hankin.IO", "app.hankin.io"),
420            "Grafana"
421        );
422    }
423
424    #[test]
425    fn unrelated_domain_returned_as_is() {
426        assert_eq!(
427            extract_subdomain("other.example.com", "app.hankin.io"),
428            "other.example.com"
429        );
430    }
431
432    // ── resource_to_zone_record ───────────────────────────────────────────────
433
434    fn make_resource(
435        full_domain: &str,
436        http: bool,
437        protocol: &str,
438        enabled: bool,
439    ) -> PangolinResource {
440        PangolinResource {
441            resource_id: 1,
442            name: "Test".to_string(),
443            full_domain: full_domain.to_string(),
444            http,
445            protocol: protocol.to_string(),
446            enabled,
447            domain_id: "dom1".to_string(),
448            health: "healthy".to_string(),
449            targets: vec![],
450            sites: vec![],
451        }
452    }
453
454    #[test]
455    fn http_resource_maps_to_http_record_type() {
456        let r = make_resource("svc.app.hankin.io", true, "tcp", true);
457        let rec = resource_to_zone_record(&r, "app.hankin.io");
458        assert_eq!(rec.record_type, "HTTP");
459        assert_eq!(rec.name, "svc");
460        assert!(!rec.disabled);
461    }
462
463    #[test]
464    fn non_http_resource_uses_uppercased_protocol() {
465        let r = make_resource("vpn.app.hankin.io", false, "tcp", true);
466        let rec = resource_to_zone_record(&r, "app.hankin.io");
467        assert_eq!(rec.record_type, "TCP");
468    }
469
470    #[test]
471    fn disabled_resource_maps_to_disabled_record() {
472        let r = make_resource("off.app.hankin.io", true, "tcp", false);
473        let rec = resource_to_zone_record(&r, "app.hankin.io");
474        assert!(rec.disabled);
475    }
476
477    #[test]
478    fn record_data_contains_resource_fields() {
479        let r = make_resource("svc.app.hankin.io", true, "tcp", true);
480        let rec = resource_to_zone_record(&r, "app.hankin.io");
481        assert_eq!(rec.data["resourceId"], 1);
482        assert_eq!(rec.data["fullDomain"], "svc.app.hankin.io");
483        assert_eq!(rec.data["health"], "healthy");
484    }
485
486    // ── parse_domains ─────────────────────────────────────────────────────────
487
488    #[test]
489    fn parses_domain_list() {
490        let data = json!({
491            "domains": [
492                {
493                    "domainId": "y61yv7gv7qmn2js",
494                    "baseDomain": "app.hankin.io",
495                    "verified": true,
496                    "type": "ns",
497                    "failed": false,
498                    "tries": 0,
499                    "configManaged": false,
500                    "certResolver": null,
501                    "preferWildcardCert": false,
502                    "errorMessage": null
503                }
504            ],
505            "pagination": { "total": "1", "limit": 1000, "offset": 0 }
506        });
507        let domains = parse_domains(&data).unwrap();
508        assert_eq!(domains.len(), 1);
509        assert_eq!(domains[0].domain_id, "y61yv7gv7qmn2js");
510        assert_eq!(domains[0].base_domain, "app.hankin.io");
511        assert_eq!(domains[0].domain_type, "ns");
512        assert!(domains[0].verified);
513        assert!(!domains[0].failed);
514    }
515
516    #[test]
517    fn missing_domains_key_returns_parse_error() {
518        let err = parse_domains(&json!({})).unwrap_err();
519        assert!(matches!(err, Error::Parse { ref context } if context.contains("domains")));
520    }
521
522    // ── parse_resources ───────────────────────────────────────────────────────
523
524    #[test]
525    fn parses_resource_list() {
526        let data = json!({
527            "resources": [
528                {
529                    "resourceId": 13613,
530                    "niceId": "granular-greater-naked-tailed-armadillo",
531                    "name": "Grafana",
532                    "ssl": true,
533                    "fullDomain": "grafana.app.hankin.io",
534                    "passwordId": null,
535                    "sso": true,
536                    "pincodeId": null,
537                    "whitelist": false,
538                    "http": true,
539                    "protocol": "tcp",
540                    "proxyPort": null,
541                    "wildcard": false,
542                    "enabled": true,
543                    "domainId": "y61yv7gv7qmn2js",
544                    "headerAuthId": null,
545                    "health": "healthy",
546                    "targets": [],
547                    "sites": []
548                }
549            ],
550            "pagination": { "total": 1, "pageSize": 5, "page": 1 }
551        });
552        let resources = parse_resources(&data).unwrap();
553        assert_eq!(resources.len(), 1);
554        assert_eq!(resources[0].resource_id, 13613);
555        assert_eq!(resources[0].full_domain, "grafana.app.hankin.io");
556        assert_eq!(resources[0].domain_id, "y61yv7gv7qmn2js");
557        assert!(resources[0].http);
558        assert!(resources[0].enabled);
559    }
560
561    #[test]
562    fn missing_resources_key_returns_parse_error() {
563        let err = parse_resources(&json!({})).unwrap_err();
564        assert!(matches!(err, Error::Parse { ref context } if context.contains("resources")));
565    }
566
567    // ── Pangolin DNS records ──────────────────────────────────────────────────
568
569    #[test]
570    fn parses_dns_records_array() {
571        let records = parse_dns_records(&json!([
572            {
573                "id": 18720,
574                "domainId": "y61yv7gv7qmn2js",
575                "recordType": "NS",
576                "baseDomain": "app.hankin.io",
577                "value": "ns1.pangolin-ns.net",
578                "verified": true
579            }
580        ]))
581        .unwrap();
582
583        assert_eq!(records.len(), 1);
584        assert_eq!(records[0].id, 18720);
585        assert_eq!(records[0].record_type, "NS");
586        assert_eq!(records[0].value, "ns1.pangolin-ns.net");
587    }
588
589    #[test]
590    fn missing_dns_records_array_returns_parse_error() {
591        let err = parse_dns_records(&json!({})).unwrap_err();
592        assert!(matches!(err, Error::Parse { ref context } if context.contains("DNS records")));
593    }
594
595    #[test]
596    fn ns_dns_record_maps_to_normalized_zone_record() {
597        let record = PangolinDnsRecord {
598            id: 18720,
599            domain_id: "y61yv7gv7qmn2js".to_string(),
600            record_type: "NS".to_string(),
601            base_domain: "app.hankin.io".to_string(),
602            value: "ns1.pangolin-ns.net".to_string(),
603            verified: true,
604        };
605
606        let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &[], false);
607
608        assert_eq!(zone_record.name, "@");
609        assert_eq!(zone_record.record_type, "NS");
610        assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
611        assert_eq!(zone_record.data["glue"], serde_json::Value::Null);
612        assert!(!zone_record.disabled);
613    }
614
615    #[test]
616    fn a_dns_record_maps_to_normalized_zone_record() {
617        let record = PangolinDnsRecord {
618            id: 11,
619            domain_id: "hankin".to_string(),
620            record_type: "A".to_string(),
621            base_domain: "*.hankin.io".to_string(),
622            value: "144.6.233.253".to_string(),
623            verified: true,
624        };
625
626        let zone_record = dns_record_to_zone_record(&record, "hankin.io", &[], false);
627
628        assert_eq!(zone_record.name, "*");
629        assert_eq!(zone_record.record_type, "A");
630        assert_eq!(zone_record.data["ipAddress"], "144.6.233.253");
631    }
632
633    #[test]
634    fn cname_dns_record_maps_to_normalized_zone_record() {
635        let record = PangolinDnsRecord {
636            id: 18724,
637            domain_id: "4u6jvem261kcg4k".to_string(),
638            record_type: "CNAME".to_string(),
639            base_domain: "_acme-challenge.huly.hankin.io".to_string(),
640            value: "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net".to_string(),
641            verified: true,
642        };
643
644        let zone_record = dns_record_to_zone_record(&record, "huly.hankin.io", &[], false);
645
646        assert_eq!(zone_record.name, "_acme-challenge");
647        assert_eq!(zone_record.record_type, "CNAME");
648        assert_eq!(
649            zone_record.data["cname"],
650            "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net"
651        );
652    }
653
654    #[test]
655    fn local_ip_flag_prefers_local_ipv4_for_a_records() {
656        let record = PangolinDnsRecord {
657            id: 11,
658            domain_id: "hankin".to_string(),
659            record_type: "A".to_string(),
660            base_domain: "hankin.io".to_string(),
661            value: "144.6.233.253".to_string(),
662            verified: true,
663        };
664        let resolved = vec![
665            "144.6.233.253".parse().unwrap(),
666            "192.168.1.10".parse().unwrap(),
667        ];
668
669        let zone_record = dns_record_to_zone_record(&record, "hankin.io", &resolved, true);
670
671        assert_eq!(zone_record.data["ipAddress"], "192.168.1.10");
672    }
673
674    #[test]
675    fn local_ip_flag_does_not_override_ns_records() {
676        let record = PangolinDnsRecord {
677            id: 18720,
678            domain_id: "y61yv7gv7qmn2js".to_string(),
679            record_type: "NS".to_string(),
680            base_domain: "app.hankin.io".to_string(),
681            value: "ns1.pangolin-ns.net".to_string(),
682            verified: true,
683        };
684        let resolved = vec!["192.168.1.10".parse().unwrap()];
685
686        let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &resolved, true);
687
688        assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
689    }
690
691    // ── redact_org_keys ───────────────────────────────────────────────────────
692
693    #[test]
694    fn ssh_keys_are_redacted() {
695        let data = json!({
696            "orgs": [
697                {
698                    "orgId": "hankin-io",
699                    "name": "Hankin.io",
700                    "sshCaPrivateKey": "PRIVATE_KEY_DATA",
701                    "sshCaPublicKey": "PUBLIC_KEY_DATA"
702                }
703            ]
704        });
705        let result = redact_org_keys(data);
706        let org = &result["orgs"][0];
707        assert!(org.get("sshCaPrivateKey").is_none());
708        assert!(org.get("sshCaPublicKey").is_none());
709        assert_eq!(org["orgId"], "hankin-io");
710    }
711
712    #[test]
713    fn redact_handles_missing_orgs_key_gracefully() {
714        let data = json!({ "other": "data" });
715        let result = redact_org_keys(data.clone());
716        assert_eq!(result, data);
717    }
718}