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::logs::{LogLine, LogsOptions, LogsRead};
19use crate::core::dns::records::RecordData;
20use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
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::core::dns::names::relative_to_zone;
388    use crate::vendors::pangolin::mapping::{
389        dns_record_to_zone_record, parse_dns_records, parse_domains, parse_resources,
390        resource_to_zone_record,
391    };
392    use crate::vendors::pangolin::responses::{PangolinDnsRecord, PangolinResource};
393
394    // ── relative_to_zone ──────────────────────────────────────────────────────
395
396    #[test]
397    fn apex_returns_at() {
398        assert_eq!(relative_to_zone("app.hankin.io", "app.hankin.io"), "@");
399    }
400
401    #[test]
402    fn single_label_subdomain() {
403        assert_eq!(
404            relative_to_zone("grafana.app.hankin.io", "app.hankin.io"),
405            "grafana"
406        );
407    }
408
409    #[test]
410    fn multi_label_subdomain() {
411        assert_eq!(
412            relative_to_zone("a.b.app.hankin.io", "app.hankin.io"),
413            "a.b"
414        );
415    }
416
417    #[test]
418    fn case_insensitive_stripping() {
419        assert_eq!(
420            relative_to_zone("Grafana.App.Hankin.IO", "app.hankin.io"),
421            "Grafana"
422        );
423    }
424
425    #[test]
426    fn unrelated_domain_returned_as_is() {
427        assert_eq!(
428            relative_to_zone("other.example.com", "app.hankin.io"),
429            "other.example.com"
430        );
431    }
432
433    // ── resource_to_zone_record ───────────────────────────────────────────────
434
435    fn make_resource(
436        full_domain: &str,
437        http: bool,
438        protocol: &str,
439        enabled: bool,
440    ) -> PangolinResource {
441        PangolinResource {
442            resource_id: 1,
443            name: "Test".to_string(),
444            full_domain: full_domain.to_string(),
445            http,
446            protocol: protocol.to_string(),
447            enabled,
448            domain_id: "dom1".to_string(),
449            health: "healthy".to_string(),
450            targets: vec![],
451            sites: vec![],
452        }
453    }
454
455    #[test]
456    fn http_resource_maps_to_http_record_type() {
457        let r = make_resource("svc.app.hankin.io", true, "tcp", true);
458        let rec = resource_to_zone_record(&r, "app.hankin.io");
459        assert_eq!(rec.record_type, "HTTP");
460        assert_eq!(rec.name, "svc");
461        assert!(!rec.disabled);
462    }
463
464    #[test]
465    fn non_http_resource_uses_uppercased_protocol() {
466        let r = make_resource("vpn.app.hankin.io", false, "tcp", true);
467        let rec = resource_to_zone_record(&r, "app.hankin.io");
468        assert_eq!(rec.record_type, "TCP");
469    }
470
471    #[test]
472    fn disabled_resource_maps_to_disabled_record() {
473        let r = make_resource("off.app.hankin.io", true, "tcp", false);
474        let rec = resource_to_zone_record(&r, "app.hankin.io");
475        assert!(rec.disabled);
476    }
477
478    #[test]
479    fn record_data_contains_resource_fields() {
480        let r = make_resource("svc.app.hankin.io", true, "tcp", true);
481        let rec = resource_to_zone_record(&r, "app.hankin.io");
482        assert_eq!(rec.data["resourceId"], 1);
483        assert_eq!(rec.data["fullDomain"], "svc.app.hankin.io");
484        assert_eq!(rec.data["health"], "healthy");
485    }
486
487    // ── parse_domains ─────────────────────────────────────────────────────────
488
489    #[test]
490    fn parses_domain_list() {
491        let data = json!({
492            "domains": [
493                {
494                    "domainId": "y61yv7gv7qmn2js",
495                    "baseDomain": "app.hankin.io",
496                    "verified": true,
497                    "type": "ns",
498                    "failed": false,
499                    "tries": 0,
500                    "configManaged": false,
501                    "certResolver": null,
502                    "preferWildcardCert": false,
503                    "errorMessage": null
504                }
505            ],
506            "pagination": { "total": "1", "limit": 1000, "offset": 0 }
507        });
508        let domains = parse_domains(&data).unwrap();
509        assert_eq!(domains.len(), 1);
510        assert_eq!(domains[0].domain_id, "y61yv7gv7qmn2js");
511        assert_eq!(domains[0].base_domain, "app.hankin.io");
512        assert_eq!(domains[0].domain_type, "ns");
513        assert!(domains[0].verified);
514        assert!(!domains[0].failed);
515    }
516
517    #[test]
518    fn missing_domains_key_returns_parse_error() {
519        let err = parse_domains(&json!({})).unwrap_err();
520        assert!(matches!(err, Error::Parse { ref context } if context.contains("domains")));
521    }
522
523    // ── parse_resources ───────────────────────────────────────────────────────
524
525    #[test]
526    fn parses_resource_list() {
527        let data = json!({
528            "resources": [
529                {
530                    "resourceId": 13613,
531                    "niceId": "granular-greater-naked-tailed-armadillo",
532                    "name": "Grafana",
533                    "ssl": true,
534                    "fullDomain": "grafana.app.hankin.io",
535                    "passwordId": null,
536                    "sso": true,
537                    "pincodeId": null,
538                    "whitelist": false,
539                    "http": true,
540                    "protocol": "tcp",
541                    "proxyPort": null,
542                    "wildcard": false,
543                    "enabled": true,
544                    "domainId": "y61yv7gv7qmn2js",
545                    "headerAuthId": null,
546                    "health": "healthy",
547                    "targets": [],
548                    "sites": []
549                }
550            ],
551            "pagination": { "total": 1, "pageSize": 5, "page": 1 }
552        });
553        let resources = parse_resources(&data).unwrap();
554        assert_eq!(resources.len(), 1);
555        assert_eq!(resources[0].resource_id, 13613);
556        assert_eq!(resources[0].full_domain, "grafana.app.hankin.io");
557        assert_eq!(resources[0].domain_id, "y61yv7gv7qmn2js");
558        assert!(resources[0].http);
559        assert!(resources[0].enabled);
560    }
561
562    #[test]
563    fn missing_resources_key_returns_parse_error() {
564        let err = parse_resources(&json!({})).unwrap_err();
565        assert!(matches!(err, Error::Parse { ref context } if context.contains("resources")));
566    }
567
568    // ── Pangolin DNS records ──────────────────────────────────────────────────
569
570    #[test]
571    fn parses_dns_records_array() {
572        let records = parse_dns_records(&json!([
573            {
574                "id": 18720,
575                "domainId": "y61yv7gv7qmn2js",
576                "recordType": "NS",
577                "baseDomain": "app.hankin.io",
578                "value": "ns1.pangolin-ns.net",
579                "verified": true
580            }
581        ]))
582        .unwrap();
583
584        assert_eq!(records.len(), 1);
585        assert_eq!(records[0].id, 18720);
586        assert_eq!(records[0].record_type, "NS");
587        assert_eq!(records[0].value, "ns1.pangolin-ns.net");
588    }
589
590    #[test]
591    fn missing_dns_records_array_returns_parse_error() {
592        let err = parse_dns_records(&json!({})).unwrap_err();
593        assert!(matches!(err, Error::Parse { ref context } if context.contains("DNS records")));
594    }
595
596    #[test]
597    fn ns_dns_record_maps_to_normalized_zone_record() {
598        let record = PangolinDnsRecord {
599            id: 18720,
600            domain_id: "y61yv7gv7qmn2js".to_string(),
601            record_type: "NS".to_string(),
602            base_domain: "app.hankin.io".to_string(),
603            value: "ns1.pangolin-ns.net".to_string(),
604            verified: true,
605        };
606
607        let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &[], false);
608
609        assert_eq!(zone_record.name, "@");
610        assert_eq!(zone_record.record_type, "NS");
611        assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
612        assert_eq!(zone_record.data["glue"], serde_json::Value::Null);
613        assert!(!zone_record.disabled);
614    }
615
616    #[test]
617    fn a_dns_record_maps_to_normalized_zone_record() {
618        let record = PangolinDnsRecord {
619            id: 11,
620            domain_id: "hankin".to_string(),
621            record_type: "A".to_string(),
622            base_domain: "*.hankin.io".to_string(),
623            value: "144.6.233.253".to_string(),
624            verified: true,
625        };
626
627        let zone_record = dns_record_to_zone_record(&record, "hankin.io", &[], false);
628
629        assert_eq!(zone_record.name, "*");
630        assert_eq!(zone_record.record_type, "A");
631        assert_eq!(zone_record.data["ipAddress"], "144.6.233.253");
632    }
633
634    #[test]
635    fn cname_dns_record_maps_to_normalized_zone_record() {
636        let record = PangolinDnsRecord {
637            id: 18724,
638            domain_id: "4u6jvem261kcg4k".to_string(),
639            record_type: "CNAME".to_string(),
640            base_domain: "_acme-challenge.huly.hankin.io".to_string(),
641            value: "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net".to_string(),
642            verified: true,
643        };
644
645        let zone_record = dns_record_to_zone_record(&record, "huly.hankin.io", &[], false);
646
647        assert_eq!(zone_record.name, "_acme-challenge");
648        assert_eq!(zone_record.record_type, "CNAME");
649        assert_eq!(
650            zone_record.data["cname"],
651            "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net"
652        );
653    }
654
655    #[test]
656    fn local_ip_flag_prefers_local_ipv4_for_a_records() {
657        let record = PangolinDnsRecord {
658            id: 11,
659            domain_id: "hankin".to_string(),
660            record_type: "A".to_string(),
661            base_domain: "hankin.io".to_string(),
662            value: "144.6.233.253".to_string(),
663            verified: true,
664        };
665        let resolved = vec![
666            "144.6.233.253".parse().unwrap(),
667            "192.168.1.10".parse().unwrap(),
668        ];
669
670        let zone_record = dns_record_to_zone_record(&record, "hankin.io", &resolved, true);
671
672        assert_eq!(zone_record.data["ipAddress"], "192.168.1.10");
673    }
674
675    #[test]
676    fn local_ip_flag_does_not_override_ns_records() {
677        let record = PangolinDnsRecord {
678            id: 18720,
679            domain_id: "y61yv7gv7qmn2js".to_string(),
680            record_type: "NS".to_string(),
681            base_domain: "app.hankin.io".to_string(),
682            value: "ns1.pangolin-ns.net".to_string(),
683            verified: true,
684        };
685        let resolved = vec!["192.168.1.10".parse().unwrap()];
686
687        let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &resolved, true);
688
689        assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
690    }
691
692    // ── redact_org_keys ───────────────────────────────────────────────────────
693
694    #[test]
695    fn ssh_keys_are_redacted() {
696        let data = json!({
697            "orgs": [
698                {
699                    "orgId": "hankin-io",
700                    "name": "Hankin.io",
701                    "sshCaPrivateKey": "PRIVATE_KEY_DATA",
702                    "sshCaPublicKey": "PUBLIC_KEY_DATA"
703                }
704            ]
705        });
706        let result = redact_org_keys(data);
707        let org = &result["orgs"][0];
708        assert!(org.get("sshCaPrivateKey").is_none());
709        assert!(org.get("sshCaPublicKey").is_none());
710        assert_eq!(org["orgId"], "hankin-io");
711    }
712
713    #[test]
714    fn redact_handles_missing_orgs_key_gracefully() {
715        let data = json!({ "other": "data" });
716        let result = redact_org_keys(data.clone());
717        assert_eq!(result, data);
718    }
719}