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/// A safe, fully preflighted integration-policy deletion. Only the guard
10/// presentation and count are public: Fleet snapshots and package metadata can
11/// include configuration values, so they remain private to the API layer.
12#[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/// Private execution facts for one stable-id delete. The raw item detects a
36/// Fleet change before deletion; the normalized policy and metadata prove that
37/// no secret or environment-bound configuration has crossed the guard.
38#[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
54/// Plan safe, stable-id integration-policy deletion without issuing a
55/// mutation. Each selected object is retained exactly so `apply_delete` can
56/// reject a Fleet change before it reaches the single-id delete route.
57pub 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
123/// Delete planning must bind an id selector to the id Fleet returned for that
124/// id route. The general resolver keeps its historical public behavior for
125/// list, get, and export; a mutation cannot accept a mismatched one-object
126/// response as a different target.
127async 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}