canic-backup 0.97.2

Manifest and orchestration primitives for Canic deployment backup and restore
Documentation
use super::*;

const ROOT: &str = "aaaaa-aa";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

fn valid_journal() -> DownloadJournal {
    DownloadJournal {
        journal_version: 1,
        backup_id: "fbk_test_001".to_string(),
        discovery_topology_hash: HASH.to_string(),
        pre_snapshot_topology_hash: HASH.to_string(),
        operation_metrics: DownloadOperationMetrics::default(),
        artifacts: vec![ArtifactJournalEntry {
            canister_id: ROOT.to_string(),
            snapshot_id: "snap-1".to_string(),
            snapshot_taken_at_timestamp: Some(1_778_709_681_897_818_005),
            snapshot_total_size_bytes: Some(272_586_987),
            state: ArtifactState::Durable,
            temp_path: None,
            artifact_path: "artifacts/root".to_string(),
            checksum_algorithm: "sha256".to_string(),
            checksum: Some(HASH.to_string()),
            updated_at: "2026-04-10T12:00:00Z".to_string(),
        }],
    }
}

#[test]
fn valid_journal_passes_validation() {
    let journal = valid_journal();

    journal.validate().expect("journal should validate");
}

#[test]
fn download_journal_unknown_field_fails_deserialize() {
    let mut value = serde_json::to_value(valid_journal()).expect("serialize journal");
    value["unexpected_field"] = serde_json::Value::Bool(true);

    let err = serde_json::from_value::<DownloadJournal>(value).expect_err("unknown field rejects");

    assert!(err.is_data());
}

#[test]
fn download_journal_requires_current_topology_and_metrics_fields() {
    for field in [
        "discovery_topology_hash",
        "pre_snapshot_topology_hash",
        "operation_metrics",
    ] {
        let mut value = serde_json::to_value(valid_journal()).expect("serialize journal");
        value.as_object_mut().expect("journal object").remove(field);

        let err = serde_json::from_value::<DownloadJournal>(value)
            .expect_err("current journal field must be present");

        assert!(err.is_data());
    }
}

#[test]
fn download_journal_requires_exact_current_optional_fields() {
    for field in [
        "snapshot_taken_at_timestamp",
        "snapshot_total_size_bytes",
        "temp_path",
        "checksum",
    ] {
        let mut value = serde_json::to_value(valid_journal()).expect("serialize journal");
        value["artifacts"][0]
            .as_object_mut()
            .expect("artifact journal object")
            .remove(field);

        let err = serde_json::from_value::<DownloadJournal>(value)
            .expect_err("current artifact journal field must be present");
        assert!(err.is_data());
    }
}

#[test]
fn resume_action_matches_artifact_state() {
    let mut entry = valid_journal().artifacts.remove(0);

    entry.state = ArtifactState::Created;
    assert_eq!(entry.resume_action(), ResumeAction::Download);

    entry.state = ArtifactState::Downloaded;
    assert_eq!(entry.resume_action(), ResumeAction::VerifyChecksum);

    entry.state = ArtifactState::ChecksumVerified;
    assert_eq!(entry.resume_action(), ResumeAction::Finalize);

    entry.state = ArtifactState::Durable;
    assert_eq!(entry.resume_action(), ResumeAction::Skip);
}

#[test]
fn artifact_states_reject_fields_owned_by_other_transitions() {
    let mut created = valid_journal();
    created.artifacts[0].state = ArtifactState::Created;
    created.artifacts[0].checksum = None;
    created.artifacts[0].temp_path = Some("artifacts/root.tmp".to_string());
    let error = created
        .validate()
        .expect_err("created artifact cannot claim a temp path");
    std::assert_matches!(
        error,
        JournalValidationError::UnexpectedField {
            field: "artifacts[].temp_path",
            state: ArtifactState::Created,
        }
    );

    let mut downloaded = valid_journal();
    downloaded.artifacts[0].state = ArtifactState::Downloaded;
    downloaded.artifacts[0].temp_path = Some("artifacts/root.tmp".to_string());
    let error = downloaded
        .validate()
        .expect_err("downloaded artifact cannot claim a checksum");
    std::assert_matches!(
        error,
        JournalValidationError::UnexpectedField {
            field: "artifacts[].checksum",
            state: ArtifactState::Downloaded,
        }
    );

    let mut durable = valid_journal();
    durable.artifacts[0].temp_path = Some("artifacts/root.tmp".to_string());
    let error = durable
        .validate()
        .expect_err("durable artifact cannot retain a temp path");
    std::assert_matches!(
        error,
        JournalValidationError::UnexpectedField {
            field: "artifacts[].temp_path",
            state: ArtifactState::Durable,
        }
    );
}

