1mod 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#[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#[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#[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#[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#[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#[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#[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}