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 = 2;
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::CoreRootAppRegistry,
260                    StateAllocationKey::CoreRuntimeEnvironment,
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(_) => Vec::new(),
285        };
286        materialize_state_manifest(&contracts).expect("test manifest")
287    }
288
289    fn test_contract(
290        role: &str,
291        built_in: Option<BuiltInRoleKind>,
292        keys: &[StateAllocationKey],
293    ) -> ResolvedRoleContract {
294        let allocations = keys
295            .iter()
296            .map(|key| {
297                let definition = allocation_definition(*key).expect("allocation definition");
298                ResolvedStateAllocation {
299                    key: *key,
300                    owner: definition.owner,
301                    memory_ids: definition.memory_ids.to_vec(),
302                    selected_by: BTreeSet::from([if let Some(built_in) = built_in {
303                        SelectionProvenance::BuiltInRole(built_in)
304                    } else if definition.owner == AllocationOwner::CanicControlPlane {
305                        SelectionProvenance::EffectiveFeature(CanicFeatureKey::ControlPlane)
306                    } else {
307                        SelectionProvenance::Capability(
308                            canic_core::role_contract::RoleCapabilityKey::Root,
309                        )
310                    }]),
311                }
312            })
313            .collect();
314        ResolvedRoleContract {
315            role: CanisterRole::owned(role.to_string()),
316            built_in,
317            capabilities: BTreeSet::new(),
318            required_features: BTreeSet::new(),
319            effective_features: BTreeSet::new(),
320            allocations,
321        }
322    }
323
324    fn build_state_audit_report(role: Option<&str>) -> StateAuditReport {
325        let resolution = StateManifestResolution::Resolved {
326            manifest: test_state_manifest(role),
327            contracts: Vec::new(),
328        };
329        super::build_state_audit_report(&resolution, role)
330    }
331
332    #[test]
333    fn state_audit_status_owns_serialized_labels() {
334        assert_eq!(StateAuditStatus::Pass.label(), "pass");
335        assert_eq!(StateAuditStatus::Warn.label(), "warn");
336        assert_eq!(StateAuditStatus::Fail.label(), "fail");
337        assert_eq!(StateAuditStatus::NotEvaluated.label(), "not_evaluated");
338    }
339
340    #[test]
341    fn builtin_report_passes_when_every_active_memory_id_is_modeled() {
342        let report = build_state_audit_report(Some("root"));
343
344        assert_eq!(report.status, StateAuditStatus::Pass);
345        assert!(report.checks.iter().any(|check| {
346            check.code == "state_manifest_schema_version_supported"
347                && check.status == StateAuditStatus::Pass
348        }));
349        assert!(
350            report
351                .checks
352                .iter()
353                .all(|check| check.code != "reserved_memory_id_declared")
354        );
355        assert!(
356            report
357                .checks
358                .iter()
359                .all(|check| check.code != "snapshot_name_invalid")
360        );
361        assert!(
362            report
363                .checks
364                .iter()
365                .any(|check| check.code == "reserved_export_import_ok")
366        );
367        assert!(
368            report
369                .checks
370                .iter()
371                .all(|check| check.status != StateAuditStatus::Fail)
372        );
373    }
374
375    #[test]
376    fn builtin_manifest_merges_control_plane_state_by_role() {
377        let manifest = test_state_manifest(Some("root"));
378        let role = manifest.roles.first().expect("root role");
379
380        assert!(role.state.iter().any(|domain| {
381            domain.domain == "template_manifests"
382                && domain.owner == "canic-control-plane"
383                && domain.memory_id == Some(80)
384        }));
385        assert!(
386            role.state
387                .iter()
388                .any(|domain| { domain.domain == "auth_state" && domain.owner == "canic-core" })
389        );
390    }
391
392    #[test]
393    fn wasm_store_role_audits_every_owned_memory_domain_cleanly() {
394        let report = build_state_audit_report(Some("wasm_store"));
395
396        assert_eq!(report.status, StateAuditStatus::Pass);
397        let role = report
398            .manifest
399            .roles
400            .iter()
401            .find(|role| role.canister_role == "wasm_store")
402            .expect("wasm_store role");
403        let domains = role
404            .state
405            .iter()
406            .map(|domain| domain.domain.as_str())
407            .collect::<Vec<_>>();
408
409        for expected in [
410            "template_manifests",
411            "template_chunk_sets",
412            "template_chunk_refs",
413            "template_chunk_payloads",
414            "wasm_store_gc_state",
415        ] {
416            assert!(domains.contains(&expected));
417        }
418        assert_eq!(domains.len(), 5);
419    }
420
421    #[test]
422    fn complete_descriptor_registry_satisfies_state_audit_metadata_contract() {
423        let keys = canic_core::role_contract::allocation::allocation_definitions()
424            .iter()
425            .map(|definition| definition.key)
426            .collect::<Vec<_>>();
427        let manifest = materialize_state_manifest(&[test_contract("catalog", None, &keys)])
428            .expect("complete descriptor manifest");
429        let checks = audit_checks(&manifest, Some("catalog"));
430
431        assert!(
432            checks
433                .iter()
434                .all(|check| check.status != StateAuditStatus::Fail),
435            "complete descriptor registry must satisfy audit metadata: {checks:#?}"
436        );
437    }
438
439    #[test]
440    fn exact_blob_role_resolution_materializes_blob_allocations() {
441        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
442        let config = workspace.join("canisters/test/blob_storage_probe/canic.toml");
443        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("test"));
444        let StateManifestResolution::Resolved {
445            manifest,
446            contracts,
447        } = resolution
448        else {
449            panic!("blob role contract should resolve")
450        };
451
452        assert_eq!(contracts.len(), 1);
453        let role = manifest.roles.first().expect("blob role manifest");
454        assert_eq!(role.canister_role, "test");
455        assert_eq!(
456            role.state
457                .iter()
458                .filter_map(|domain| domain.memory_id)
459                .filter(|memory_id| (62..=65).contains(memory_id))
460                .collect::<Vec<_>>(),
461            vec![63, 65, 64, 62]
462        );
463        assert!(role.state.iter().all(|domain| domain.owner == "canic-core"));
464    }
465
466    #[test]
467    fn placement_roles_materialize_exact_placement_state() {
468        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
469
470        for (config_path, role, expected_ids) in [
471            ("fleets/test/canic.toml", "user_hub", vec![49, 53, 54, 56]),
472            (
473                "canisters/audit/scaling_probe/canic.toml",
474                "scale_hub",
475                vec![49, 52],
476            ),
477            (
478                "canisters/test/project_hub_stub/canic.toml",
479                "project_hub",
480                vec![49, 55],
481            ),
482        ] {
483            let config = workspace.join(config_path);
484            let resolution = resolve_project_state_manifest(&workspace, &[config], Some(role));
485            let StateManifestResolution::Resolved { manifest, .. } = resolution else {
486                panic!("{role} role contract should resolve");
487            };
488            let mut actual_ids = manifest.roles[0]
489                .state
490                .iter()
491                .filter_map(|domain| domain.memory_id)
492                .filter(|memory_id| (49..=56).contains(memory_id))
493                .collect::<Vec<_>>();
494            actual_ids.sort_unstable();
495
496            assert_eq!(actual_ids, expected_ids, "unexpected state for {role}");
497        }
498    }
499
500    #[test]
501    fn exact_built_in_resolution_materializes_runtime_template_and_gc_allocations() {
502        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
503        let resolution = resolve_project_state_manifest(&workspace, &[], Some("wasm_store"));
504        let StateManifestResolution::Resolved {
505            manifest,
506            contracts,
507        } = resolution
508        else {
509            panic!("built-in wasm_store contract should resolve")
510        };
511
512        assert_eq!(contracts.len(), 1);
513        let mut ids = manifest.roles[0]
514            .state
515            .iter()
516            .filter_map(|domain| domain.memory_id)
517            .collect::<Vec<_>>();
518        ids.sort_unstable();
519        assert_eq!(
520            ids,
521            vec![
522                11, 12, 13, 15, 16, 18, 20, 29, 30, 34, 35, 39, 40, 41, 42, 43, 44, 45, 46, 47, 80,
523                81, 82, 83, 85,
524            ]
525        );
526        assert_eq!(
527            manifest.roles[0]
528                .reserved_memory
529                .iter()
530                .map(|entry| entry.memory_id)
531                .collect::<Vec<_>>(),
532            Vec::<u8>::new()
533        );
534    }
535
536    #[test]
537    fn unknown_role_resolution_returns_no_manifest() {
538        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
539        let config = workspace.join("canisters/audit/root_probe/canic.toml");
540        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("missing"));
541
542        assert!(matches!(
543            resolution,
544            StateManifestResolution::Rejected { errors }
545                if errors.iter().any(|finding| matches!(finding, RoleContractFinding::RoleUnknown { .. }))
546        ));
547    }
548
549    #[test]
550    fn unsupported_manifest_schema_version_fails() {
551        let mut manifest = test_state_manifest(Some("root"));
552        manifest.schema_version = STATE_MANIFEST_SCHEMA_VERSION + 1;
553
554        let checks = audit_checks(&manifest, Some("root"));
555
556        assert!(checks.iter().any(|check| {
557            check.code == "state_manifest_schema_version_unsupported"
558                && check.status == StateAuditStatus::Fail
559        }));
560    }
561
562    #[test]
563    fn duplicate_state_role_fails() {
564        let mut manifest = test_state_manifest(Some("root"));
565        let duplicate = manifest.roles[0].clone();
566        manifest.roles.push(duplicate);
567
568        let checks = audit_checks(&manifest, None);
569
570        assert!(checks.iter().any(|check| {
571            check.code == "state_role_duplicate" && check.status == StateAuditStatus::Fail
572        }));
573    }
574
575    #[test]
576    fn duplicate_memory_id_fails_within_role() {
577        let mut manifest = test_state_manifest(Some("root"));
578        let role = manifest.roles.first_mut().expect("root role");
579        role.state[1].memory_id = role.state[0].memory_id;
580
581        let checks = audit_checks(&manifest, Some("root"));
582        assert!(
583            checks
584                .iter()
585                .any(|check| check.code == "memory_id_duplicate"
586                    && check.status == StateAuditStatus::Fail)
587        );
588    }
589
590    #[test]
591    fn duplicate_state_domain_fails_within_role() {
592        let mut manifest = test_state_manifest(Some("root"));
593        let role = manifest.roles.first_mut().expect("root role");
594        let mut duplicate = role.state[0].clone();
595        duplicate.memory_id = Some(250);
596        role.state.push(duplicate);
597
598        let checks = audit_checks(&manifest, Some("root"));
599
600        assert!(
601            checks
602                .iter()
603                .any(|check| check.code == "state_domain_duplicate"
604                    && check.status == StateAuditStatus::Fail)
605        );
606    }
607
608    #[test]
609    fn active_cycle_tracker_memory_is_not_reported_as_reserved() {
610        let report = build_state_audit_report(Some("root"));
611
612        assert!(
613            report
614                .checks
615                .iter()
616                .all(|check| check.code != "reserved_memory_id_declared")
617        );
618    }
619
620    #[test]
621    fn active_domain_reclaiming_reserved_memory_id_fails() {
622        let mut manifest = test_state_manifest(Some("root"));
623        let role = manifest.roles.first_mut().expect("root role");
624        let reserved_id = 250;
625        role.reserved_memory.push(ReservedMemoryManifest {
626            label: "future_state".to_string(),
627            memory_id: reserved_id,
628            owner: "canic-core".to_string(),
629            reason: "synthetic collision fixture".to_string(),
630        });
631        role.state[0].memory_id = Some(reserved_id);
632
633        let checks = audit_checks(&manifest, Some("root"));
634
635        assert!(
636            checks
637                .iter()
638                .any(|check| check.code == "reserved_memory_id_collision"
639                    && check.status == StateAuditStatus::Fail)
640        );
641    }
642
643    #[test]
644    fn storage_not_applicable_is_explicit_metadata() {
645        let manifest = StateManifest {
646            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
647            roles: vec![StateRoleManifest {
648                canister_role: "root".to_string(),
649                state: vec![StateDomainManifest {
650                    domain: "external_authority".to_string(),
651                    version: 1,
652                    storage: StateStorage::NotApplicable,
653                    memory_id: None,
654                    owner: "canic-core".to_string(),
655                    record: "ExternalAuthorityRecord".to_string(),
656                    snapshot: "ExternalAuthorityData".to_string(),
657                    min_supported_version: 1,
658                    migration_policy: MigrationPolicy::NotApplicable,
659                    restore_order: Some(10),
660                    post_upgrade_invariant: Some("external_authority_invariants".to_string()),
661                    migrations: Vec::new(),
662                }],
663                reserved_memory: Vec::new(),
664            }],
665        };
666
667        let checks = audit_checks(&manifest, None);
668
669        assert!(checks.iter().any(|check| {
670            check.code == "state_domain_storage_not_applicable"
671                && check.status == StateAuditStatus::Pass
672        }));
673        assert!(
674            checks
675                .iter()
676                .all(|check| check.code != "state_domain_missing_memory_id")
677        );
678    }
679
680    #[test]
681    fn invalid_support_window_fails() {
682        let manifest = StateManifest {
683            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
684            roles: vec![StateRoleManifest {
685                canister_role: "root".to_string(),
686                state: vec![StateDomainManifest {
687                    domain: "auth_sessions".to_string(),
688                    version: 2,
689                    storage: StateStorage::StableMemory,
690                    memory_id: Some(19),
691                    owner: "canic-core".to_string(),
692                    record: "AuthSessionRecord".to_string(),
693                    snapshot: "AuthSessionsData".to_string(),
694                    min_supported_version: 3,
695                    migration_policy: MigrationPolicy::NewDomain,
696                    restore_order: Some(10),
697                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
698                    migrations: Vec::new(),
699                }],
700                reserved_memory: Vec::new(),
701            }],
702        };
703
704        let checks = audit_checks(&manifest, None);
705
706        assert!(checks.iter().any(|check| {
707            check.code == "state_domain_invalid_support_window"
708                && check.status == StateAuditStatus::Fail
709        }));
710        assert!(
711            checks
712                .iter()
713                .all(|check| check.code != "migration_available")
714        );
715    }
716
717    #[test]
718    fn duplicate_migration_declaration_fails() {
719        let manifest = StateManifest {
720            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
721            roles: vec![StateRoleManifest {
722                canister_role: "root".to_string(),
723                state: vec![StateDomainManifest {
724                    domain: "auth_sessions".to_string(),
725                    version: 3,
726                    storage: StateStorage::StableMemory,
727                    memory_id: Some(19),
728                    owner: "canic-core".to_string(),
729                    record: "AuthSessionRecord".to_string(),
730                    snapshot: "AuthSessionsData".to_string(),
731                    min_supported_version: 2,
732                    migration_policy: MigrationPolicy::Migrate,
733                    restore_order: Some(10),
734                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
735                    migrations: vec![
736                        StateMigrationManifest {
737                            from: 2,
738                            to: 3,
739                            kind: "function".to_string(),
740                            name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
741                            test: Some(
742                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
743                            ),
744                        },
745                        StateMigrationManifest {
746                            from: 2,
747                            to: 3,
748                            kind: "function".to_string(),
749                            name: Some("migrate_auth_sessions_v2_to_v3_again".to_string()),
750                            test: Some(
751                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
752                            ),
753                        },
754                    ],
755                }],
756                reserved_memory: Vec::new(),
757            }],
758        };
759
760        let checks = audit_checks(&manifest, None);
761
762        assert!(checks.iter().any(|check| {
763            check.code == "migration_declaration_duplicate"
764                && check.status == StateAuditStatus::Fail
765        }));
766    }
767
768    #[test]
769    fn invalid_migration_declaration_fails() {
770        let manifest = StateManifest {
771            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
772            roles: vec![StateRoleManifest {
773                canister_role: "root".to_string(),
774                state: vec![StateDomainManifest {
775                    domain: "auth_sessions".to_string(),
776                    version: 4,
777                    storage: StateStorage::StableMemory,
778                    memory_id: Some(19),
779                    owner: "canic-core".to_string(),
780                    record: "AuthSessionRecord".to_string(),
781                    snapshot: "AuthSessionsData".to_string(),
782                    min_supported_version: 2,
783                    migration_policy: MigrationPolicy::Migrate,
784                    restore_order: Some(10),
785                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
786                    migrations: vec![StateMigrationManifest {
787                        from: 2,
788                        to: 4,
789                        kind: "function".to_string(),
790                        name: Some("migrate_auth_sessions_v2_to_v4".to_string()),
791                        test: Some("auth_sessions_v2_to_v4_upgrade_preserves_sessions".to_string()),
792                    }],
793                }],
794                reserved_memory: Vec::new(),
795            }],
796        };
797
798        let checks = audit_checks(&manifest, None);
799
800        assert!(checks.iter().any(|check| {
801            check.code == "migration_declaration_invalid" && check.status == StateAuditStatus::Fail
802        }));
803    }
804
805    #[test]
806    fn missing_migration_test_warns_separately_from_missing_migration() {
807        let manifest = StateManifest {
808            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
809            roles: vec![StateRoleManifest {
810                canister_role: "root".to_string(),
811                state: vec![StateDomainManifest {
812                    domain: "auth_sessions".to_string(),
813                    version: 3,
814                    storage: StateStorage::StableMemory,
815                    memory_id: Some(19),
816                    owner: "canic-core".to_string(),
817                    record: "AuthSessionRecord".to_string(),
818                    snapshot: "AuthSessionsData".to_string(),
819                    min_supported_version: 2,
820                    migration_policy: MigrationPolicy::Migrate,
821                    restore_order: Some(10),
822                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
823                    migrations: vec![StateMigrationManifest {
824                        from: 2,
825                        to: 3,
826                        kind: "function".to_string(),
827                        name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
828                        test: None,
829                    }],
830                }],
831                reserved_memory: Vec::new(),
832            }],
833        };
834        let checks = audit_checks(&manifest, None);
835
836        assert!(
837            checks
838                .iter()
839                .any(|check| check.code == "upgrade_test_missing"
840                    && check.status == StateAuditStatus::Warn)
841        );
842        assert!(checks.iter().all(|check| check.code != "migration_missing"));
843    }
844
845    #[test]
846    fn unknown_filtered_role_fails() {
847        let report = build_state_audit_report(Some("missing"));
848
849        assert_eq!(report.status, StateAuditStatus::Fail);
850        assert_eq!(report.checks[0].code, "state_role_missing");
851    }
852}