1use super::common::*;
2use super::metadata::{configured_secret_paths, validate_package_metadata_snapshot};
3use super::normalization::normalize;
4use super::read::{ResolvedIntegrationPolicy, collect};
5
6const DELETE_RACE_WARNING: &str =
7 "warning Fleet can change after the final recheck and before the write";
8
9#[derive(Clone, PartialEq)]
13pub struct IntegrationPolicyDeletePlan {
14 pub preview: MutationPlan,
15 pub total: usize,
16 pub(super) host: String,
17 host_snapshot: String,
18 pub(super) space: String,
19 space_snapshot: String,
20 pub(super) parent_snapshots: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
21 parent_snapshots_snapshot: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
22 pub(super) targets: Vec<IntegrationPolicyDeleteTarget>,
23}
24
25impl fmt::Debug for IntegrationPolicyDeletePlan {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 formatter
28 .debug_struct("IntegrationPolicyDeletePlan")
29 .field("target_count", &self.targets.len())
30 .field("total", &self.total)
31 .finish()
32 }
33}
34
35#[derive(Clone, PartialEq)]
39pub(super) struct IntegrationPolicyDeleteTarget {
40 pub(super) id: String,
41 name: String,
42 pub(super) item: Map<String, Value>,
43 item_snapshot: Map<String, Value>,
44 spec: IntegrationPolicySpec,
45 spec_snapshot: IntegrationPolicySpec,
46 pub(super) parents: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
47 pub(super) package: IntegrationPackageSpec,
48 pub(super) dependency: PackageDependencySnapshot,
49 dependency_snapshot: PackageDependencySnapshot,
50 pub(super) metadata: Map<String, Value>,
51 metadata_snapshot: Map<String, Value>,
52}
53
54pub async fn plan_delete(
58 transport: &Transport,
59 selectors: &[String],
60) -> Result<IntegrationPolicyDeletePlan> {
61 if selectors.is_empty() {
62 return Err(Error::new(
63 ErrorKind::Error,
64 "integration-policy delete needs at least one selector",
65 ));
66 }
67 if selectors.iter().any(|selector| selector.trim().is_empty()) {
68 return Err(Error::new(
69 ErrorKind::Error,
70 "integration-policy delete selectors must not be empty",
71 ));
72 }
73
74 let mut resolved = BTreeMap::new();
75 for selector in selectors {
76 let resolved_policy = resolve_delete_item(transport, selector).await?;
77 let id = required_string(
78 &resolved_policy.item,
79 "id",
80 "integration policy delete planning read",
81 )?;
82 if id != resolved_policy.summary.id {
83 return Err(http(
84 "decoding integration policy delete planning read: response id did not match its summary",
85 ));
86 }
87 resolved.entry(id).or_insert(resolved_policy);
88 }
89
90 let mut targets = Vec::with_capacity(resolved.len());
91 let mut issues = Vec::new();
92 for (id, resolved_policy) in resolved {
93 match plan_delete_target(transport, &id, resolved_policy.item).await {
94 Ok(target) => targets.push(target),
95 Err(error) => issues.push(error),
96 }
97 }
98 if !issues.is_empty() {
99 return collapse_delete_planning_issues(issues);
100 }
101
102 let parent_snapshots = shared_delete_parents(&targets).map_err(|_| {
103 Error::new(
104 ErrorKind::Conflict,
105 "agent policy changed while planning integration deletion",
106 )
107 })?;
108 let plan = IntegrationPolicyDeletePlan {
109 preview: delete_preview(&targets),
110 total: targets.len(),
111 host_snapshot: transport.kibana_url().to_owned(),
112 host: transport.kibana_url().to_owned(),
113 space_snapshot: transport.space().to_owned(),
114 space: transport.space().to_owned(),
115 parent_snapshots_snapshot: parent_snapshots.clone(),
116 parent_snapshots,
117 targets,
118 };
119 validate_delete_plan(&plan)?;
120 Ok(plan)
121}
122
123async fn resolve_delete_item(
128 transport: &Transport,
129 selector: &str,
130) -> Result<ResolvedIntegrationPolicy> {
131 match integration_policies::get(transport, selector).await {
132 Ok(policy) => {
133 let summary = summary_from_item(&policy.item)?;
134 if summary.id != selector {
135 return Err(http(
136 "decoding integration policy delete planning read: response id did not match the selector",
137 ));
138 }
139 return Ok(ResolvedIntegrationPolicy {
140 summary,
141 item: policy.item,
142 });
143 }
144 Err(error) if error.kind == ErrorKind::NotFound => {}
145 Err(error) => return Err(delete_remote_error(error, "planning integration read")),
146 }
147 let matches = collect(transport)
148 .await
149 .map_err(|error| delete_remote_error(error, "planning integration list read"))?
150 .iter()
151 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
152 .map(summary_from_item)
153 .collect::<Result<Vec<_>>>()?;
154 match matches.as_slice() {
155 [] => Err(Error::new(
156 ErrorKind::NotFound,
157 format!("no integration policy with id or name '{selector}'"),
158 )),
159 [summary] => {
160 let policy = integration_policies::get(transport, &summary.id)
161 .await
162 .map_err(|error| delete_remote_error(error, "planning name read"))?;
163 let returned_id = required_string(
164 &policy.item,
165 "id",
166 "integration policy delete planning read",
167 )?;
168 if returned_id != summary.id {
169 return Err(http(
170 "decoding integration policy delete planning read: name resolution returned an unexpected id",
171 ));
172 }
173 Ok(ResolvedIntegrationPolicy {
174 summary: summary.clone(),
175 item: policy.item,
176 })
177 }
178 many => Err(Error::new(
179 ErrorKind::Conflict,
180 format!(
181 "integration policy '{selector}' is ambiguous: {}",
182 many.iter()
183 .map(|policy| policy.id.as_str())
184 .collect::<Vec<_>>()
185 .join(", ")
186 ),
187 )),
188 }
189}
190
191async fn plan_delete_target(
192 transport: &Transport,
193 id: &str,
194 item: Map<String, Value>,
195) -> Result<IntegrationPolicyDeleteTarget> {
196 if required_string(&item, "id", "integration policy delete planning read")? != id {
197 return Err(http(
198 "decoding integration policy delete planning read: response id did not match the request",
199 ));
200 }
201 let spec = normalize(&item, transport.space())?;
202 if spec.id != id {
203 return Err(http(
204 "decoding integration policy delete planning read: normalized id did not match the request",
205 ));
206 }
207 let parent_ids = read_parents(id, &item)?;
208 let parents = read_parent_snapshots(transport, id, &parent_ids)
209 .await
210 .map_err(|error| delete_remote_error(error, "planning parent read"))?;
211 validate_delete_parent_safety(id, &spec, &parents)?;
212
213 let package = package_coordinate(&item, "integration policy delete planning read")?;
214 if package != spec.package {
215 return Err(http(
216 "decoding integration policy delete planning read: package did not normalize canonically",
217 ));
218 }
219 let dependency = read_dependencies(transport, &package)
220 .await
221 .map_err(|error| delete_remote_error(error, "planning package read"))?;
222 ensure_delete_dependency(id, &dependency, &package)?;
223 let metadata =
224 integration_policies::package_metadata(transport, &package.name, &package.version)
225 .await
226 .map_err(|error| delete_remote_error(error, "planning package metadata read"))?
227 .item;
228 validate_package_metadata_snapshot(&metadata, &package)?;
229 let secret_paths = configured_secret_paths(&spec, &metadata)?;
230 if !secret_paths.is_empty() {
231 return unsupported(format!(
232 "integration policy '{id}' is not portable: {}",
233 secret_paths
234 .into_iter()
235 .map(|path| format!("{id}:{path}"))
236 .collect::<Vec<_>>()
237 .join(", ")
238 ));
239 }
240
241 Ok(IntegrationPolicyDeleteTarget {
242 id: id.to_owned(),
243 name: spec.name.clone(),
244 item_snapshot: item.clone(),
245 item,
246 spec_snapshot: spec.clone(),
247 spec,
248 parents,
249 package,
250 dependency_snapshot: dependency.clone(),
251 dependency,
252 metadata_snapshot: metadata.clone(),
253 metadata,
254 })
255}
256
257fn collapse_delete_planning_issues(mut issues: Vec<Error>) -> Result<IntegrationPolicyDeletePlan> {
258 if issues.len() == 1 {
259 return Err(issues.remove(0));
260 }
261 if issues.iter().all(|error| error.kind == ErrorKind::Conflict) {
262 return Err(Error::new(
263 ErrorKind::Conflict,
264 issues
265 .into_iter()
266 .map(|error| error.message)
267 .collect::<Vec<_>>()
268 .join("; "),
269 ));
270 }
271 Err(issues.remove(0))
272}
273
274fn validate_delete_parent_safety(
275 id: &str,
276 spec: &IntegrationPolicySpec,
277 parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
278) -> Result<()> {
279 if parents.len() != spec.policy_ids.len()
280 || parents
281 .keys()
282 .map(String::as_str)
283 .ne(spec.policy_ids.iter().map(String::as_str))
284 {
285 return Err(http(format!(
286 "decoding integration policy '{id}': parent snapshots do not match policy_ids"
287 )));
288 }
289 for parent in parents.values() {
290 if parent.platform_owned {
291 return unsupported(format!(
292 "integration policy '{id}' is not portable: parent {} is platform-owned",
293 parent.id
294 ));
295 }
296 if parent.protected {
297 return unsupported(format!(
298 "integration policy '{id}' is not portable: parent {} is_protected",
299 parent.id
300 ));
301 }
302 if parent
303 .attached_integrations
304 .binary_search_by(|attached| attached.as_str().cmp(id))
305 .is_err()
306 {
307 return Err(http(format!(
308 "decoding integration policy '{id}': parent '{}' is missing its attachment",
309 parent.id
310 )));
311 }
312 }
313 let namespaces = parents
314 .values()
315 .map(|parent| parent.namespace.as_str())
316 .collect::<BTreeSet<_>>();
317 match &spec.namespace {
318 Some(namespace)
319 if parents
320 .values()
321 .all(|parent| &parent.namespace == namespace) => {}
322 Some(_) => {
323 return unsupported(format!(
324 "integration policy '{id}' is not portable: namespace does not match every parent"
325 ));
326 }
327 None if namespaces.len() == 1 => {}
328 None => {
329 return unsupported(format!(
330 "integration policy '{id}' is not portable: parents have different namespaces"
331 ));
332 }
333 }
334 Ok(())
335}
336
337fn ensure_delete_dependency(
338 id: &str,
339 dependency: &PackageDependencySnapshot,
340 package: &IntegrationPackageSpec,
341) -> Result<()> {
342 match &dependency.state {
343 PackageDependencyState::Installed { version }
344 if dependency.name == package.name && version == &package.version =>
345 {
346 Ok(())
347 }
348 PackageDependencyState::Installed { .. } => Err(Error::new(
349 ErrorKind::Conflict,
350 format!(
351 "integration policy '{id}' package {} has a different installed version",
352 package.name
353 ),
354 )),
355 PackageDependencyState::NotInstalled => Err(Error::new(
356 ErrorKind::Conflict,
357 format!(
358 "integration policy '{id}' package {} is not installed",
359 package.name
360 ),
361 )),
362 }
363}
364
365fn shared_delete_parents(
366 targets: &[IntegrationPolicyDeleteTarget],
367) -> Result<BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>> {
368 let mut shared = BTreeMap::new();
369 for target in targets {
370 for (id, parent) in &target.parents {
371 match shared.entry(id.clone()) {
372 std::collections::btree_map::Entry::Vacant(entry) => {
373 entry.insert(parent.clone());
374 }
375 std::collections::btree_map::Entry::Occupied(entry) if entry.get() != parent => {
376 return Err(Error::new(
377 ErrorKind::Conflict,
378 format!("agent policy '{id}' changed while planning integration deletion"),
379 ));
380 }
381 std::collections::btree_map::Entry::Occupied(_) => {}
382 }
383 }
384 }
385 Ok(shared)
386}
387
388fn delete_preview(targets: &[IntegrationPolicyDeleteTarget]) -> MutationPlan {
389 let mut affected = BTreeMap::new();
390 let mut preview_details = Vec::with_capacity(targets.len() + 2);
391 for target in targets {
392 let parents = target
393 .parents
394 .values()
395 .map(|parent| {
396 affected.entry(parent.id.clone()).or_insert(parent.agents);
397 format!("{} ({}) agents {}", parent.id, parent.name, parent.agents)
398 })
399 .collect::<Vec<_>>();
400 let agents = target
401 .parents
402 .values()
403 .map(|parent| parent.agents)
404 .sum::<u64>();
405 preview_details.push(format!(
406 "{} {} parents {} agents {agents}",
407 target.id,
408 target.name,
409 parents.join(", ")
410 ));
411 }
412 preview_details.push(format!(
413 "affected agents {}",
414 affected.values().sum::<u64>()
415 ));
416 preview_details.push(DELETE_RACE_WARNING.to_owned());
417 MutationPlan {
418 preview_action: format!("Delete {} integration policy(ies)", targets.len()),
419 preview_details,
420 targets: targets.iter().map(|target| target.id.clone()).collect(),
421 }
422}
423
424pub(super) fn delete_remote_error(error: Error, context: &str) -> Error {
425 let message = format!("integration-policy delete {context} failed");
426 match error.http_status {
427 Some(status) => Error::with_status(error.kind, status, message),
428 None => Error::new(error.kind, message),
429 }
430}
431
432pub(super) fn validate_delete_plan(plan: &IntegrationPolicyDeletePlan) -> Result<()> {
433 let invalid = || Error::new(ErrorKind::Error, "invalid integration-policy delete plan");
434 if plan.targets.is_empty()
435 || plan.total != plan.targets.len()
436 || plan.host.trim().is_empty()
437 || plan.host != plan.host_snapshot
438 || plan.space != plan.space_snapshot
439 {
440 return Err(invalid());
441 }
442
443 let mut previous: Option<&str> = None;
444 let mut shared = BTreeMap::new();
445 for target in &plan.targets {
446 if target.id.trim().is_empty()
447 || target.name.trim().is_empty()
448 || previous.is_some_and(|previous| previous >= target.id.as_str())
449 || target.item != target.item_snapshot
450 || target.spec.validate().is_err()
451 || target.spec != target.spec_snapshot
452 || target.id != target.spec.id
453 || target.name != target.spec.name
454 || required_string(&target.item, "id", "integration policy delete plan")
455 .ok()
456 .as_deref()
457 != Some(target.id.as_str())
458 || normalize(&target.item, &plan.space).ok().as_ref() != Some(&target.spec)
459 || package_coordinate(&target.item, "integration policy delete plan")
460 .ok()
461 .as_ref()
462 != Some(&target.package)
463 || target.package != target.spec.package
464 || target.dependency != target.dependency_snapshot
465 || !valid_package_state(&target.dependency)
466 || target.dependency.name != target.package.name
467 || !is_exact_installed(&target.dependency, &target.package)
468 || target.metadata != target.metadata_snapshot
469 || validate_package_metadata_snapshot(&target.metadata, &target.package).is_err()
470 || !matches!(configured_secret_paths(&target.spec, &target.metadata), Ok(paths) if paths.is_empty())
471 {
472 return Err(invalid());
473 }
474
475 let parent_ids = match read_parents(&target.id, &target.item) {
476 Ok(ids) => ids,
477 Err(_) => return Err(invalid()),
478 };
479 if parent_ids.iter().collect::<BTreeSet<_>>()
480 != target.parents.keys().collect::<BTreeSet<_>>()
481 || validate_delete_parent_safety(&target.id, &target.spec, &target.parents).is_err()
482 {
483 return Err(invalid());
484 }
485 for (parent_id, parent) in &target.parents {
486 if !valid_parent_snapshot(parent_id, parent)
487 || parent.platform_owned
488 || parent.protected
489 || parent
490 .attached_integrations
491 .binary_search_by(|attached| attached.as_str().cmp(&target.id))
492 .is_err()
493 {
494 return Err(invalid());
495 }
496 match shared.entry(parent_id.as_str()) {
497 std::collections::btree_map::Entry::Vacant(entry) => {
498 entry.insert(parent);
499 }
500 std::collections::btree_map::Entry::Occupied(entry) if *entry.get() != parent => {
501 return Err(invalid());
502 }
503 std::collections::btree_map::Entry::Occupied(_) => {}
504 }
505 }
506 previous = Some(&target.id);
507 }
508 if plan.parent_snapshots != plan.parent_snapshots_snapshot
509 || shared_delete_parents(&plan.targets).ok().as_ref() != Some(&plan.parent_snapshots)
510 {
511 return Err(invalid());
512 }
513 if plan.preview != delete_preview(&plan.targets) {
514 return Err(invalid());
515 }
516 Ok(())
517}
518
519#[cfg(test)]
520mod delete_plan_tests {
521 use super::super::delete_apply::apply_delete;
522 use super::*;
523 use elasticctl_core::{Profile, Transport};
524 use wiremock::matchers::{method, path, query_param};
525 use wiremock::{Mock, MockServer, ResponseTemplate};
526
527 async fn verified_server() -> MockServer {
528 let server = MockServer::start().await;
529 Mock::given(method("GET"))
530 .and(path("/api/status"))
531 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
532 "version": {"number": "9.5.1", "build_flavor": "traditional"}
533 })))
534 .mount(&server)
535 .await;
536 server
537 }
538
539 fn transport_for(server: &MockServer) -> Transport {
540 Transport::new(&Profile {
541 kibana_url: server.uri(),
542 es_url: None,
543 api_key: Some("essu_test".into()),
544 username: None,
545 password: None,
546 space: "default".into(),
547 verify: true,
548 timeout_secs: 5,
549 })
550 .expect("transport")
551 }
552
553 fn valid_plan() -> IntegrationPolicyDeletePlan {
554 let spec = IntegrationPolicySpec::try_from(json!({
555 "id": "delete-1",
556 "name": "Delete integration",
557 "namespace": "default",
558 "policy_ids": ["parent-1"],
559 "package": {"name": "system", "version": "2.0.0"},
560 "inputs": {}
561 }))
562 .expect("valid integration policy");
563 let mut item = serde_json::to_value(&spec)
564 .expect("serialize integration policy")
565 .as_object()
566 .expect("integration policy is an object")
567 .clone();
568 item.insert("enabled".into(), Value::Bool(true));
569 let parent = agent_policy_ops::AgentPolicyParentSnapshot {
570 id: "parent-1".into(),
571 name: "Parent 1".into(),
572 namespace: "default".into(),
573 agents: 4,
574 attached_integrations: vec!["delete-1".into()],
575 platform_owned: false,
576 protected: false,
577 };
578 let parents = BTreeMap::from([(parent.id.clone(), parent)]);
579 let dependency = PackageDependencySnapshot {
580 name: "system".into(),
581 state: PackageDependencyState::Installed {
582 version: "2.0.0".into(),
583 },
584 };
585 let metadata = json!({
586 "name": "system",
587 "version": "2.0.0",
588 "vars": [],
589 "policy_templates": []
590 })
591 .as_object()
592 .expect("metadata object")
593 .clone();
594 let target = IntegrationPolicyDeleteTarget {
595 id: spec.id.clone(),
596 name: spec.name.clone(),
597 item_snapshot: item.clone(),
598 item,
599 spec_snapshot: spec.clone(),
600 spec,
601 parents: parents.clone(),
602 package: IntegrationPackageSpec {
603 name: "system".into(),
604 version: "2.0.0".into(),
605 },
606 dependency_snapshot: dependency.clone(),
607 dependency,
608 metadata_snapshot: metadata.clone(),
609 metadata,
610 };
611 let targets = vec![target];
612 let parent_snapshots = parents;
613 IntegrationPolicyDeletePlan {
614 preview: delete_preview(&targets),
615 total: targets.len(),
616 host: "https://fleet.example.invalid".into(),
617 host_snapshot: "https://fleet.example.invalid".into(),
618 space: "default".into(),
619 space_snapshot: "default".into(),
620 parent_snapshots_snapshot: parent_snapshots.clone(),
621 parent_snapshots,
622 targets,
623 }
624 }
625
626 #[test]
627 fn delete_plan_accepts_a_coherent_private_snapshot() {
628 assert!(validate_delete_plan(&valid_plan()).is_ok());
629 }
630
631 #[test]
632 fn delete_plan_rejects_empty_total_order_and_preview_tampering() {
633 let plan = valid_plan();
634
635 let mut empty = plan.clone();
636 empty.targets.clear();
637 empty.total = 0;
638 empty.parent_snapshots.clear();
639 empty.parent_snapshots_snapshot.clear();
640 empty.preview = delete_preview(&empty.targets);
641 assert!(validate_delete_plan(&empty).is_err());
642
643 let mut total = plan.clone();
644 total.total = 2;
645 assert!(validate_delete_plan(&total).is_err());
646
647 let mut duplicate = plan.clone();
648 duplicate.targets.push(duplicate.targets[0].clone());
649 duplicate.total = 2;
650 duplicate.preview = delete_preview(&duplicate.targets);
651 assert!(validate_delete_plan(&duplicate).is_err());
652
653 let mut preview = plan;
654 preview.preview.preview_action = "tampered".into();
655 assert!(validate_delete_plan(&preview).is_err());
656
657 let mut host = valid_plan();
658 host.host = "https://other.example.invalid".into();
659 assert!(validate_delete_plan(&host).is_err());
660
661 let mut space = valid_plan();
662 space.space = "other".into();
663 assert!(validate_delete_plan(&space).is_err());
664 }
665
666 #[test]
667 fn delete_plan_rejects_raw_spec_parent_package_and_metadata_tampering() {
668 let plan = valid_plan();
669
670 let mut raw_and_spec = plan.clone();
671 raw_and_spec.targets[0]
672 .item
673 .insert("description".into(), json!("tampered"));
674 raw_and_spec.targets[0].spec.description = Some("tampered".into());
675 raw_and_spec.preview = delete_preview(&raw_and_spec.targets);
676 assert!(validate_delete_plan(&raw_and_spec).is_err());
677
678 let mut parent = plan.clone();
679 parent.targets[0]
680 .parents
681 .get_mut("parent-1")
682 .expect("parent")
683 .agents = 99;
684 parent.preview = delete_preview(&parent.targets);
685 assert!(validate_delete_plan(&parent).is_err());
686
687 let mut parent_snapshot = plan.clone();
688 parent_snapshot
689 .parent_snapshots
690 .get_mut("parent-1")
691 .expect("parent")
692 .agents = 99;
693 assert!(validate_delete_plan(&parent_snapshot).is_err());
694
695 let mut dependency = plan.clone();
696 dependency.targets[0].dependency = PackageDependencySnapshot {
697 name: "system".into(),
698 state: PackageDependencyState::Installed {
699 version: "1.0.0".into(),
700 },
701 };
702 assert!(validate_delete_plan(&dependency).is_err());
703
704 let mut metadata = plan;
705 metadata.targets[0]
706 .metadata
707 .insert("version".into(), json!("9.9.9"));
708 assert!(validate_delete_plan(&metadata).is_err());
709 }
710
711 #[tokio::test]
712 async fn delete_apply_rereads_metadata_after_coherent_secret_tampering() {
713 let server = verified_server().await;
714 let transport = transport_for(&server);
715 let spec = IntegrationPolicySpec::try_from(json!({
716 "id": "delete-1",
717 "name": "Delete integration",
718 "namespace": "default",
719 "policy_ids": ["parent-1"],
720 "package": {"name": "system", "version": "2.0.0"},
721 "vars": {"package_secret": "live-plaintext-value-must-not-leak"},
722 "inputs": {}
723 }))
724 .expect("valid integration policy");
725 let mut item = serde_json::to_value(&spec)
726 .expect("serialize integration policy")
727 .as_object()
728 .expect("integration policy is an object")
729 .clone();
730 item.insert("enabled".into(), Value::Bool(true));
731 let parent = agent_policy_ops::AgentPolicyParentSnapshot {
732 id: "parent-1".into(),
733 name: "Parent 1".into(),
734 namespace: "default".into(),
735 agents: 4,
736 attached_integrations: vec![spec.id.clone()],
737 platform_owned: false,
738 protected: false,
739 };
740 let parents = BTreeMap::from([(parent.id.clone(), parent)]);
741 let dependency = PackageDependencySnapshot {
742 name: "system".into(),
743 state: PackageDependencyState::Installed {
744 version: "2.0.0".into(),
745 },
746 };
747 let original_metadata = json!({
748 "name": "system",
749 "version": "2.0.0",
750 "vars": [{"name": "package_secret", "secret": true}],
751 "policy_templates": []
752 })
753 .as_object()
754 .expect("metadata object")
755 .clone();
756 let mut target = IntegrationPolicyDeleteTarget {
757 id: spec.id.clone(),
758 name: spec.name.clone(),
759 item_snapshot: item.clone(),
760 item,
761 spec_snapshot: spec.clone(),
762 spec,
763 parents: parents.clone(),
764 package: IntegrationPackageSpec {
765 name: "system".into(),
766 version: "2.0.0".into(),
767 },
768 dependency_snapshot: dependency.clone(),
769 dependency,
770 metadata_snapshot: original_metadata.clone(),
771 metadata: original_metadata.clone(),
772 };
773 let mut plan = IntegrationPolicyDeletePlan {
774 preview: delete_preview(std::slice::from_ref(&target)),
775 total: 1,
776 host: server.uri(),
777 host_snapshot: server.uri(),
778 space: "default".into(),
779 space_snapshot: "default".into(),
780 parent_snapshots_snapshot: parents.clone(),
781 parent_snapshots: parents,
782 targets: vec![target.clone()],
783 };
784 assert!(validate_delete_plan(&plan).is_err());
785
786 let forged_metadata = json!({
787 "name": "system",
788 "version": "2.0.0",
789 "vars": [{"name": "package_secret", "secret": false}],
790 "policy_templates": []
791 })
792 .as_object()
793 .expect("metadata object")
794 .clone();
795 target.metadata = forged_metadata.clone();
796 target.metadata_snapshot = forged_metadata;
797 target.item_snapshot = target.item.clone();
798 target.spec_snapshot = target.spec.clone();
799 plan.targets = vec![target];
800 plan.parent_snapshots = shared_delete_parents(&plan.targets).expect("shared parents");
801 plan.parent_snapshots_snapshot = plan.parent_snapshots.clone();
802 plan.preview = delete_preview(&plan.targets);
803 assert!(validate_delete_plan(&plan).is_ok());
804
805 let item = plan.targets[0].item.clone();
806 Mock::given(method("GET"))
807 .and(path("/api/fleet/package_policies/delete-1"))
808 .and(query_param("format", "simplified"))
809 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"item": item})))
810 .expect(1)
811 .mount(&server)
812 .await;
813 Mock::given(method("GET"))
814 .and(path("/api/fleet/agent_policies/parent-1"))
815 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
816 "item": parent_item_for_delete_test("parent-1", "delete-1")
817 })))
818 .expect(1)
819 .mount(&server)
820 .await;
821 Mock::given(method("GET"))
822 .and(path("/api/fleet/epm/packages/system"))
823 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
824 "item": {
825 "name": "system",
826 "status": "installed",
827 "installationInfo": {"version": "2.0.0"}
828 }
829 })))
830 .expect(1)
831 .mount(&server)
832 .await;
833 Mock::given(method("GET"))
834 .and(path("/api/fleet/epm/packages/system/2.0.0"))
835 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
836 "item": original_metadata
837 })))
838 .expect(1)
839 .mount(&server)
840 .await;
841 Mock::given(method("DELETE"))
842 .and(path("/api/fleet/package_policies/delete-1"))
843 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "delete-1"})))
844 .expect(0)
845 .mount(&server)
846 .await;
847
848 let report = apply_delete(&transport, &plan)
849 .await
850 .expect("metadata race is a row failure");
851 assert!(report.deleted.is_empty());
852 assert_eq!(
853 report.failed,
854 vec![json!({
855 "id": "delete-1",
856 "applied": false,
857 "error": "integration policy package metadata changed since preview"
858 })]
859 );
860 let requests = server.received_requests().await.expect("recorded requests");
861 assert_eq!(
862 requests
863 .iter()
864 .filter(|request| request.url.path() == "/api/fleet/epm/packages/system/2.0.0")
865 .count(),
866 1
867 );
868 assert!(requests.iter().all(|request| request.method != "DELETE"));
869 assert!(
870 !report.failed[0]["error"]
871 .as_str()
872 .expect("error string")
873 .contains("live-plaintext-value-must-not-leak")
874 );
875 }
876
877 fn parent_item_for_delete_test(id: &str, attached: &str) -> Value {
878 json!({
879 "id": id,
880 "name": "Parent 1",
881 "namespace": "default",
882 "agents": 4,
883 "package_policies": [attached],
884 })
885 }
886}