use crate::test_support::temp_dir;
use canic_backup::{
artifacts::ArtifactChecksum,
journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
manifest::{
BackupUnit, BackupUnitKind, ConsistencySection, FleetBackupManifest, FleetMember,
FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
VerificationCheck, VerificationPlan,
},
};
use std::{
fs,
path::{Path, PathBuf},
};
const ROOT: &str = "aaaaa-aa";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn backup_usage_lists_commands_without_nested_flag_dump() {
let text = usage();
assert!(text.contains("Usage: canic backup"));
assert!(text.contains("list"));
assert!(text.contains("verify"));
assert!(text.contains("status"));
}
#[test]
fn parses_backup_list_options() {
let options = BackupListOptions::parse([
OsString::from("--dir"),
OsString::from("snapshots"),
OsString::from("--out"),
OsString::from("backups.txt"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("snapshots"));
assert_eq!(options.out, Some(PathBuf::from("backups.txt")));
let default_options = BackupListOptions::parse([]).expect("parse default options");
assert_eq!(default_options.dir, PathBuf::from("backups"));
}
#[test]
fn parses_backup_verify_options() {
let options = BackupVerifyOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("report.json"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("report.json")));
}
#[test]
fn parses_backup_status_options() {
let options = BackupStatusOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("status.json"),
OsString::from("--require-complete"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("status.json")));
assert!(options.require_complete);
}
#[test]
fn backup_status_reads_journal_resume_report() {
let root = temp_dir("canic-cli-backup-status");
let layout = BackupLayout::new(root.clone());
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupStatusOptions {
dir: root.clone(),
out: None,
require_complete: false,
};
let report = backup_status(&options).expect("read backup status");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert_eq!(report.total_artifacts, 1);
assert!(report.is_complete);
assert_eq!(report.pending_artifacts, 0);
assert_eq!(report.counts.skip, 1);
}
#[test]
fn backup_list_reads_backup_directories() {
let root = temp_dir("canic-cli-backup-list");
let first = root.join("fleet-demo-20260507-120000");
let second = root.join("fleet-demo-20260507-130000");
let ignored = root.join("not-a-backup");
BackupLayout::new(first)
.write_manifest(&valid_manifest_with("backup-old", "2026-05-07T12:00:00Z"))
.expect("write first manifest");
BackupLayout::new(second)
.write_manifest(&valid_manifest_with("backup-new", "2026-05-07T13:00:00Z"))
.expect("write second manifest");
fs::create_dir_all(&ignored).expect("create ignored dir");
let options = BackupListOptions {
dir: root.clone(),
out: None,
};
let entries = backup_list(&options).expect("list backups");
let rendered = render_backup_list(&entries);
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].backup_id, "backup-new");
assert_eq!(entries[1].backup_id, "backup-old");
assert!(rendered.contains("DIR"));
assert!(rendered.contains("backup-new"));
assert!(rendered.contains("fleet-demo-20260507-130000"));
}
#[test]
fn backup_list_formats_unix_created_at() {
let entries = vec![BackupListEntry {
dir: PathBuf::from("backups/fleet-demo-20240507-140000"),
backup_id: "backup".to_string(),
created_at: "unix:1715090400".to_string(),
members: 7,
status: "ok".to_string(),
}];
let rendered = render_backup_list(&entries);
assert!(rendered.contains("07/05/2024 14:00"));
assert!(!rendered.contains("unix:"));
}
#[test]
fn require_complete_accepts_complete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = journal_with_checksum(HASH.to_string()).resume_report();
enforce_status_requirements(&options, &report).expect("complete status should pass");
}
#[test]
fn require_complete_rejects_incomplete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = created_journal().resume_report();
let err =
enforce_status_requirements(&options, &report).expect_err("incomplete status should fail");
assert!(matches!(
err,
BackupCommandError::IncompleteJournal {
pending_artifacts: 1,
total_artifacts: 1,
..
}
));
}
#[test]
fn verify_backup_reads_layout_and_artifacts() {
let root = temp_dir("canic-cli-backup-verify");
let layout = BackupLayout::new(root.clone());
let checksum = write_artifact(&root, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash.clone()))
.expect("write journal");
let options = BackupVerifyOptions {
dir: root.clone(),
out: None,
};
let report = verify_backup(&options).expect("verify backup");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.verified);
assert_eq!(report.durable_artifacts, 1);
assert_eq!(report.artifacts[0].checksum, checksum.hash);
}
fn valid_manifest() -> FleetBackupManifest {
valid_manifest_with("backup-test", "2026-05-03T00:00:00Z")
}
fn valid_manifest_with(backup_id: &str, created_at: &str) -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: backup_id.to_string(),
created_at: created_at.to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "0.30.3".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
backup_units: vec![BackupUnit {
unit_id: "fleet".to_string(),
kind: BackupUnitKind::Single,
roles: vec!["root".to_string()],
}],
},
fleet: FleetSection {
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
topology_hash: HASH.to_string(),
members: vec![fleet_member()],
},
verification: VerificationPlan::default(),
}
}
fn fleet_member() -> FleetMember {
FleetMember {
role: "root".to_string(),
canister_id: ROOT.to_string(),
parent_canister_id: None,
subnet_canister_id: Some(ROOT.to_string()),
controller_hint: None,
identity_mode: IdentityMode::Fixed,
verification_checks: vec![VerificationCheck {
kind: "status".to_string(),
roles: vec!["root".to_string()],
}],
source_snapshot: SourceSnapshot {
snapshot_id: "root-snapshot".to_string(),
module_hash: None,
wasm_hash: None,
code_version: Some("v0.30.3".to_string()),
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
},
}
}
fn journal_with_checksum(checksum: String) -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Durable,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: Some(checksum),
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn created_journal() -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Created,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
let path = root.join("artifacts/root");
fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
fs::write(&path, bytes).expect("write artifact");
ArtifactChecksum::from_bytes(bytes)
}
use super::*;