Skip to main content

elasticctl_api/fleet/integration_policy_ops/
delete_plan.rs

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}