Skip to main content

canic_host/state_manifest/
mod.rs

1//! Module: state_manifest
2//!
3//! Responsibility: build host-side state manifest and audit reports from
4//! Rust-authored Canic state declarations.
5//! Does not own: stable-memory inspection, migration execution, CLI parsing, or
6//! runtime introspection.
7//! Boundary: consumes passive declaration metadata from `canic-core` and emits
8//! diagnostic-only reports.
9
10mod aggregation;
11mod audit;
12mod resolution;
13
14pub use resolution::{StateManifestResolution, resolve_project_state_manifest};
15
16use canic_core::state_contract::{STATE_MANIFEST_SCHEMA_VERSION, StateManifest};
17use serde::Serialize;
18
19pub const STATE_AUDIT_COMMAND: &str = "canic state audit";
20pub const STATE_MANIFEST_COMMAND: &str = "canic state manifest";
21pub const STATE_AUDIT_SCHEMA_VERSION: u16 = 1;
22
23const SCOPE_PROJECT: StateAuditScope = StateAuditScope::Project;
24const SCOPE_ROLE: StateAuditScope = StateAuditScope::Role;
25
26///
27/// StateAuditReport
28///
29/// Diagnostic report for declared state domains.
30///
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
33pub struct StateAuditReport {
34    pub schema_version: u16,
35    pub command: &'static str,
36    pub scope: StateAuditScope,
37    pub role: Option<String>,
38    pub status: StateAuditStatus,
39    pub manifest: StateManifest,
40    pub checks: Vec<StateAuditCheck>,
41    pub next_actions: Vec<String>,
42}
43
44///
45/// StateAuditCheck
46///
47/// Stable check row emitted by `canic state audit`.
48///
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
51pub struct StateAuditCheck {
52    pub category: StateAuditCategory,
53    pub code: &'static str,
54    pub status: StateAuditStatus,
55    pub severity: StateAuditSeverity,
56    pub subject: String,
57    pub detail: String,
58    pub next: Option<String>,
59    pub source: StateAuditSource,
60}
61
62///
63/// StateAuditScope
64///
65/// Stable audit scope emitted by `canic state audit`.
66///
67
68#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum StateAuditScope {
71    Project,
72    Role,
73}
74
75impl StateAuditScope {
76    #[must_use]
77    pub const fn label(self) -> &'static str {
78        match self {
79            Self::Project => "project",
80            Self::Role => "role",
81        }
82    }
83}
84
85///
86/// StateAuditCategory
87///
88/// Stable category label emitted by state-audit checks.
89///
90
91#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum StateAuditCategory {
94    Invariant,
95    Lifecycle,
96    Manifest,
97    MemoryId,
98    Migration,
99    Naming,
100    SchemaVersion,
101    Snapshot,
102    TestCoverage,
103}
104
105impl StateAuditCategory {
106    #[must_use]
107    pub const fn label(self) -> &'static str {
108        match self {
109            Self::Invariant => "invariant",
110            Self::Lifecycle => "lifecycle",
111            Self::Manifest => "manifest",
112            Self::MemoryId => "memory_id",
113            Self::Migration => "migration",
114            Self::Naming => "naming",
115            Self::SchemaVersion => "schema_version",
116            Self::Snapshot => "snapshot",
117            Self::TestCoverage => "test_coverage",
118        }
119    }
120}
121
122///
123/// StateAuditSource
124///
125/// Stable source-attribution label emitted by state-audit checks.
126///
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
129#[serde(rename_all = "snake_case")]
130pub enum StateAuditSource {
131    StateManifest,
132}
133
134impl StateAuditSource {
135    #[must_use]
136    pub const fn label(self) -> &'static str {
137        match self {
138            Self::StateManifest => "state_manifest",
139        }
140    }
141}
142
143///
144/// StateAuditStatus
145///
146/// Stable audit status for reports and checks.
147///
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum StateAuditStatus {
152    Pass,
153    Warn,
154    Fail,
155    NotEvaluated,
156}
157
158impl StateAuditStatus {
159    #[must_use]
160    pub const fn label(self) -> &'static str {
161        match self {
162            Self::Pass => "pass",
163            Self::Warn => "warn",
164            Self::Fail => "fail",
165            Self::NotEvaluated => "not_evaluated",
166        }
167    }
168}
169
170///
171/// StateAuditSeverity
172///
173/// Stable severity framing for audit checks.
174///
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
177#[serde(rename_all = "snake_case")]
178pub enum StateAuditSeverity {
179    Info,
180    Warning,
181    Blocked,
182    Unsupported,
183}
184
185#[must_use]
186pub fn build_state_audit_report(
187    resolution: &StateManifestResolution,
188    role: Option<&str>,
189) -> StateAuditReport {
190    let (manifest, contract_errors) = match resolution {
191        StateManifestResolution::Resolved { manifest, .. } => (manifest.clone(), Vec::new()),
192        StateManifestResolution::Rejected { errors } => (
193            StateManifest {
194                schema_version: STATE_MANIFEST_SCHEMA_VERSION,
195                roles: Vec::new(),
196            },
197            errors.clone(),
198        ),
199    };
200    let mut checks = audit::audit_checks(
201        &manifest,
202        if contract_errors.is_empty() {
203            role
204        } else {
205            None
206        },
207    );
208    checks.extend(contract_errors.iter().map(audit::role_contract_check));
209    aggregation::sort_checks(&mut checks);
210
211    let status = aggregation::aggregate_status(&checks);
212    let mut next_actions = aggregation::next_actions(status, role);
213    next_actions.sort();
214    next_actions.dedup();
215
216    StateAuditReport {
217        schema_version: STATE_AUDIT_SCHEMA_VERSION,
218        command: STATE_AUDIT_COMMAND,
219        scope: if role.is_some() {
220            SCOPE_ROLE
221        } else {
222            SCOPE_PROJECT
223        },
224        role: role.map(ToString::to_string),
225        status,
226        manifest,
227        checks,
228        next_actions,
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::role_contract::materialize_state_manifest;
236    use canic_core::{
237        ids::CanisterRole,
238        role_contract::{
239            AllocationOwner, BuiltInRoleKind, CanicFeatureKey, ResolvedRoleContract,
240            ResolvedStateAllocation, RoleContractFinding, SelectionProvenance, StateAllocationKey,
241            allocation::allocation_definition,
242        },
243        state_contract::{
244            MigrationPolicy, ReservedMemoryManifest, StateDomainManifest, StateMigrationManifest,
245            StateRoleManifest, StateStorage,
246        },
247    };
248    use std::{collections::BTreeSet, path::PathBuf};
249
250    use super::audit::audit_checks;
251
252    fn test_state_manifest(role: Option<&str>) -> StateManifest {
253        let contracts = match role {
254            Some("root") | None => vec![test_contract(
255                "root",
256                None,
257                &[
258                    StateAllocationKey::CoreRuntimeTopology,
259                    StateAllocationKey::CoreRuntimeEnvironment,
260                    StateAllocationKey::CoreFleetActivation,
261                    StateAllocationKey::CoreAuthState,
262                    StateAllocationKey::CoreReplayReceipts,
263                    StateAllocationKey::CoreRuntimeObservability,
264                    StateAllocationKey::CoreRuntimeIntent,
265                    StateAllocationKey::CanisterPool,
266                    StateAllocationKey::TemplateManifests,
267                    StateAllocationKey::TemplateChunkSets,
268                    StateAllocationKey::TemplateChunkRefs,
269                    StateAllocationKey::TemplateChunkPayloads,
270                    StateAllocationKey::ControlPlaneSubnetState,
271                ],
272            )],
273            Some("wasm_store") => vec![test_contract(
274                "wasm_store",
275                Some(BuiltInRoleKind::WasmStore),
276                &[
277                    StateAllocationKey::TemplateManifests,
278                    StateAllocationKey::TemplateChunkSets,
279                    StateAllocationKey::TemplateChunkRefs,
280                    StateAllocationKey::TemplateChunkPayloads,
281                    StateAllocationKey::WasmStoreGcState,
282                ],
283            )],
284            Some("fleet_coordinator") => vec![test_contract(
285                "fleet_coordinator",
286                Some(BuiltInRoleKind::FleetCoordinator),
287                &[StateAllocationKey::FleetCoordinatorRegistry],
288            )],
289            Some(_) => Vec::new(),
290        };
291        materialize_state_manifest(&contracts).expect("test manifest")
292    }
293
294    fn test_contract(
295        role: &str,
296        built_in: Option<BuiltInRoleKind>,
297        keys: &[StateAllocationKey],
298    ) -> ResolvedRoleContract {
299        let allocations = keys
300            .iter()
301            .map(|key| {
302                let definition = allocation_definition(*key).expect("allocation definition");
303                ResolvedStateAllocation {
304                    key: *key,
305                    owner: definition.owner,
306                    memory_ids: definition.memory_ids.to_vec(),
307                    selected_by: BTreeSet::from([if let Some(built_in) = built_in {
308                        SelectionProvenance::BuiltInRole(built_in)
309                    } else if definition.owner == AllocationOwner::CanicControlPlane {
310                        SelectionProvenance::EffectiveFeature(CanicFeatureKey::ControlPlane)
311                    } else {
312                        SelectionProvenance::Capability(
313                            canic_core::role_contract::RoleCapabilityKey::Root,
314                        )
315                    }]),
316                }
317            })
318            .collect();
319        ResolvedRoleContract {
320            role: CanisterRole::owned(role.to_string()),
321            built_in,
322            capabilities: BTreeSet::new(),
323            required_features: BTreeSet::new(),
324            effective_features: BTreeSet::new(),
325            allocations,
326        }
327    }
328
329    fn build_state_audit_report(role: Option<&str>) -> StateAuditReport {
330        let resolution = StateManifestResolution::Resolved {
331            manifest: test_state_manifest(role),
332            contracts: Vec::new(),
333        };
334        super::build_state_audit_report(&resolution, role)
335    }
336
337    #[test]
338    fn state_audit_status_owns_serialized_labels() {
339        assert_eq!(StateAuditStatus::Pass.label(), "pass");
340        assert_eq!(StateAuditStatus::Warn.label(), "warn");
341        assert_eq!(StateAuditStatus::Fail.label(), "fail");
342        assert_eq!(StateAuditStatus::NotEvaluated.label(), "not_evaluated");
343    }
344
345    #[test]
346    fn builtin_report_passes_when_every_active_memory_id_is_modeled() {
347        let report = build_state_audit_report(Some("root"));
348
349        assert_eq!(report.status, StateAuditStatus::Pass);
350        assert!(report.checks.iter().any(|check| {
351            check.code == "state_manifest_schema_version_supported"
352                && check.status == StateAuditStatus::Pass
353        }));
354        assert!(
355            report
356                .checks
357                .iter()
358                .all(|check| check.code != "reserved_memory_id_declared")
359        );
360        assert!(
361            report
362                .checks
363                .iter()
364                .all(|check| check.code != "snapshot_name_invalid")
365        );
366        assert!(
367            report
368                .checks
369                .iter()
370                .any(|check| check.code == "reserved_export_import_ok")
371        );
372        assert!(
373            report
374                .checks
375                .iter()
376                .all(|check| check.status != StateAuditStatus::Fail)
377        );
378    }
379
380    #[test]
381    fn builtin_manifest_merges_control_plane_state_by_role() {
382        let manifest = test_state_manifest(Some("root"));
383        let role = manifest.roles.first().expect("root role");
384
385        assert!(role.state.iter().any(|domain| {
386            domain.domain == "template_manifests"
387                && domain.owner == "canic-control-plane"
388                && domain.memory_id == Some(10)
389        }));
390        assert!(
391            role.state
392                .iter()
393                .any(|domain| { domain.domain == "auth_state" && domain.owner == "canic-core" })
394        );
395    }
396
397    #[test]
398    fn wasm_store_role_audits_every_owned_memory_domain_cleanly() {
399        let report = build_state_audit_report(Some("wasm_store"));
400
401        assert_eq!(report.status, StateAuditStatus::Pass);
402        let role = report
403            .manifest
404            .roles
405            .iter()
406            .find(|role| role.canister_role == "wasm_store")
407            .expect("wasm_store role");
408        let domains = role
409            .state
410            .iter()
411            .map(|domain| domain.domain.as_str())
412            .collect::<Vec<_>>();
413
414        for expected in [
415            "template_manifests",
416            "template_chunk_sets",
417            "template_chunk_refs",
418            "template_chunk_payloads",
419            "wasm_store_gc_state",
420        ] {
421            assert!(domains.contains(&expected));
422        }
423        assert_eq!(domains.len(), 5);
424    }
425
426    #[test]
427    fn fleet_coordinator_role_audits_its_registry_state_cleanly() {
428        let report = build_state_audit_report(Some("fleet_coordinator"));
429
430        assert_eq!(report.status, StateAuditStatus::Pass);
431        let role = report
432            .manifest
433            .roles
434            .iter()
435            .find(|role| role.canister_role == "fleet_coordinator")
436            .expect("Fleet Coordinator role");
437        assert_eq!(role.state.len(), 1);
438        assert_eq!(role.state[0].domain, "fleet_coordinator_registry");
439        assert_eq!(role.state[0].memory_id, Some(16));
440    }
441
442    #[test]
443    fn complete_descriptor_registry_satisfies_state_audit_metadata_contract() {
444        let keys = canic_core::role_contract::allocation::allocation_definitions()
445            .iter()
446            .map(|definition| definition.key)
447            .collect::<Vec<_>>();
448        let manifest = materialize_state_manifest(&[test_contract("catalog", None, &keys)])
449            .expect("complete descriptor manifest");
450        let checks = audit_checks(&manifest, Some("catalog"));
451
452        assert!(
453            checks
454                .iter()
455                .all(|check| check.status != StateAuditStatus::Fail),
456            "complete descriptor registry must satisfy audit metadata: {checks:#?}"
457        );
458    }
459
460    #[test]
461    fn exact_blob_role_resolution_materializes_blob_allocations() {
462        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
463        let config = workspace.join("canisters/test/blob_storage_probe/canic.toml");
464        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("test"));
465        let StateManifestResolution::Resolved {
466            manifest,
467            contracts,
468        } = resolution
469        else {
470            panic!("blob role contract should resolve")
471        };
472
473        assert_eq!(contracts.len(), 1);
474        let role = manifest.roles.first().expect("blob role manifest");
475        assert_eq!(role.canister_role, "test");
476        assert_eq!(
477            role.state
478                .iter()
479                .filter_map(|domain| domain.memory_id)
480                .filter(|memory_id| (59..=62).contains(memory_id))
481                .collect::<Vec<_>>(),
482            vec![60, 62, 61, 59]
483        );
484        assert!(role.state.iter().all(|domain| domain.owner == "canic-core"));
485    }
486
487    #[test]
488    fn placement_roles_materialize_exact_placement_state() {
489        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
490
491        for (config_path, role, expected_ids) in [
492            ("apps/test/canic.toml", "user_hub", vec![53, 56, 57, 58]),
493            (
494                "canisters/audit/scaling_probe/canic.toml",
495                "scale_hub",
496                vec![53, 54],
497            ),
498            (
499                "canisters/test/project_hub_stub/canic.toml",
500                "project_hub",
501                vec![53, 55],
502            ),
503        ] {
504            let config = workspace.join(config_path);
505            let resolution = resolve_project_state_manifest(&workspace, &[config], Some(role));
506            let StateManifestResolution::Resolved { manifest, .. } = resolution else {
507                panic!("{role} role contract should resolve");
508            };
509            let mut actual_ids = manifest.roles[0]
510                .state
511                .iter()
512                .filter_map(|domain| domain.memory_id)
513                .filter(|memory_id| (53..=58).contains(memory_id))
514                .collect::<Vec<_>>();
515            actual_ids.sort_unstable();
516
517            assert_eq!(actual_ids, expected_ids, "unexpected state for {role}");
518        }
519    }
520
521    #[test]
522    fn exact_built_in_resolution_materializes_runtime_template_and_gc_allocations() {
523        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
524        let resolution = resolve_project_state_manifest(&workspace, &[], Some("wasm_store"));
525        let StateManifestResolution::Resolved {
526            manifest,
527            contracts,
528        } = resolution
529        else {
530            panic!("built-in wasm_store contract should resolve")
531        };
532
533        assert_eq!(contracts.len(), 1);
534        let mut ids = manifest.roles[0]
535            .state
536            .iter()
537            .filter_map(|domain| domain.memory_id)
538            .collect::<Vec<_>>();
539        ids.sort_unstable();
540        assert_eq!(
541            ids,
542            vec![
543                10, 11, 12, 13, 15, 30, 31, 32, 33, 34, 35, 37, 38, 39, 40, 41, 42, 44, 45, 46, 47,
544                48, 49, 50, 51, 52,
545            ]
546        );
547        assert_eq!(
548            manifest.roles[0]
549                .reserved_memory
550                .iter()
551                .map(|entry| entry.memory_id)
552                .collect::<Vec<_>>(),
553            Vec::<u8>::new()
554        );
555    }
556
557    #[test]
558    fn exact_fleet_coordinator_resolution_materializes_only_registry_state() {
559        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
560        let resolution = resolve_project_state_manifest(&workspace, &[], Some("fleet_coordinator"));
561        let StateManifestResolution::Resolved {
562            manifest,
563            contracts,
564        } = resolution
565        else {
566            panic!("built-in Fleet Coordinator contract should resolve")
567        };
568
569        assert_eq!(contracts.len(), 1);
570        assert_eq!(manifest.roles.len(), 1);
571        assert_eq!(
572            manifest.roles[0]
573                .state
574                .iter()
575                .filter_map(|domain| domain.memory_id)
576                .collect::<Vec<_>>(),
577            vec![16]
578        );
579    }
580
581    #[test]
582    fn unknown_role_resolution_returns_no_manifest() {
583        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
584        let config = workspace.join("canisters/audit/root_probe/canic.toml");
585        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("missing"));
586
587        assert!(matches!(
588            resolution,
589            StateManifestResolution::Rejected { errors }
590                if errors.iter().any(|finding| matches!(finding, RoleContractFinding::RoleUnknown { .. }))
591        ));
592    }
593
594    #[test]
595    fn unsupported_manifest_schema_version_fails() {
596        let mut manifest = test_state_manifest(Some("root"));
597        manifest.schema_version = STATE_MANIFEST_SCHEMA_VERSION + 1;
598
599        let checks = audit_checks(&manifest, Some("root"));
600
601        assert!(checks.iter().any(|check| {
602            check.code == "state_manifest_schema_version_unsupported"
603                && check.status == StateAuditStatus::Fail
604        }));
605    }
606
607    #[test]
608    fn duplicate_state_role_fails() {
609        let mut manifest = test_state_manifest(Some("root"));
610        let duplicate = manifest.roles[0].clone();
611        manifest.roles.push(duplicate);
612
613        let checks = audit_checks(&manifest, None);
614
615        assert!(checks.iter().any(|check| {
616            check.code == "state_role_duplicate" && check.status == StateAuditStatus::Fail
617        }));
618    }
619
620    #[test]
621    fn duplicate_memory_id_fails_within_role() {
622        let mut manifest = test_state_manifest(Some("root"));
623        let role = manifest.roles.first_mut().expect("root role");
624        role.state[1].memory_id = role.state[0].memory_id;
625
626        let checks = audit_checks(&manifest, Some("root"));
627        assert!(
628            checks
629                .iter()
630                .any(|check| check.code == "memory_id_duplicate"
631                    && check.status == StateAuditStatus::Fail)
632        );
633    }
634
635    #[test]
636    fn duplicate_state_domain_fails_within_role() {
637        let mut manifest = test_state_manifest(Some("root"));
638        let role = manifest.roles.first_mut().expect("root role");
639        let mut duplicate = role.state[0].clone();
640        duplicate.memory_id = Some(250);
641        role.state.push(duplicate);
642
643        let checks = audit_checks(&manifest, Some("root"));
644
645        assert!(
646            checks
647                .iter()
648                .any(|check| check.code == "state_domain_duplicate"
649                    && check.status == StateAuditStatus::Fail)
650        );
651    }
652
653    #[test]
654    fn active_cycle_tracker_memory_is_not_reported_as_reserved() {
655        let report = build_state_audit_report(Some("root"));
656
657        assert!(
658            report
659                .checks
660                .iter()
661                .all(|check| check.code != "reserved_memory_id_declared")
662        );
663    }
664
665    #[test]
666    fn active_domain_reclaiming_reserved_memory_id_fails() {
667        let mut manifest = test_state_manifest(Some("root"));
668        let role = manifest.roles.first_mut().expect("root role");
669        let reserved_id = 250;
670        role.reserved_memory.push(ReservedMemoryManifest {
671            label: "future_state".to_string(),
672            memory_id: reserved_id,
673            owner: "canic-core".to_string(),
674            reason: "synthetic collision fixture".to_string(),
675        });
676        role.state[0].memory_id = Some(reserved_id);
677
678        let checks = audit_checks(&manifest, Some("root"));
679
680        assert!(
681            checks
682                .iter()
683                .any(|check| check.code == "reserved_memory_id_collision"
684                    && check.status == StateAuditStatus::Fail)
685        );
686    }
687
688    #[test]
689    fn storage_not_applicable_is_explicit_metadata() {
690        let manifest = StateManifest {
691            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
692            roles: vec![StateRoleManifest {
693                canister_role: "root".to_string(),
694                state: vec![StateDomainManifest {
695                    domain: "external_authority".to_string(),
696                    version: 1,
697                    storage: StateStorage::NotApplicable,
698                    memory_id: None,
699                    owner: "canic-core".to_string(),
700                    record: "ExternalAuthorityRecord".to_string(),
701                    snapshot: "ExternalAuthorityData".to_string(),
702                    min_supported_version: 1,
703                    migration_policy: MigrationPolicy::NotApplicable,
704                    restore_order: Some(10),
705                    post_upgrade_invariant: Some("external_authority_invariants".to_string()),
706                    migrations: Vec::new(),
707                }],
708                reserved_memory: Vec::new(),
709            }],
710        };
711
712        let checks = audit_checks(&manifest, None);
713
714        assert!(checks.iter().any(|check| {
715            check.code == "state_domain_storage_not_applicable"
716                && check.status == StateAuditStatus::Pass
717        }));
718        assert!(
719            checks
720                .iter()
721                .all(|check| check.code != "state_domain_missing_memory_id")
722        );
723    }
724
725    #[test]
726    fn invalid_support_window_fails() {
727        let manifest = StateManifest {
728            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
729            roles: vec![StateRoleManifest {
730                canister_role: "root".to_string(),
731                state: vec![StateDomainManifest {
732                    domain: "auth_sessions".to_string(),
733                    version: 2,
734                    storage: StateStorage::StableMemory,
735                    memory_id: Some(36),
736                    owner: "canic-core".to_string(),
737                    record: "AuthSessionRecord".to_string(),
738                    snapshot: "AuthSessionsData".to_string(),
739                    min_supported_version: 3,
740                    migration_policy: MigrationPolicy::NewDomain,
741                    restore_order: Some(10),
742                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
743                    migrations: Vec::new(),
744                }],
745                reserved_memory: Vec::new(),
746            }],
747        };
748
749        let checks = audit_checks(&manifest, None);
750
751        assert!(checks.iter().any(|check| {
752            check.code == "state_domain_invalid_support_window"
753                && check.status == StateAuditStatus::Fail
754        }));
755        assert!(
756            checks
757                .iter()
758                .all(|check| check.code != "migration_available")
759        );
760    }
761
762    #[test]
763    fn duplicate_migration_declaration_fails() {
764        let manifest = StateManifest {
765            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
766            roles: vec![StateRoleManifest {
767                canister_role: "root".to_string(),
768                state: vec![StateDomainManifest {
769                    domain: "auth_sessions".to_string(),
770                    version: 3,
771                    storage: StateStorage::StableMemory,
772                    memory_id: Some(36),
773                    owner: "canic-core".to_string(),
774                    record: "AuthSessionRecord".to_string(),
775                    snapshot: "AuthSessionsData".to_string(),
776                    min_supported_version: 2,
777                    migration_policy: MigrationPolicy::Migrate,
778                    restore_order: Some(10),
779                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
780                    migrations: vec![
781                        StateMigrationManifest {
782                            from: 2,
783                            to: 3,
784                            kind: "function".to_string(),
785                            name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
786                            test: Some(
787                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
788                            ),
789                        },
790                        StateMigrationManifest {
791                            from: 2,
792                            to: 3,
793                            kind: "function".to_string(),
794                            name: Some("migrate_auth_sessions_v2_to_v3_again".to_string()),
795                            test: Some(
796                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
797                            ),
798                        },
799                    ],
800                }],
801                reserved_memory: Vec::new(),
802            }],
803        };
804
805        let checks = audit_checks(&manifest, None);
806
807        assert!(checks.iter().any(|check| {
808            check.code == "migration_declaration_duplicate"
809                && check.status == StateAuditStatus::Fail
810        }));
811    }
812
813    #[test]
814    fn invalid_migration_declaration_fails() {
815        let manifest = StateManifest {
816            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
817            roles: vec![StateRoleManifest {
818                canister_role: "root".to_string(),
819                state: vec![StateDomainManifest {
820                    domain: "auth_sessions".to_string(),
821                    version: 4,
822                    storage: StateStorage::StableMemory,
823                    memory_id: Some(36),
824                    owner: "canic-core".to_string(),
825                    record: "AuthSessionRecord".to_string(),
826                    snapshot: "AuthSessionsData".to_string(),
827                    min_supported_version: 2,
828                    migration_policy: MigrationPolicy::Migrate,
829                    restore_order: Some(10),
830                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
831                    migrations: vec![StateMigrationManifest {
832                        from: 2,
833                        to: 4,
834                        kind: "function".to_string(),
835                        name: Some("migrate_auth_sessions_v2_to_v4".to_string()),
836                        test: Some("auth_sessions_v2_to_v4_upgrade_preserves_sessions".to_string()),
837                    }],
838                }],
839                reserved_memory: Vec::new(),
840            }],
841        };
842
843        let checks = audit_checks(&manifest, None);
844
845        assert!(checks.iter().any(|check| {
846            check.code == "migration_declaration_invalid" && check.status == StateAuditStatus::Fail
847        }));
848    }
849
850    #[test]
851    fn missing_migration_test_warns_separately_from_missing_migration() {
852        let manifest = StateManifest {
853            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
854            roles: vec![StateRoleManifest {
855                canister_role: "root".to_string(),
856                state: vec![StateDomainManifest {
857                    domain: "auth_sessions".to_string(),
858                    version: 3,
859                    storage: StateStorage::StableMemory,
860                    memory_id: Some(36),
861                    owner: "canic-core".to_string(),
862                    record: "AuthSessionRecord".to_string(),
863                    snapshot: "AuthSessionsData".to_string(),
864                    min_supported_version: 2,
865                    migration_policy: MigrationPolicy::Migrate,
866                    restore_order: Some(10),
867                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
868                    migrations: vec![StateMigrationManifest {
869                        from: 2,
870                        to: 3,
871                        kind: "function".to_string(),
872                        name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
873                        test: None,
874                    }],
875                }],
876                reserved_memory: Vec::new(),
877            }],
878        };
879        let checks = audit_checks(&manifest, None);
880
881        assert!(
882            checks
883                .iter()
884                .any(|check| check.code == "upgrade_test_missing"
885                    && check.status == StateAuditStatus::Warn)
886        );
887        assert!(checks.iter().all(|check| check.code != "migration_missing"));
888    }
889
890    #[test]
891    fn unknown_filtered_role_fails() {
892        let report = build_state_audit_report(Some("missing"));
893
894        assert_eq!(report.status, StateAuditStatus::Fail);
895        assert_eq!(report.checks[0].code, "state_role_missing");
896    }
897}