use super::*;
use crate::{discovery::SnapshotTarget, journal::ArtifactState, persistence::BackupLayout};
use std::{
error::Error as StdError,
fmt, fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
const ROOT: &str = "aaaaa-aa";
const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
#[test]
fn snapshot_manifest_includes_selection_and_artifacts() {
let targets = vec![
SnapshotTarget {
canister_id: ROOT.to_string(),
role: Some("root".to_string()),
parent_canister_id: None,
},
SnapshotTarget {
canister_id: CHILD.to_string(),
role: Some("app".to_string()),
parent_canister_id: Some(ROOT.to_string()),
},
];
let artifacts = targets
.iter()
.map(|target| SnapshotArtifact {
canister_id: target.canister_id.clone(),
snapshot_id: format!("snapshot-{}", target.role.as_deref().unwrap_or("unknown")),
path: std::path::PathBuf::from(target.canister_id.clone()),
checksum: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.to_string(),
})
.collect::<Vec<_>>();
let topology_hash =
topology_hash_for_targets(ROOT, &targets).expect("topology hash should build");
let manifest = build_snapshot_manifest(SnapshotManifestInput {
backup_id: "backup-test".to_string(),
created_at: "unknown".to_string(),
tool_name: "canic-cli".to_string(),
tool_version: "0.31.0".to_string(),
environment: "local".to_string(),
root_canister: ROOT.to_string(),
selected_canister: ROOT.to_string(),
include_children: true,
targets: &targets,
artifacts: &artifacts,
discovery_topology_hash: topology_hash.clone(),
pre_snapshot_topology_hash: topology_hash,
})
.expect("snapshot manifest should build");
assert_eq!(manifest.backup_id, "backup-test");
assert_eq!(manifest.fleet.members.len(), 2);
assert_eq!(
manifest.fleet.members[1]
.source_snapshot
.checksum
.as_deref(),
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
);
assert_eq!(
manifest.consistency.backup_units[0].kind,
BackupUnitKind::Subtree
);
}
#[test]
fn topology_stability_rejects_drift() {
let mut discovery = topology_hash_for_targets(
ROOT,
&[SnapshotTarget {
canister_id: ROOT.to_string(),
role: Some("root".to_string()),
parent_canister_id: None,
}],
)
.expect("topology hash should build");
let pre_snapshot = discovery.clone();
discovery.hash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
let err =
ensure_topology_stable(&discovery, &pre_snapshot).expect_err("topology drift should fail");
assert!(matches!(err, SnapshotManifestError::TopologyChanged { .. }));
}
#[test]
fn download_snapshots_writes_manifest_and_durable_journal() {
let root = temp_dir("canic-backup-download");
let out = root.join("backup");
let config = single_snapshot_config(out.clone());
let mut driver = FakeSnapshotDriver;
let result = download_snapshots(&config, &mut driver).expect("download snapshots");
let layout = BackupLayout::new(out);
let journal = layout.read_journal().expect("read journal");
let manifest = layout.read_manifest().expect("read manifest");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(result.artifacts.len(), 1);
assert!(result.planned_commands.is_empty());
assert_eq!(journal.artifacts.len(), 1);
assert_eq!(journal.operation_metrics.target_count, 1);
assert_eq!(journal.operation_metrics.snapshot_create_started, 1);
assert_eq!(journal.operation_metrics.snapshot_create_completed, 1);
assert_eq!(journal.operation_metrics.snapshot_download_started, 1);
assert_eq!(journal.operation_metrics.snapshot_download_completed, 1);
assert_eq!(journal.operation_metrics.checksum_verify_started, 1);
assert_eq!(journal.operation_metrics.checksum_verify_completed, 1);
assert_eq!(journal.operation_metrics.artifact_finalize_started, 1);
assert_eq!(journal.operation_metrics.artifact_finalize_completed, 1);
assert_eq!(journal.artifacts[0].state, ArtifactState::Durable);
assert!(journal.artifacts[0].checksum.is_some());
assert_eq!(manifest.backup_id, journal.backup_id);
assert_eq!(manifest.fleet.members.len(), 1);
assert_eq!(manifest.fleet.members[0].canister_id, ROOT);
assert_eq!(
manifest.fleet.members[0].source_snapshot.snapshot_id,
"snapshot-aaaaa-aa"
);
assert_eq!(
manifest.fleet.members[0]
.source_snapshot
.checksum
.as_deref(),
journal.artifacts[0].checksum.as_deref()
);
}
#[test]
fn dry_run_returns_planned_commands_without_writing_manifest() {
let root = temp_dir("canic-backup-dry-run");
let out = root.join("backup");
let mut config = single_snapshot_config(out.clone());
config.dry_run = true;
config.lifecycle = SnapshotLifecycleMode::StopAndResume;
let mut driver = FakeSnapshotDriver;
let result = download_snapshots(&config, &mut driver).expect("dry-run snapshots");
fs::remove_dir_all(root).ok();
assert_eq!(result.artifacts.len(), 1);
assert_eq!(
result.planned_commands,
vec![
"dfx canister stop aaaaa-aa",
"dfx canister snapshot create aaaaa-aa",
"dfx canister snapshot download aaaaa-aa <snapshot-id>",
"dfx canister start aaaaa-aa"
]
);
assert!(!out.join("fleet-backup-manifest.json").exists());
}
#[derive(Default)]
struct FakeSnapshotDriver;
impl SnapshotDriver for FakeSnapshotDriver {
fn registry_json(&mut self, _root: &str) -> Result<String, SnapshotDriverError> {
Err(Box::new(FakeDriverError("registry unavailable")))
}
fn create_snapshot(&mut self, canister_id: &str) -> Result<String, SnapshotDriverError> {
Ok(format!("snapshot-{canister_id}"))
}
fn stop_canister(&mut self, _canister_id: &str) -> Result<(), SnapshotDriverError> {
Ok(())
}
fn start_canister(&mut self, _canister_id: &str) -> Result<(), SnapshotDriverError> {
Ok(())
}
fn download_snapshot(
&mut self,
canister_id: &str,
snapshot_id: &str,
artifact_path: &Path,
) -> Result<(), SnapshotDriverError> {
fs::create_dir_all(artifact_path)?;
fs::write(
artifact_path.join("snapshot.txt"),
format!("{canister_id}:{snapshot_id}\n"),
)?;
Ok(())
}
fn create_snapshot_command(&self, canister_id: &str) -> String {
format!("dfx canister snapshot create {canister_id}")
}
fn stop_canister_command(&self, canister_id: &str) -> String {
format!("dfx canister stop {canister_id}")
}
fn start_canister_command(&self, canister_id: &str) -> String {
format!("dfx canister start {canister_id}")
}
fn download_snapshot_command(
&self,
canister_id: &str,
snapshot_id: &str,
_artifact_path: &Path,
) -> String {
format!("dfx canister snapshot download {canister_id} {snapshot_id}")
}
}
#[derive(Debug)]
struct FakeDriverError(&'static str);
impl fmt::Display for FakeDriverError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.0)
}
}
impl StdError for FakeDriverError {}
fn single_snapshot_config(out: PathBuf) -> SnapshotDownloadConfig {
SnapshotDownloadConfig {
canister: ROOT.to_string(),
out,
root: None,
include_children: false,
recursive: false,
dry_run: false,
lifecycle: SnapshotLifecycleMode::StopBeforeSnapshot,
backup_id: "backup-test".to_string(),
created_at: "unknown".to_string(),
tool_name: "canic-test".to_string(),
tool_version: "0.31.0".to_string(),
environment: "local".to_string(),
}
}
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time after epoch")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
}