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
32fn default_true() -> bool {
33    true
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct UpdateFirewallPolicyRequest {
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub name: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub action: Option<FirewallAction>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub allow_return_traffic: Option<bool>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub enabled: Option<bool>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub description: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub ip_version: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub connection_states: Option<Vec<String>>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub source_filter: Option<TrafficFilterSpec>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub destination_filter: Option<TrafficFilterSpec>,
56    #[serde(skip_serializing_if = "Option::is_none", alias = "logging")]
57    pub logging_enabled: Option<bool>,
58}
59
60/// Port-side specification: either inline values or a reference to a
61/// firewall port-group by its `external_id`. Mirrors the controller's
62/// portFilter wire shape.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum PortSpec {
66    /// Inline port values (single ports or ranges like `"8000-9000"`).
67    Values {
68        items: Vec<String>,
69        #[serde(default)]
70        match_opposite: bool,
71    },
72    /// Reference to a port-group via its `external_id` UUID.
73    MatchingList {
74        list_id: String,
75        #[serde(default)]
76        match_opposite: bool,
77    },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(
82    tag = "type",
83    rename_all = "snake_case",
84    from = "TrafficFilterSpecWire"
85)]
86pub enum TrafficFilterSpec {
87    Network {
88        network_ids: Vec<String>,
89        #[serde(default)]
90        match_opposite: bool,
91        /// Optional port restriction (the API nests portFilter inside the
92        /// network/IP filter rather than treating it as a separate type).
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        ports: Option<PortSpec>,
95    },
96    IpAddress {
97        addresses: Vec<String>,
98        #[serde(default)]
99        match_opposite: bool,
100        /// Optional port restriction.
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        ports: Option<PortSpec>,
103    },
104    Port {
105        ports: PortSpec,
106    },
107    /// Address-group filter referencing a firewall group (address-group)
108    /// by its `external_id`. May carry an optional port restriction in
109    /// the same filter (mirrors what `IpAddress` supports for inline
110    /// addresses).
111    IpMatchingList {
112        list_id: String,
113        #[serde(default)]
114        match_opposite: bool,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        ports: Option<PortSpec>,
117    },
118}
119
120/// Internal wire-format wrapper used during deserialization to accept
121/// pre-PortSpec JSON files. The legacy `Port` variant stored ports as a
122/// flat `Vec<String>` with `match_opposite` at the variant level. The
123/// legacy `port_matching_list` top-level variant carried a port-group
124/// reference; it lowers to `Port { ports: PortSpec::MatchingList { ... } }`.
125#[derive(Deserialize)]
126#[serde(tag = "type", rename_all = "snake_case")]
127enum TrafficFilterSpecWire {
128    Network {
129        network_ids: Vec<String>,
130        #[serde(default)]
131        match_opposite: bool,
132        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
133        ports: Option<PortSpec>,
134    },
135    IpAddress {
136        addresses: Vec<String>,
137        #[serde(default)]
138        match_opposite: bool,
139        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
140        ports: Option<PortSpec>,
141    },
142    Port {
143        #[serde(deserialize_with = "deserialize_port_spec")]
144        ports: PortSpec,
145        /// Legacy field: pre-PortSpec the Port variant carried
146        /// `match_opposite` at the variant level. Folded into the inner
147        /// `PortSpec` during conversion.
148        #[serde(default)]
149        match_opposite: bool,
150    },
151    /// Legacy top-level port-group reference. Lowered to `Port` with a
152    /// nested `PortSpec::MatchingList` during conversion.
153    PortMatchingList {
154        list_id: String,
155        #[serde(default)]
156        match_opposite: bool,
157    },
158    /// Address-group filter. Optional `ports` companion supports rules
159    /// like "members of address-group X on port-group Y" in one filter.
160    IpMatchingList {
161        list_id: String,
162        #[serde(default)]
163        match_opposite: bool,
164        #[serde(default, deserialize_with = "deserialize_port_spec_opt")]
165        ports: Option<PortSpec>,
166    },
167}
168
169impl From<TrafficFilterSpecWire> for TrafficFilterSpec {
170    fn from(wire: TrafficFilterSpecWire) -> Self {
171        match wire {
172            TrafficFilterSpecWire::Network {
173                network_ids,
174                match_opposite,
175                ports,
176            } => Self::Network {
177                network_ids,
178                match_opposite,
179                ports,
180            },
181            TrafficFilterSpecWire::IpAddress {
182                addresses,
183                match_opposite,
184                ports,
185            } => Self::IpAddress {
186                addresses,
187                match_opposite,
188                ports,
189            },
190            TrafficFilterSpecWire::Port {
191                mut ports,
192                match_opposite: legacy_mo,
193            } => {
194                if legacy_mo {
195                    match &mut ports {
196                        PortSpec::Values { match_opposite, .. }
197                        | PortSpec::MatchingList { match_opposite, .. } => {
198                            *match_opposite = *match_opposite || legacy_mo;
199                        }
200                    }
201                }
202                Self::Port { ports }
203            }
204            TrafficFilterSpecWire::PortMatchingList {
205                list_id,
206                match_opposite,
207            } => Self::Port {
208                ports: PortSpec::MatchingList {
209                    list_id,
210                    match_opposite,
211                },
212            },
213            TrafficFilterSpecWire::IpMatchingList {
214                list_id,
215                match_opposite,
216                ports,
217            } => Self::IpMatchingList {
218                list_id,
219                match_opposite,
220                ports,
221            },
222        }
223    }
224}
225
226/// Deserialize a [`PortSpec`] from either the new tagged shape
227/// (`{"type": "values", "items": [...]}`) or the legacy flat
228/// `Vec<String>` array used pre-PortSpec.
229fn deserialize_port_spec<'de, D>(deserializer: D) -> Result<PortSpec, D::Error>
230where
231    D: serde::Deserializer<'de>,
232{
233    #[derive(Deserialize)]
234    #[serde(untagged)]
235    enum Compat {
236        Tagged(PortSpec),
237        LegacyArray(Vec<String>),
238    }
239    Ok(match Compat::deserialize(deserializer)? {
240        Compat::Tagged(spec) => spec,
241        Compat::LegacyArray(items) => PortSpec::Values {
242            items,
243            match_opposite: false,
244        },
245    })
246}
247
248fn deserialize_port_spec_opt<'de, D>(deserializer: D) -> Result<Option<PortSpec>, D::Error>
249where
250    D: serde::Deserializer<'de>,
251{
252    #[derive(Deserialize)]
253    #[serde(untagged)]
254    enum Compat {
255        Tagged(PortSpec),
256        LegacyArray(Vec<String>),
257    }
258    let opt: Option<Compat> = Option::deserialize(deserializer)?;
259    Ok(opt.map(|compat| match compat {
260        Compat::Tagged(spec) => spec,
261        Compat::LegacyArray(items) => PortSpec::Values {
262            items,
263            match_opposite: false,
264        },
265    }))
266}
267
268impl CreateFirewallPolicyRequest {
269    /// Compatibility no-op retained for callers from the pre-0.10 request shape.
270    ///
271    /// Policy requests are canonical now; CLI-only shorthand fields are
272    /// normalized before constructing this public API type.
273    pub fn resolve_filters(&mut self) -> Result<(), String> {
274        Ok(())
275    }
276}
277
278impl UpdateFirewallPolicyRequest {
279    /// Same as [`CreateFirewallPolicyRequest::resolve_filters`].
280    pub fn resolve_filters(&mut self) -> Result<(), String> {
281        Ok(())
282    }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct CreateFirewallZoneRequest {
287    pub name: String,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub description: Option<String>,
290    #[serde(alias = "networks")]
291    pub network_ids: Vec<EntityId>,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct UpdateFirewallZoneRequest {
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub name: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub description: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none", alias = "networks")]
301    pub network_ids: Option<Vec<EntityId>>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct CreateAclRuleRequest {
306    pub name: String,
307    #[serde(default = "default_acl_rule_type")]
308    pub rule_type: String,
309    pub action: FirewallAction,
310    #[serde(alias = "source_zone")]
311    pub source_zone_id: EntityId,
312    #[serde(alias = "dest_zone")]
313    pub destination_zone_id: EntityId,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub description: Option<String>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub protocol: Option<String>,
318    #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
319    pub source_port: Option<String>,
320    #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
321    pub destination_port: Option<String>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub source_filter: Option<TrafficFilterSpec>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub destination_filter: Option<TrafficFilterSpec>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub enforcing_device_filter: Option<Value>,
328    #[serde(default = "default_true")]
329    pub enabled: bool,
330}
331
332fn default_acl_rule_type() -> String {
333    "IP".into()
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize)]
337pub struct UpdateAclRuleRequest {
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub name: Option<String>,
340    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
341    pub rule_type: Option<String>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub action: Option<FirewallAction>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub enabled: Option<bool>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub description: Option<String>,
348    #[serde(skip_serializing_if = "Option::is_none", alias = "source_zone")]
349    pub source_zone_id: Option<EntityId>,
350    #[serde(skip_serializing_if = "Option::is_none", alias = "dest_zone")]
351    pub destination_zone_id: Option<EntityId>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub protocol: Option<String>,
354    #[serde(skip_serializing_if = "Option::is_none", alias = "src_port")]
355    pub source_port: Option<String>,
356    #[serde(skip_serializing_if = "Option::is_none", alias = "dst_port")]
357    pub destination_port: Option<String>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub source_filter: Option<TrafficFilterSpec>,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub destination_filter: Option<TrafficFilterSpec>,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub enforcing_device_filter: Option<Value>,
364}
365
366// ── NAT Policy ──────────────────────────────────────────────────
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct CreateNatPolicyRequest {
370    pub name: String,
371    /// masquerade | source | destination
372    #[serde(rename = "type", alias = "nat_type")]
373    pub nat_type: String,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub description: Option<String>,
376    #[serde(default = "default_true")]
377    pub enabled: bool,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub interface_id: Option<EntityId>,
380    /// tcp | udp | tcp_udp | all
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub protocol: Option<String>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub src_address: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub src_port: Option<String>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub dst_address: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub dst_port: Option<String>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub translated_address: Option<String>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub translated_port: Option<String>,
395}
396
397#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398pub struct UpdateNatPolicyRequest {
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub name: Option<String>,
401    /// masquerade | source | destination
402    #[serde(
403        rename = "type",
404        alias = "nat_type",
405        skip_serializing_if = "Option::is_none"
406    )]
407    pub nat_type: Option<String>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub description: Option<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub enabled: Option<bool>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub interface_id: Option<EntityId>,
414    /// tcp | udp | tcp_udp | all
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub protocol: Option<String>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub src_address: Option<String>,
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub src_port: Option<String>,
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub dst_address: Option<String>,
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub dst_port: Option<String>,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub translated_address: Option<String>,
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub translated_port: Option<String>,
429}
430
431// ── Firewall Group ───────────────────────────────────────────
432
433use crate::model::FirewallGroupType;
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct CreateFirewallGroupRequest {
437    pub name: String,
438    /// Group type. Required from `--from-file`; the CLI flag path always
439    /// populates this. Accepts kebab-case (`"port-group"`,
440    /// `"address-group"`, `"ipv6-address-group"`) matching the CLI
441    /// `--type` flag, and PascalCase variant names for backward
442    /// compatibility. Aliased as `type` so JSON files mirroring the
443    /// CLI flag (`{"type": "address-group", ...}`) round-trip cleanly.
444    #[serde(alias = "type")]
445    pub group_type: FirewallGroupType,
446    #[serde(alias = "members")]
447    pub group_members: Vec<String>,
448}
449
450#[derive(Debug, Clone, Default, Serialize, Deserialize)]
451pub struct UpdateFirewallGroupRequest {
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub name: Option<String>,
454    #[serde(skip_serializing_if = "Option::is_none", alias = "members")]
455    pub group_members: Option<Vec<String>>,
456}
457
458#[cfg(test)]
459mod tests {
460    use super::{
461        CreateAclRuleRequest, CreateFirewallGroupRequest, CreateFirewallPolicyRequest, PortSpec,
462        TrafficFilterSpec, UpdateAclRuleRequest, UpdateFirewallGroupRequest,
463    };
464    use crate::model::{FirewallAction, FirewallGroupType};
465
466    /// The existing source_filter / destination_filter path must still work
467    /// for users who write the full TrafficFilterSpec in their JSON files.
468    #[test]
469    fn create_firewall_policy_full_filter_spec_still_works() {
470        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
471            "name": "Full filter",
472            "action": "Allow",
473            "source_zone_id": "aaa",
474            "destination_zone_id": "bbb",
475            "destination_filter": {
476                "type": "ip_address",
477                "addresses": ["10.0.40.10"],
478                "match_opposite": false
479            }
480        }))
481        .expect("full filter spec should deserialize");
482
483        assert!(req.destination_filter.is_some());
484    }
485
486    /// Pre-PortSpec JSON files used a flat `Vec<String>` for `Port.ports`
487    /// with `match_opposite` at the variant level. The new schema nests
488    /// both inside `PortSpec`, but the deserializer must still accept the
489    /// legacy shape so existing payloads keep working.
490    #[test]
491    fn destination_filter_accepts_legacy_port_variant_shape() {
492        let req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
493            "name": "Block port 80",
494            "action": "Block",
495            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
496            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
497            "destination_filter": {
498                "type": "port",
499                "ports": ["80"],
500                "match_opposite": true
501            }
502        }))
503        .expect("legacy port shape should still deserialize");
504
505        let Some(TrafficFilterSpec::Port {
506            ports:
507                PortSpec::Values {
508                    items,
509                    match_opposite,
510                },
511        }) = &req.destination_filter
512        else {
513            panic!(
514                "expected Port with PortSpec::Values, got {:?}",
515                req.destination_filter
516            )
517        };
518        assert_eq!(items, &["80"]);
519        // Legacy outer match_opposite is folded into the inner PortSpec.
520        assert!(*match_opposite);
521    }
522
523    /// Tagged PortSpec::MatchingList round-trips from JSON as a sibling of
524    /// addresses (the shape PR 2's group resolver emits and what users will
525    /// hand-write for direct group-uuid references).
526    #[test]
527    fn destination_filter_accepts_ip_address_with_port_matching_list() {
528        let mut req: CreateFirewallPolicyRequest = serde_json::from_value(serde_json::json!({
529            "name": "Apple Companion Link",
530            "action": "Allow",
531            "source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
532            "destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
533            "destination_filter": {
534                "type": "ip_address",
535                "addresses": ["10.0.10.2", "10.0.10.4"],
536                "ports": {
537                    "type": "matching_list",
538                    "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55"
539                }
540            }
541        }))
542        .expect("ip_address + port matching_list should deserialize");
543
544        req.resolve_filters().expect("no shorthand, no-op");
545
546        let Some(TrafficFilterSpec::IpAddress {
547            addresses,
548            ports: Some(PortSpec::MatchingList { list_id, .. }),
549            ..
550        }) = &req.destination_filter
551        else {
552            panic!(
553                "expected IpAddress with PortSpec::MatchingList, got {:?}",
554                req.destination_filter
555            )
556        };
557        assert_eq!(addresses, &["10.0.10.2", "10.0.10.4"]);
558        assert_eq!(list_id, "24740a56-9cb9-4890-a5ac-589d30914a55");
559    }
560
561    #[test]
562    fn create_acl_rule_request_defaults_rule_type() {
563        let request: CreateAclRuleRequest = serde_json::from_value(serde_json::json!({
564            "name": "Allow IoT",
565            "action": "Allow",
566            "source_zone_id": "iot",
567            "destination_zone_id": "lan",
568            "enabled": true
569        }))
570        .expect("acl rule request should deserialize");
571
572        assert_eq!(request.rule_type, "IP");
573    }
574
575    #[test]
576    fn update_acl_rule_request_serializes_type_field() {
577        let request = UpdateAclRuleRequest {
578            rule_type: Some("DEVICE".into()),
579            action: Some(FirewallAction::Allow),
580            ..Default::default()
581        };
582
583        let value = serde_json::to_value(&request).expect("acl rule request should serialize");
584        assert_eq!(
585            value.get("type").and_then(serde_json::Value::as_str),
586            Some("DEVICE")
587        );
588        assert_eq!(value.get("rule_type"), None);
589    }
590
591    /// Firewall-group `--from-file` JSON should accept `members` (mirroring
592    /// the CLI flag name) as well as the wire-level `group_members`.
593    /// Otherwise serde silently drops the CLI-style field and a file
594    /// written from `--help` output PUTs an unchanged group while
595    /// reporting success.
596    #[test]
597    fn create_firewall_group_request_accepts_members_alias() {
598        let req: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
599            "name": "HA",
600            "type": "port-group",
601            "members": ["80", "8000-8002"]
602        }))
603        .expect("members alias should deserialize");
604
605        assert_eq!(req.name, "HA");
606        assert_eq!(req.group_members, vec!["80", "8000-8002"]);
607    }
608
609    /// `--from-file` JSON should accept the kebab-case `type` field
610    /// (mirroring the CLI `--type` flag) and deserialize each known
611    /// group type into its Rust variant. Without this, a file like
612    /// `{"type": "address-group", ...}` was silently parsed as a port
613    /// group via the previous default, corrupting the wire payload.
614    #[test]
615    fn create_firewall_group_request_kebab_case_type_alias() {
616        let port: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
617            "name": "HA",
618            "type": "port-group",
619            "members": ["80"]
620        }))
621        .expect("kebab-case port-group should deserialize");
622        assert_eq!(port.group_type, FirewallGroupType::PortGroup);
623
624        let addr: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
625            "name": "Cloud IOT",
626            "type": "address-group",
627            "members": ["10.0.0.1"]
628        }))
629        .expect("kebab-case address-group should deserialize");
630        assert_eq!(addr.group_type, FirewallGroupType::AddressGroup);
631
632        let ipv6: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
633            "name": "ULA",
634            "type": "ipv6-address-group",
635            "members": ["fd00::/8"]
636        }))
637        .expect("kebab-case ipv6-address-group should deserialize");
638        assert_eq!(ipv6.group_type, FirewallGroupType::Ipv6AddressGroup);
639
640        // PascalCase still works for backward compatibility with files
641        // produced before the alias was added.
642        let legacy: CreateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
643            "name": "HA",
644            "group_type": "AddressGroup",
645            "members": ["10.0.0.1"]
646        }))
647        .expect("PascalCase variant should deserialize");
648        assert_eq!(legacy.group_type, FirewallGroupType::AddressGroup);
649    }
650
651    /// Missing type should now error rather than silently default to
652    /// `port-group` -- a payload like `{"name":"x","members":["10.0.0.1"]}`
653    /// was getting silently classified as a port group with addresses
654    /// as members, producing an invalid wire payload.
655    #[test]
656    fn create_firewall_group_request_requires_type() {
657        let result: Result<CreateFirewallGroupRequest, _> =
658            serde_json::from_value(serde_json::json!({
659                "name": "Cloud IOT",
660                "members": ["10.0.0.1"]
661            }));
662        assert!(
663            result.is_err(),
664            "missing `type` / `group_type` should not silently default to PortGroup"
665        );
666    }
667
668    #[test]
669    fn update_firewall_group_request_accepts_members_alias() {
670        let req: UpdateFirewallGroupRequest = serde_json::from_value(serde_json::json!({
671            "members": ["80", "443"]
672        }))
673        .expect("members alias should deserialize");
674
675        assert_eq!(
676            req.group_members.as_deref(),
677            Some(&["80".into(), "443".into()][..])
678        );
679    }
680
681    // ── TrafficFilterSpec matching list variants ───────────────────
682
683    /// Port-group references are modeled as `Port { ports: PortSpec::MatchingList }`.
684    /// The legacy `port_matching_list` top-level variant is accepted on
685    /// deserialize and lowered to the new shape.
686    #[test]
687    fn port_group_reference_round_trips_via_port_variant() {
688        let spec = TrafficFilterSpec::Port {
689            ports: PortSpec::MatchingList {
690                list_id: "24740a56-9cb9-4890-a5ac-589d30914a55".into(),
691                match_opposite: false,
692            },
693        };
694        let json = serde_json::to_value(&spec).expect("should serialize");
695        assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("port"));
696
697        // Legacy port_matching_list shape still deserializes (lowered to Port).
698        let legacy = serde_json::json!({
699            "type": "port_matching_list",
700            "list_id": "24740a56-9cb9-4890-a5ac-589d30914a55",
701            "match_opposite": false,
702        });
703        let from_legacy: TrafficFilterSpec =
704            serde_json::from_value(legacy).expect("legacy shape should deserialize");
705        assert!(matches!(
706            from_legacy,
707            TrafficFilterSpec::Port {
708                ports: PortSpec::MatchingList { .. },
709            }
710        ));
711    }
712
713    #[test]
714    fn ip_matching_list_round_trips() {
715        let spec = TrafficFilterSpec::IpMatchingList {
716            list_id: "b777b27c-410c-4b40-8489-a61bf1a536d4".into(),
717            match_opposite: true,
718            ports: None,
719        };
720        let json = serde_json::to_value(&spec).expect("should serialize");
721        assert_eq!(
722            json.get("type").and_then(|v| v.as_str()),
723            Some("ip_matching_list")
724        );
725
726        let round_tripped: TrafficFilterSpec =
727            serde_json::from_value(json).expect("should deserialize");
728        match round_tripped {
729            TrafficFilterSpec::IpMatchingList { match_opposite, .. } => assert!(match_opposite),
730            other => panic!("expected IpMatchingList, got {other:?}"),
731        }
732    }
733}