Skip to main content

unifly_api/command/requests/
policy.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::model::{EntityId, FirewallAction};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct CreateFirewallPolicyRequest {
8    pub name: String,
9    pub action: FirewallAction,
10    #[serde(alias = "source_zone")]
11    pub source_zone_id: EntityId,
12    #[serde(alias = "dest_zone")]
13    pub destination_zone_id: EntityId,
14    #[serde(default = "default_true")]
15    pub enabled: bool,
16    #[serde(default, alias = "logging")]
17    pub logging_enabled: bool,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub allow_return_traffic: Option<bool>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub description: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub ip_version: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub connection_states: Option<Vec<String>>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub source_filter: Option<TrafficFilterSpec>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub destination_filter: Option<TrafficFilterSpec>,
30
31    // Shorthand fields for --from-file convenience (map to source/destination_filter)
32    #[serde(default, skip_serializing)]
33    pub src_network: Option<Vec<String>>,
34    #[serde(default, skip_serializing)]
35    pub src_ip: Option<Vec<String>>,
36    #[serde(default, skip_serializing)]
37    pub src_port: Option<Vec<String>>,
38    #[serde(default, skip_serializing)]
39    pub dst_network: Option<Vec<String>>,
40    #[serde(default, skip_serializing)]
41    pub dst_ip: Option<Vec<String>>,
42    #[serde(default, skip_serializing)]
43    pub dst_port: Option<Vec<String>>,
44
45    // Group reference shorthands (resolved by CLI to source/destination_filter)
46    #[serde(default, skip_serializing)]
47    pub src_port_group: Option<String>,
48    #[serde(default, skip_serializing)]
49    pub dst_port_group: Option<String>,
50    #[serde(default, skip_serializing)]
51    pub src_address_group: Option<String>,
52    #[serde(default, skip_serializing)]
53    pub dst_address_group: Option<String>,
54}
55
56fn default_true() -> bool {
57    true
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct UpdateFirewallPolicyRequest {
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub action: Option<FirewallAction>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub allow_return_traffic: Option<bool>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub enabled: Option<bool>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub description: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub ip_version: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub connection_states: Option<Vec<String>>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub source_filter: Option<TrafficFilterSpec>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub destination_filter: Option<TrafficFilterSpec>,
80    #[serde(skip_serializing_if = "Option::is_none", alias = "logging")]
81    pub logging_enabled: Option<bool>,
82
83    // Shorthand fields for --from-file convenience (map to source/destination_filter)
84    #[serde(default, skip_serializing)]
85    pub src_network: Option<Vec<String>>,
86    #[serde(default, skip_serializing)]
87    pub src_ip: Option<Vec<String>>,
88    #[serde(default, skip_serializing)]
89    pub src_port: Option<Vec<String>>,
90    #[serde(default, skip_serializing)]
91    pub dst_network: Option<Vec<String>>,
92    #[serde(default, skip_serializing)]
93    pub dst_ip: Option<Vec<String>>,
94    #[serde(default, skip_serializing)]
95    pub dst_port: Option<Vec<String>>,
96
97    // Group reference shorthands (resolved by CLI to source/destination_filter)
98    #[serde(default, skip_serializing)]
99    pub src_port_group: Option<String>,
100    #[serde(default, skip_serializing)]
101    pub dst_port_group: Option<String>,
102    #[serde(default, skip_serializing)]
103    pub src_address_group: Option<String>,
104    #[serde(default, skip_serializing)]
105    pub dst_address_group: Option<String>,
106}
107
108/// Port-side specification: either inline values or a reference to a
109/// firewall port-group by its `external_id`. Mirrors the controller's
110/// portFilter wire shape.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "type", rename_all = "snake_case")]
113pub enum PortSpec {
114    /// Inline port values (single ports or ranges like `"8000-9000"`).
115    Values {
116        items: Vec<String>,
117        #[serde(default)]
118        match_opposite: bool,
119    },
120    /// Reference to a port-group via its `external_id` UUID.
121    MatchingList {
122        list_id: String,
123        #[serde(default)]
124        match_opposite: bool,
125    },
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(
130    tag = "type",
131    rename_all = "snake_case",
132    from = "TrafficFilterSpecWire"
133)]
134pub enum TrafficFilterSpec {
135    Network {
136        network_ids: Vec<String>,
137        #[serde(default)]
138        match_opposite: bool,
139        /// Optional port restriction (the API nests portFilter inside the
140        /// network/IP filter rather than treating it as a separate type).
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        ports: Option<PortSpec>,
143    },
144    IpAddress {
145        addresses: Vec<String>,
146        #[serde(default)]
147        match_opposite: bool,
148        /// Optional port restriction.
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        ports: Option<PortSpec>,
151    },
152    Port {
153        ports: PortSpec,
154    },
155    /// Address-group filter referencing a firewall group (address-group)
156    /// by its `external_id`. May carry an optional port restriction in
157    /// the same filter (mirrors what `IpAddress` supports for inline
158    /// addresses).
159    IpMatchingList {
160        list_id: String,
161        #[serde(default)]
162        match_opposite: bool,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        ports: Option<PortSpec>,
165    },
166}
167
168/// Internal wire-format wrapper used during deserialization to accept
169/// pre-PortSpec JSON files. The legacy `Port` variant stored ports as a
170/// flat `Vec<String>` with `match_opposite` at the variant level. The
171/// legacy `port_matching_list` top-level variant carried a port-group
172/// reference; it lowers to `Port { ports: PortSpec::MatchingList { ... } }`.
173#[derive(Deserialize)]
174#[serde(tag = "type", rename_all = "snake_case")]
175enum TrafficFilterSpecWire {
176    Network {
177        network_ids: Vec<String>,
178        #[serde(default)]
179        match_opposite: bool,
180        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
181        ports: Option<PortSpec>,
182    },
183    IpAddress {
184        addresses: Vec<String>,
185        #[serde(default)]
186        match_opposite: bool,
187        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
188        ports: Option<PortSpec>,
189    },
190    Port {
191        #[serde(deserialize_with = "deserialize_port_spec")]
192        ports: PortSpec,
193        /// Legacy field: pre-PortSpec the Port variant carried
194        /// `match_opposite` at the variant level. Folded into the inner
195        /// `PortSpec` during conversion.
196        #[serde(default)]
197        match_opposite: bool,
198    },
199    /// Legacy top-level port-group reference. Lowered to `Port` with a
200    /// nested `PortSpec::MatchingList` during conversion.
201    PortMatchingList {
202        list_id: String,
203        #[serde(default)]
204        match_opposite: bool,
205    },
206    /// Address-group filter. Optional `ports` companion supports rules
207    /// like "members of address-group X on port-group Y" in one filter.
208    IpMatchingList {
209        list_id: String,
210        #[serde(default)]
211        match_opposite: bool,
212        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
213        ports: Option<PortSpec>,
214    },
215}
216
217impl From<TrafficFilterSpecWire> for TrafficFilterSpec {
218    fn from(wire: TrafficFilterSpecWire) -> Self {
219        match wire {
220            TrafficFilterSpecWire::Network {
221                network_ids,
222                match_opposite,
223                ports,
224            } => Self::Network {
225                network_ids,
226                match_opposite,
227                ports,
228            },
229            TrafficFilterSpecWire::IpAddress {
230                addresses,
231                match_opposite,
232                ports,
233            } => Self::IpAddress {
234                addresses,
235                match_opposite,
236                ports,
237            },
238            TrafficFilterSpecWire::Port {
239                mut ports,
240                match_opposite: legacy_mo,
241            } => {
242                if legacy_mo {
243                    match &mut ports {
244                        PortSpec::Values { match_opposite, .. }
245                        | PortSpec::MatchingList { match_opposite, .. } => {
246                            *match_opposite = *match_opposite || legacy_mo;
247                        }
248                    }
249                }
250                Self::Port { ports }
251            }
252            TrafficFilterSpecWire::PortMatchingList {
253                list_id,
254                match_opposite,
255            } => Self::Port {
256                ports: PortSpec::MatchingList {
257                    list_id,
258                    match_opposite,
259                },
260            },
261            TrafficFilterSpecWire::IpMatchingList {
262                list_id,
263                match_opposite,
264                ports,
265            } => Self::IpMatchingList {
266                list_id,
267                match_opposite,
268                ports,
269            },
270        }
271    }
272}
273
274/// Deserialize a [`PortSpec`] from either the new tagged shape
275/// (`{"type": "values", "items": [...]}`) or the legacy flat
276/// `Vec<String>` array used pre-PortSpec.
277fn deserialize_port_spec<'de, D>(deserializer: D) -> Result<PortSpec, D::Error>
278where
279    D: serde::Deserializer<'de>,
280{
281    #[derive(Deserialize)]
282    #[serde(untagged)]
283    enum Compat {
284        Tagged(PortSpec),
285        LegacyArray(Vec<String>),
286    }
287    Ok(match Compat::deserialize(deserializer)? {
288        Compat::Tagged(spec) => spec,
289        Compat::LegacyArray(items) => PortSpec::Values {
290            items,
291            match_opposite: false,
292        },
293    })
294}
295
296fn deserialize_port_spec_opt<'de, D>(deserializer: D) -> Result<Option<PortSpec>, D::Error>
297where
298    D: serde::Deserializer<'de>,
299{
300    #[derive(Deserialize)]
301    #[serde(untagged)]
302    enum Compat {
303        Tagged(PortSpec),
304        LegacyArray(Vec<String>),
305    }
306    let opt: Option<Compat> = Option::deserialize(deserializer)?;
307    Ok(opt.map(|compat| match compat {
308        Compat::Tagged(spec) => spec,
309        Compat::LegacyArray(items) => PortSpec::Values {
310            items,
311            match_opposite: false,
312        },
313    }))
314}
315
316impl CreateFirewallPolicyRequest {
317    /// Convert shorthand `src_ip`/`dst_ip`/`src_port`/`dst_port`/`src_network`/
318    /// `dst_network` fields into the canonical `source_filter`/`destination_filter`.
319    ///
320    /// Returns `Err` if both a shorthand field and the corresponding filter are set,
321    /// or if more than one shorthand family is specified for the same side.
322    pub fn resolve_filters(&mut self) -> Result<(), String> {
323        self.source_filter = resolve_side(
324            "src",
325            self.source_filter.take(),
326            self.src_network.take(),
327            self.src_ip.take(),
328            self.src_port.take(),
329        )?;
330        self.destination_filter = resolve_side(
331            "dst",
332            self.destination_filter.take(),
333            self.dst_network.take(),
334            self.dst_ip.take(),
335            self.dst_port.take(),
336        )?;
337        Ok(())
338    }
339}
340
341impl UpdateFirewallPolicyRequest {
342    /// Same as [`CreateFirewallPolicyRequest::resolve_filters`].
343    pub fn resolve_filters(&mut self) -> Result<(), String> {
344        self.source_filter = resolve_side(
345            "src",
346            self.source_filter.take(),
347            self.src_network.take(),
348            self.src_ip.take(),
349            self.src_port.take(),
350        )?;
351        self.destination_filter = resolve_side(
352            "dst",
353            self.destination_filter.take(),
354            self.dst_network.take(),
355            self.dst_ip.take(),
356            self.dst_port.take(),
357        )?;
358        Ok(())
359    }
360}
361
362fn resolve_side(
363    prefix: &str,
364    existing: Option<TrafficFilterSpec>,
365    networks: Option<Vec<String>>,
366    ips: Option<Vec<String>>,
367    ports: Option<Vec<String>>,
368) -> Result<Option<TrafficFilterSpec>, String> {
369    // network + ip is invalid; port can combine with either network or ip
370    if networks.is_some() && ips.is_some() {
371        return Err(format!("cannot combine {prefix}_network and {prefix}_ip"));
372    }
373
374    let has_shorthand = networks.is_some() || ips.is_some() || ports.is_some();
375
376    if has_shorthand && existing.is_some() {
377        return Err(format!(
378            "cannot combine shorthand fields with {prefix_filter}",
379            prefix_filter = if prefix == "src" {
380                "source_filter"
381            } else {
382                "destination_filter"
383            }
384        ));
385    }
386
387    if let Some(existing) = existing {
388        return Ok(Some(existing));
389    }
390
391    let port_spec = ports.map(|items| PortSpec::Values {
392        items,
393        match_opposite: false,
394    });
395
396    Ok(if let Some(network_ids) = networks {
397        Some(TrafficFilterSpec::Network {
398            network_ids,
399            match_opposite: false,
400            ports: port_spec,
401        })
402    } else if let Some(addresses) = ips {
403        Some(TrafficFilterSpec::IpAddress {
404            addresses,
405            match_opposite: false,
406            ports: port_spec,
407        })
408    } else {
409        port_spec.map(|ports| TrafficFilterSpec::Port { ports })
410    })
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct CreateFirewallZoneRequest {
415    pub name: String,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub description: Option<String>,
418    #[serde(alias = "networks")]
419    pub network_ids: Vec<EntityId>,
420}
421
422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct UpdateFirewallZoneRequest {
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub name: Option<String>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub description: Option<String>,
428    #[serde(skip_serializing_if = "Option::is_none", alias = "networks")]
429    pub network_ids: Option<Vec<EntityId>>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct CreateAclRuleRequest {
434    pub name: String,
435    #[serde(default = "default_acl_rule_type")]
436    pub rule_type: String,
437    pub action: FirewallAction,
438    #[serde(alias = "source_zone")]
439    pub source_zone_id: EntityId,
440    #[serde(alias = "dest_zone")]
441    pub destination_zone_id: EntityId,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub description: Option<String>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub protocol: Option<String>,
446    #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
447    pub source_port: Option<String>,
448    #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
449    pub destination_port: Option<String>,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub source_filter: Option<TrafficFilterSpec>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub destination_filter: Option<TrafficFilterSpec>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub enforcing_device_filter: Option<Value>,
456    #[serde(default = "default_true")]
457    pub enabled: bool,
458}
459
460fn default_acl_rule_type() -> String {
461    "IP".into()
462}
463
464#[derive(Debug, Clone, Default, Serialize, Deserialize)]
465pub struct UpdateAclRuleRequest {
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub name: Option<String>,
468    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
469    pub rule_type: Option<String>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub action: Option<FirewallAction>,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub enabled: Option<bool>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub description: Option<String>,
476    #[serde(skip_serializing_if = "Option::is_none", alias = "source_zone")]
477    pub source_zone_id: Option<EntityId>,
478    #[serde(skip_serializing_if = "Option::is_none", alias = "dest_zone")]
479    pub destination_zone_id: Option<EntityId>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub protocol: Option<String>,
482    #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
483    pub source_port: Option<String>,
484    #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
485    pub destination_port: Option<String>,
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub source_filter: Option<TrafficFilterSpec>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub destination_filter: Option<TrafficFilterSpec>,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub enforcing_device_filter: Option<Value>,
492}
493
494// ── NAT Policy ──────────────────────────────────────────────────
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct CreateNatPolicyRequest {
498    pub name: String,
499    /// masquerade | source | destination
500    #[serde(rename = "type", alias = "nat_type")]
501    pub nat_type: String,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub description: Option<String>,
504    #[serde(default = "default_true")]
505    pub enabled: bool,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub interface_id: Option<EntityId>,
508    /// tcp | udp | tcp_udp | all
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub protocol: Option<String>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub src_address: Option<String>,
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub src_port: Option<String>,
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub dst_address: Option<String>,
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub dst_port: Option<String>,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub translated_address: Option<String>,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub translated_port: Option<String>,
523}
524
525#[derive(Debug, Clone, Default, Serialize, Deserialize)]
526pub struct UpdateNatPolicyRequest {
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub name: Option<String>,
529    /// masquerade | source | destination
530    #[serde(
531        rename = "type",
532        alias = "nat_type",
533        skip_serializing_if = "Option::is_none"
534    )]
535    pub nat_type: Option<String>,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub description: Option<String>,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub enabled: Option<bool>,
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub interface_id: Option<EntityId>,
542    /// tcp | udp | tcp_udp | all
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub protocol: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub src_address: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub src_port: Option<String>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub dst_address: Option<String>,
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub dst_port: Option<String>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub translated_address: Option<String>,
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub translated_port: Option<String>,
557}
558
559// ── Firewall Group ───────────────────────────────────────────
560
561use crate::model::FirewallGroupType;
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct CreateFirewallGroupRequest {
565    pub name: String,
566    /// Group type. Required from `--from-file`; the CLI flag path always
567    /// populates this. Accepts kebab-case (`"port-group"`,
568    /// `"address-group"`, `"ipv6-address-group"`) matching the CLI
569    /// `--type` flag, and PascalCase variant names for backward
570    /// compatibility. Aliased as `type` so JSON files mirroring the
571    /// CLI flag (`{"type": "address-group", ...}`) round-trip cleanly.
572    #[serde(alias = "type")]
573    pub group_type: FirewallGroupType,
574    #[serde(alias = "members")]
575    pub group_members: Vec<String>,
576}
577
578#[derive(Debug, Clone, Default, Serialize, Deserialize)]
579pub struct UpdateFirewallGroupRequest {
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub name: Option<String>,
582    #[serde(skip_serializing_if = "Option::is_none", alias = "members")]
583    pub group_members: Option<Vec<String>>,
584}
585
586#[cfg(test)]
587mod tests {
588    use super::{
589        CreateAclRuleRequest, CreateFirewallGroupRequest, CreateFirewallPolicyRequest, PortSpec,
590        TrafficFilterSpec, UpdateAclRuleRequest, UpdateFirewallGroupRequest,
591        UpdateFirewallPolicyRequest,
592    };
593    use crate::model::{FirewallAction, FirewallGroupType};
594
595    /// Bug 1 regression: dst_ip and dst_port in --from-file JSON must
596    /// deserialize into the shorthand fields (not be silently dropped).
597    #[test]
598    fn create_firewall_policy_shorthand_fields_deserialize() {
599        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
600            "name": "Allow Awair",
601            "action": "Allow",
602            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
603            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
604            "allow_return_traffic": false,
605            "connection_states": ["NEW"],
606            "dst_ip": ["10.0.40.10"],
607            "dst_port": ["80"]
608        }))
609        .expect("shorthand fields should deserialize");
610
611        assert_eq!(req.dst_ip.as_deref(), Some(&["10.0.40.10".to_owned()][..]));
612        assert_eq!(req.dst_port.as_deref(), Some(&["80".to_owned()][..]));
613        // Filter fields should still be None — resolution happens later
614        assert!(req.destination_filter.is_none());
615    }
616
617    /// Shorthand fields must not leak into serialized output (they are
618    /// internal to --from-file and should never reach the API wire format).
619    #[test]
620    fn create_firewall_policy_shorthand_fields_skip_serializing() {
621        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
622            "name": "Test",
623            "action": "Block",
624            "source_zone_id": "aaa",
625            "destination_zone_id": "bbb",
626            "dst_ip": ["10.0.0.1"]
627        }))
628        .expect("should deserialize");
629
630        let value = serde_json::to_value(&req).expect("should serialize");
631        assert!(value.get("dst_ip").is_none(), "dst_ip must not serialize");
632        assert!(
633            value.get("dst_port").is_none(),
634            "dst_port must not serialize"
635        );
636        assert!(value.get("src_ip").is_none(), "src_ip must not serialize");
637    }
638
639    /// The existing source_filter / destination_filter path must still work
640    /// for users who write the full TrafficFilterSpec in their JSON files.
641    #[test]
642    fn create_firewall_policy_full_filter_spec_still_works() {
643        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
644            "name": "Full filter",
645            "action": "Allow",
646            "source_zone_id": "aaa",
647            "destination_zone_id": "bbb",
648            "destination_filter": {
649                "type": "ip_address",
650                "addresses": ["10.0.40.10"],
651                "match_opposite": false
652            }
653        }))
654        .expect("full filter spec should deserialize");
655
656        assert!(req.destination_filter.is_some());
657        assert!(req.dst_ip.is_none());
658    }
659
660    /// dst_ip + dst_port should combine into IpAddress filter with nested ports
661    #[test]
662    fn resolve_filters_combines_dst_ip_and_dst_port() {
663        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
664            "name": "Allow Awair",
665            "action": "Allow",
666            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
667            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
668            "dst_ip": ["10.0.40.10"],
669            "dst_port": ["80"]
670        }))
671        .expect("should deserialize");
672
673        req.resolve_filters().expect("ip + port should be allowed");
674        match &req.destination_filter {
675            Some(TrafficFilterSpec::IpAddress {
676                addresses, ports, ..
677            }) => {
678                assert_eq!(addresses, &["10.0.40.10"]);
679                let Some(PortSpec::Values { items, .. }) = ports else {
680                    panic!("expected PortSpec::Values, got {ports:?}")
681                };
682                assert_eq!(items, &["80"]);
683            }
684            other => panic!("expected IpAddress filter with ports, got {other:?}"),
685        }
686    }
687
688    /// dst_network + dst_ip is still invalid (two primary filter types)
689    #[test]
690    fn resolve_filters_rejects_network_plus_ip() {
691        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
692            "name": "Conflict",
693            "action": "Block",
694            "source_zone_id": "aaa",
695            "destination_zone_id": "bbb",
696            "dst_network": ["net-uuid"],
697            "dst_ip": ["10.0.0.1"]
698        }))
699        .expect("should deserialize");
700
701        assert!(req.resolve_filters().is_err());
702    }
703
704    #[test]
705    fn resolve_filters_converts_dst_ip_only() {
706        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
707            "name": "Allow Awair",
708            "action": "Allow",
709            "source_zone_id": "aaa",
710            "destination_zone_id": "bbb",
711            "dst_ip": ["10.0.40.10"]
712        }))
713        .expect("should deserialize");
714
715        req.resolve_filters().expect("should resolve");
716        match &req.destination_filter {
717            Some(TrafficFilterSpec::IpAddress { addresses, .. }) => {
718                assert_eq!(addresses, &["10.0.40.10"]);
719            }
720            other => panic!("expected IpAddress filter, got {other:?}"),
721        }
722    }
723
724    #[test]
725    fn resolve_filters_rejects_shorthand_plus_full_filter() {
726        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
727            "name": "Conflict",
728            "action": "Block",
729            "source_zone_id": "aaa",
730            "destination_zone_id": "bbb",
731            "dst_ip": ["10.0.0.1"],
732            "destination_filter": {
733                "type": "ip_address",
734                "addresses": ["10.0.0.2"]
735            }
736        }))
737        .expect("should deserialize");
738
739        let err = req.resolve_filters().expect_err("should conflict");
740        assert!(err.contains("cannot combine"), "got: {err}");
741    }
742
743    #[test]
744    fn resolve_filters_update_request_works() {
745        let mut req: UpdateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
746            "dst_port": ["443", "8443"]
747        }))
748        .expect("should deserialize");
749
750        req.resolve_filters().expect("should resolve");
751        let Some(TrafficFilterSpec::Port {
752            ports: PortSpec::Values { items, .. },
753        }) = &req.destination_filter
754        else {
755            panic!(
756                "expected Port filter with values, got {:?}",
757                req.destination_filter
758            )
759        };
760        assert_eq!(items, &["443", "8443"]);
761    }
762
763    /// Pre-PortSpec JSON files used a flat `Vec<String>` for `Port.ports`
764    /// with `match_opposite` at the variant level. The new schema nests
765    /// both inside `PortSpec`, but the deserializer must still accept the
766    /// legacy shape so existing payloads keep working.
767    #[test]
768    fn destination_filter_accepts_legacy_port_variant_shape() {
769        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
770            "name": "Block port 80",
771            "action": "Block",
772            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
773            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
774            "destination_filter": {
775                "type": "port",
776                "ports": ["80"],
777                "match_opposite": true
778            }
779        }))
780        .expect("legacy port shape should still deserialize");
781
782        let Some(TrafficFilterSpec::Port {
783            ports:
784                PortSpec::Values {
785                    items,
786                    match_opposite,
787                },
788        }) = &req.destination_filter
789        else {
790            panic!(
791                "expected Port with PortSpec::Values, got {:?}",
792                req.destination_filter
793            )
794        };
795        assert_eq!(items, &["80"]);
796        // Legacy outer match_opposite is folded into the inner PortSpec.
797        assert!(*match_opposite);
798    }
799
800    /// Tagged PortSpec::MatchingList round-trips from JSON as a sibling of
801    /// addresses (the shape PR 2's group resolver emits and what users will
802    /// hand-write for direct group-uuid references).
803    #[test]
804    fn destination_filter_accepts_ip_address_with_port_matching_list() {
805        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
806            "name": "Apple Companion Link",
807            "action": "Allow",
808            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
809            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
810            "destination_filter": {
811                "type": "ip_address",
812                "addresses": ["10.0.10.2", "10.0.10.4"],
813                "ports": {
814                    "type": "matching_list",
815                    "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55"
816                }
817            }
818        }))
819        .expect("ip_address + port matching_list should deserialize");
820
821        req.resolve_filters().expect("no shorthand, no-op");
822
823        let Some(TrafficFilterSpec::IpAddress {
824            addresses,
825            ports: Some(PortSpec::MatchingList { list_id, .. }),
826            ..
827        }) = &req.destination_filter
828        else {
829            panic!(
830                "expected IpAddress with PortSpec::MatchingList, got {:?}",
831                req.destination_filter
832            )
833        };
834        assert_eq!(addresses, &["10.0.10.2", "10.0.10.4"]);
835        assert_eq!(list_id, "24740a56-9cb9-4890-a5ac-589d30914a55");
836    }
837
838    #[test]
839    fn create_acl_rule_request_defaults_rule_type() {
840        let request: CreateAclRuleRequest = serde_json::from_value(serde_json::json!({
841            "name": "Allow IoT",
842            "action": "Allow",
843            "source_zone_id": "iot",
844            "destination_zone_id": "lan",
845            "enabled": true
846        }))
847        .expect("acl rule request should deserialize");
848
849        assert_eq!(request.rule_type, "IP");
850    }
851
852    #[test]
853    fn update_acl_rule_request_serializes_type_field() {
854        let request = UpdateAclRuleRequest {
855            rule_type: Some("DEVICE".into()),
856            action: Some(FirewallAction::Allow),
857            ..Default::default()
858        };
859
860        let value = serde_json::to_value(&request).expect("acl rule request should serialize");
861        assert_eq!(
862            value.get("type").and_then(serde_json::Value::as_str),
863            Some("DEVICE")
864        );
865        assert_eq!(value.get("rule_type"), None);
866    }
867
868    // ── Group shorthand tests ──────────────────────────────────────
869
870    #[test]
871    fn group_shorthand_fields_deserialize() {
872        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
873            "name": "HA IoT Services",
874            "action": "Allow",
875            "source_zone_id": "aaa",
876            "destination_zone_id": "bbb",
877            "dst_port_group": "HA",
878            "src_address_group": "Cloud IOT"
879        }))
880        .expect("group shorthands should deserialize");
881
882        assert_eq!(req.dst_port_group.as_deref(), Some("HA"));
883        assert_eq!(req.src_address_group.as_deref(), Some("Cloud IOT"));
884        assert!(req.destination_filter.is_none());
885        assert!(req.source_filter.is_none());
886    }
887
888    #[test]
889    fn group_shorthand_fields_skip_serializing() {
890        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
891            "name": "Test",
892            "action": "Allow",
893            "source_zone_id": "aaa",
894            "destination_zone_id": "bbb",
895            "dst_port_group": "HA",
896            "dst_address_group": "Cloud IOT"
897        }))
898        .expect("should deserialize");
899
900        let value = serde_json::to_value(&req).expect("should serialize");
901        assert!(
902            value.get("dst_port_group").is_none(),
903            "dst_port_group must not serialize"
904        );
905        assert!(
906            value.get("dst_address_group").is_none(),
907            "dst_address_group must not serialize"
908        );
909        assert!(
910            value.get("src_port_group").is_none(),
911            "src_port_group must not serialize"
912        );
913        assert!(
914            value.get("src_address_group").is_none(),
915            "src_address_group must not serialize"
916        );
917    }
918
919    #[test]
920    fn update_group_shorthand_fields_deserialize() {
921        let req: UpdateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
922            "dst_port_group": "HA"
923        }))
924        .expect("update group shorthand should deserialize");
925
926        assert_eq!(req.dst_port_group.as_deref(), Some("HA"));
927    }
928
929    /// Firewall-group `--from-file` JSON should accept `members` (mirroring
930    /// the CLI flag name) as well as the wire-level `group_members`.
931    /// Otherwise serde silently drops the CLI-style field and a file
932    /// written from `--help` output PUTs an unchanged group while
933    /// reporting success.
934    #[test]
935    fn create_firewall_group_request_accepts_members_alias() {
936        let req: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
937            "name": "HA",
938            "type": "port-group",
939            "members": ["80", "8000-8002"]
940        }))
941        .expect("members alias should deserialize");
942
943        assert_eq!(req.name, "HA");
944        assert_eq!(req.group_members, vec!["80", "8000-8002"]);
945    }
946
947    /// `--from-file` JSON should accept the kebab-case `type` field
948    /// (mirroring the CLI `--type` flag) and deserialize each known
949    /// group type into its Rust variant. Without this, a file like
950    /// `{"type": "address-group", ...}` was silently parsed as a port
951    /// group via the previous default, corrupting the wire payload.
952    #[test]
953    fn create_firewall_group_request_kebab_case_type_alias() {
954        let port: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
955            "name": "HA",
956            "type": "port-group",
957            "members": ["80"]
958        }))
959        .expect("kebab-case port-group should deserialize");
960        assert_eq!(port.group_type, FirewallGroupType::PortGroup);
961
962        let addr: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
963            "name": "Cloud IOT",
964            "type": "address-group",
965            "members": ["10.0.0.1"]
966        }))
967        .expect("kebab-case address-group should deserialize");
968        assert_eq!(addr.group_type, FirewallGroupType::AddressGroup);
969
970        let ipv6: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
971            "name": "ULA",
972            "type": "ipv6-address-group",
973            "members": ["fd00::/8"]
974        }))
975        .expect("kebab-case ipv6-address-group should deserialize");
976        assert_eq!(ipv6.group_type, FirewallGroupType::Ipv6AddressGroup);
977
978        // PascalCase still works for backward compatibility with files
979        // produced before the alias was added.
980        let legacy: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
981            "name": "HA",
982            "group_type": "AddressGroup",
983            "members": ["10.0.0.1"]
984        }))
985        .expect("PascalCase variant should deserialize");
986        assert_eq!(legacy.group_type, FirewallGroupType::AddressGroup);
987    }
988
989    /// Missing type should now error rather than silently default to
990    /// `port-group` -- a payload like `{"name":"x","members":["10.0.0.1"]}`
991    /// was getting silently classified as a port group with addresses
992    /// as members, producing an invalid wire payload.
993    #[test]
994    fn create_firewall_group_request_requires_type() {
995        let result: Result<CreateFirewallGroupRequest, _> =
996            serde_json::from_value(serde_json::json!({
997                "name": "Cloud IOT",
998                "members": ["10.0.0.1"]
999            }));
1000        assert!(
1001            result.is_err(),
1002            "missing `type` / `group_type` should not silently default to PortGroup"
1003        );
1004    }
1005
1006    #[test]
1007    fn update_firewall_group_request_accepts_members_alias() {
1008        let req: UpdateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
1009            "members": ["80", "443"]
1010        }))
1011        .expect("members alias should deserialize");
1012
1013        assert_eq!(
1014            req.group_members.as_deref(),
1015            Some(&["80".into(), "443".into()][..])
1016        );
1017    }
1018
1019    // ── TrafficFilterSpec matching list variants ───────────────────
1020
1021    /// Port-group references are modeled as `Port { ports: PortSpec::MatchingList }`.
1022    /// The legacy `port_matching_list` top-level variant is accepted on
1023    /// deserialize and lowered to the new shape.
1024    #[test]
1025    fn port_group_reference_round_trips_via_port_variant() {
1026        let spec = TrafficFilterSpec::Port {
1027            ports: PortSpec::MatchingList {
1028                list_id: "24740a56-9cb9-4890-a5ac-589d30914a55".into(),
1029                match_opposite: false,
1030            },
1031        };
1032        let json = serde_json::to_value(&spec).expect("should serialize");
1033        assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("port"));
1034
1035        // Legacy port_matching_list shape still deserializes (lowered to Port).
1036        let legacy = serde_json::json!({
1037            "type": "port_matching_list",
1038            "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55",
1039            "match_opposite": false,
1040        });
1041        let from_legacy: TrafficFilterSpec =
1042            serde_json::from_value(legacy).expect("legacy shape should deserialize");
1043        assert!(matches!(
1044            from_legacy,
1045            TrafficFilterSpec::Port {
1046                ports: PortSpec::MatchingList { .. },
1047            }
1048        ));
1049    }
1050
1051    #[test]
1052    fn ip_matching_list_round_trips() {
1053        let spec = TrafficFilterSpec::IpMatchingList {
1054            list_id: "b777b27c-410c-4b40-8489-a61bf1a536d4".into(),
1055            match_opposite: true,
1056            ports: None,
1057        };
1058        let json = serde_json::to_value(&spec).expect("should serialize");
1059        assert_eq!(
1060            json.get("type").and_then(|v| v.as_str()),
1061            Some("ip_matching_list")
1062        );
1063
1064        let round_tripped: TrafficFilterSpec =
1065            serde_json::from_value(json).expect("should deserialize");
1066        match round_tripped {
1067            TrafficFilterSpec::IpMatchingList { match_opposite, .. } => assert!(match_opposite),
1068            other => panic!("expected IpMatchingList, got {other:?}"),
1069        }
1070    }
1071}