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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "type", rename_all = "snake_case")]
113pub enum PortSpec {
114 Values {
116 items: Vec<String>,
117 #[serde(default)]
118 match_opposite: bool,
119 },
120 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
150 ports: Option<PortSpec>,
151 },
152 Port {
153 ports: PortSpec,
154 },
155 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#[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 #[serde(default)]
197 match_opposite: bool,
198 },
199 PortMatchingList {
202 list_id: String,
203 #[serde(default)]
204 match_opposite: bool,
205 },
206 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
274fn 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct CreateNatPolicyRequest {
498 pub name: String,
499 #[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 #[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 #[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 #[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
559use crate::model::FirewallGroupType;
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct CreateFirewallGroupRequest {
565 pub name: String,
566 #[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 #[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 assert!(req.destination_filter.is_none());
615 }
616
617 #[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 #[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 #[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 #[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 #[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 assert!(*match_opposite);
798 }
799
800 #[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 #[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 #[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 #[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 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 #[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 #[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 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}