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