#[test]
fn journal_state_names_match_their_rust_variants_without_serde_rules() {
    assert_eq!(
        serde_json::to_value(ArtifactState::ChecksumVerified).expect("serialize artifact state"),
        serde_json::json!("ChecksumVerified")
    );
    assert_eq!(
        serde_json::to_value(ResumeAction::VerifyChecksum).expect("serialize resume action"),
        serde_json::json!("VerifyChecksum")
    );
}

#[test]
fn resume_report_counts_states_and_actions() {
    let mut journal = valid_journal();
    journal.artifacts[0].state = ArtifactState::Created;
    journal.artifacts[0].checksum = None;
    let mut downloaded = journal.artifacts[0].clone();
    downloaded.snapshot_id = "snap-2".to_string();
    downloaded.state = ArtifactState::Downloaded;
    downloaded.temp_path = Some("artifacts/root.tmp".to_string());
    let mut durable = valid_journal().artifacts.remove(0);
    durable.snapshot_id = "snap-3".to_string();
    journal.artifacts.push(downloaded);
    journal.artifacts.push(durable);

    let report = journal.resume_report();

    assert_eq!(report.total_artifacts, 3);
    assert_eq!(report.discovery_topology_hash, HASH);
    assert_eq!(report.pre_snapshot_topology_hash, HASH);
    assert!(!report.is_complete);
    assert_eq!(report.pending_artifacts, 2);
    assert_eq!(report.counts.created, 1);
    assert_eq!(report.counts.downloaded, 1);
    assert_eq!(report.counts.durable, 1);
    assert_eq!(report.counts.download, 1);
    assert_eq!(report.counts.verify_checksum, 1);
    assert_eq!(report.counts.skip, 1);
    assert_eq!(report.artifacts[0].resume_action, ResumeAction::Download);
}

#[test]
fn state_transitions_require_the_canonical_next_state() {
    let mut entry = valid_journal().artifacts.remove(0);

    let backward = entry
        .advance_to(
            ArtifactState::Downloaded,
            "2026-04-10T12:01:00Z".to_string(),
        )
        .expect_err("durable cannot move back to downloaded");
    std::assert_matches!(
        backward,
        JournalValidationError::InvalidStateTransition { .. }
    );

    entry.state = ArtifactState::Created;
    let repeated = entry
        .advance_to(ArtifactState::Created, "2026-04-10T12:01:00Z".to_string())
        .expect_err("artifact state cannot be overwritten in place");
    std::assert_matches!(
        repeated,
        JournalValidationError::InvalidStateTransition { .. }
    );

    let skipped = entry
        .advance_to(
            ArtifactState::ChecksumVerified,
            "2026-04-10T12:01:00Z".to_string(),
        )
        .expect_err("artifact state cannot skip the download transition");
    std::assert_matches!(
        skipped,
        JournalValidationError::InvalidStateTransition { .. }
    );

    entry
        .advance_to(
            ArtifactState::Downloaded,
            "2026-04-10T12:01:00Z".to_string(),
        )
        .expect("created artifact advances to downloaded");
}

#[test]
fn durable_artifact_requires_checksum() {
    let mut journal = valid_journal();
    journal.artifacts[0].checksum = None;

    let err = journal
        .validate()
        .expect_err("durable artifact without checksum should fail");

    std::assert_matches!(err, JournalValidationError::EmptyField(_));
}

#[test]
fn duplicate_artifacts_fail_validation() {
    let mut journal = valid_journal();
    journal.artifacts.push(journal.artifacts[0].clone());

    let err = journal
        .validate()
        .expect_err("duplicate artifact should fail");

    std::assert_matches!(err, JournalValidationError::DuplicateArtifact { .. });
}

#[test]
fn artifact_paths_must_stay_relative_to_backup_root() {
    for path in ["../outside", "/tmp/outside"] {
        let mut journal = valid_journal();
        journal.artifacts[0].artifact_path = path.to_string();

        let err = journal
            .validate()
            .expect_err("escaping artifact path should fail");

        std::assert_matches!(err, JournalValidationError::InvalidArtifactPath { .. });
    }
}

#[test]
fn journal_round_trips_through_json() {
    let journal = valid_journal();

    let encoded = serde_json::to_string(&journal).expect("serialize journal");
    let decoded: DownloadJournal = serde_json::from_str(&encoded).expect("deserialize journal");

    decoded.validate().expect("decoded journal should validate");
}