safe-migrate 0.9.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use safe_migrate::api;
use std::error::Error as _;

#[test]
fn public_api_analyzes_a_typed_migration_chain_with_an_opaque_baseline() {
    let config = api::Config::default()
        .with_cache_encryption(false)
        .with_tier_thresholds(100_000, 10_000);
    let outcome = api::analyze_chain(
        &config,
        [api::Migration::new("migration.sql", "")],
        &api::Baseline::unavailable(),
    )
    .expect("the supported API should accept a valid empty migration");

    assert!(outcome.findings().is_empty());
    assert_eq!(outcome.confidence(), api::Confidence::Tainted);
    assert_eq!(outcome.evidence().len(), 1);
    assert_eq!(
        outcome.evidence()[0].code,
        api::EvidenceCode::BaselineUnavailable
    );
    assert!(api::rule(&api::Config::default(), "missing-idempotency").is_ok());
}

#[test]
fn public_config_builders_cover_every_runtime_setting() {
    let rule = api::RuleConfig::new()
        .disabled(true)
        .tier_thresholds(Some(25), Some(10));
    let config = api::Config::default()
        .with_cache_encryption(true)
        .with_auto_sync(true)
        .with_stale_stats_days(3)
        .with_tier_thresholds(500, 50)
        .with_default_rows(250)
        .with_toast_width_threshold_bytes(1024)
        .with_assumed_postgres_version(170000)
        .with_schema_scope(["public"])
        .with_rule("missing-idempotency", rule.clone())
        .enable_rule("missing-idempotency")
        .disable_rule("require-lock-timeout");

    assert!(config.cache_encryption());
    assert!(config.auto_sync());
    assert_eq!(config.stale_stats_days(), 3);
    assert_eq!(config.tier1_threshold_rows(), 500);
    assert_eq!(config.tier2_threshold_rows(), 50);
    assert_eq!(config.default_rows(), 250);
    assert_eq!(config.toast_width_threshold_bytes(), 1024);
    assert_eq!(config.assumed_postgres_version(), 170000);
    assert_eq!(
        config.schema_scope(),
        Some([String::from("public")].as_slice())
    );
    assert!(!config.is_rule_disabled("missing-idempotency"));
    assert!(config.is_rule_disabled("require-lock-timeout"));
    assert_eq!(
        config
            .rule_config("missing-idempotency")
            .unwrap()
            .disabled_override(),
        Some(false)
    );
    assert_eq!(rule.disabled_override(), Some(true));
    assert_eq!(rule.tier1_threshold_rows(), Some(25));
    assert_eq!(rule.tier2_threshold_rows(), Some(10));
}

#[test]
fn unavailable_baseline_is_honest_in_every_report_surface() {
    let config = api::Config::default();
    let baseline = api::Baseline::unavailable();
    let inspection = baseline.inspect();
    assert!(!inspection.available);
    assert_eq!(inspection.format_version, None);
    assert_eq!(inspection.observed_settings.lock_timeout_ms, None);

    let outcome = api::analyze(
        &config,
        "001_create_users.sql",
        "CREATE TABLE users (id bigint);",
        &baseline,
    )
    .expect("analyze with conservative defaults");
    assert_eq!(outcome.baseline().status, api::BaselineStatus::Unavailable);
    assert_eq!(outcome.confidence(), api::Confidence::Tainted);
    assert_eq!(
        serde_json::to_value(outcome.confidence()).unwrap(),
        "Tainted"
    );

    let json = outcome.json();
    assert_eq!(json["baseline"]["status"], "unavailable");
    assert_eq!(json["baseline"]["auto_sync"], "not_requested");
    assert_eq!(
        json["baseline"],
        serde_json::to_value(outcome.baseline()).unwrap()
    );
    assert_eq!(
        json["evidence"],
        serde_json::to_value(outcome.evidence()).unwrap()
    );
    assert!(outcome.markdown().contains("## Baseline"));
}

#[test]
fn public_finding_serialization_matches_the_report_contract() {
    let config = api::Config::default();
    let baseline = api::Baseline::unavailable();
    let outcome = api::analyze(
        &config,
        "001_create_users.sql",
        "CREATE TABLE users (id bigint);",
        &baseline,
    )
    .unwrap();
    let finding = outcome
        .findings()
        .iter()
        .find(|finding| finding.rule_id == "missing-idempotency")
        .expect("CREATE TABLE without a guard should be reported");
    assert_eq!(finding.operation_kind, api::OperationKind::CreateTable);
    assert_eq!(finding.object_kind, api::ObjectKind::Table);
    let value = serde_json::to_value(finding).unwrap();

    assert_eq!(value["fk_dependency_related"], false);
    assert!(value.get("foreign_key_dependency_related").is_none());
    assert_eq!(value["rule_title"], "Missing idempotency");
    assert!(value.get("rule_summary").is_some());
    assert!(value.get("impact").is_some());
    assert!(value.get("dedup_key").is_some());
    let report_finding = outcome.json()["violations"]
        .as_array()
        .unwrap()
        .iter()
        .find(|candidate| candidate["rule_id"] == finding.rule_id)
        .unwrap()
        .clone();
    assert_eq!(value, report_finding);
}

#[test]
fn outcome_exposes_the_same_verdict_and_summary_as_its_reports() {
    let outcome = api::analyze(
        &api::Config::default(),
        "001_create_users.sql",
        "CREATE TABLE users (id bigint);",
        &api::Baseline::unavailable(),
    )
    .unwrap();

    assert_eq!(outcome.verdict(), api::Verdict::Safe);
    assert_eq!(outcome.verdict().as_str(), "SAFE");
    assert_eq!(
        outcome.recommendation(),
        "no blocking finding, but baseline evidence is uncertain — review before deploying"
    );
    let summary = outcome.summary();
    assert_eq!(summary.total, outcome.findings().len());
    assert_eq!(summary.total, summary.tier1 + summary.tier2 + summary.tier3);
    assert_eq!(outcome.json()["verdict"], "SAFE");
    assert_eq!(outcome.json()["schema_version"], api::REPORT_SCHEMA_VERSION);
    assert_eq!(
        outcome.json()["summary"],
        serde_json::to_value(summary).unwrap()
    );
}

#[test]
fn optional_baseline_only_downgrades_a_missing_file() {
    let directory = tempfile::tempdir().unwrap();
    let config = api::Config::default();
    let missing = directory.path().join("missing.cache");
    let baseline = api::Baseline::load_optional(&missing, &config).unwrap();

    assert!(!baseline.is_available());
}

#[test]
fn configuration_errors_have_a_stable_kind_and_source() {
    let directory = tempfile::tempdir().unwrap();
    let path = directory.path().join("invalid.toml");
    std::fs::write(&path, "auto_syn = true").unwrap();
    let error = api::Config::load_required_from_file(&path).unwrap_err();

    assert_eq!(error.kind(), api::ErrorKind::Configuration);
    assert!(error.source().is_some());

    let unknown = api::rule(&api::Config::default(), "not-a-rule").unwrap_err();
    assert_eq!(unknown.kind(), api::ErrorKind::UnknownRule);
}

#[test]
fn public_io_and_validation_errors_retain_their_source_chain() {
    let remote = api::DatabaseUrl::new("postgres://db.example.com/app").unwrap_err();
    assert_eq!(remote.kind(), api::ErrorKind::Configuration);
    assert!(remote.source().is_some());

    let directory = tempfile::tempdir().unwrap();
    let cache = directory.path().join("truncated.cache");
    std::fs::write(&cache, b"not-zstd").unwrap();
    let error = api::Baseline::load(&cache, &api::Config::default().with_cache_encryption(false))
        .unwrap_err();
    assert_eq!(error.kind(), api::ErrorKind::Cache);
    assert!(error.source().is_some());
}

#[test]
fn config_and_baseline_can_be_reused_across_analyses() {
    let config = api::Config::default();
    let baseline = api::Baseline::unavailable();

    for filename in ["001.sql", "002.sql"] {
        api::analyze(&config, filename, "", &baseline).unwrap();
    }
}

#[test]
fn public_api_values_remain_send_and_sync() {
    fn assert_send_sync<T: Send + Sync>() {}

    assert_send_sync::<api::Config>();
    assert_send_sync::<api::Baseline>();
    assert_send_sync::<api::AnalysisOutcome>();
    assert_send_sync::<api::Error>();
    assert_send_sync::<api::DatabaseUrl>();
    assert_send_sync::<api::CacheKey>();
}

#[test]
fn public_secret_inputs_are_validated_and_redacted() {
    let secret_url = "postgres://private-user:private-password@localhost/app";
    let database_url = api::DatabaseUrl::new(secret_url).unwrap();
    let database_debug = format!("{database_url:?}");
    assert!(!database_debug.contains("private-user"));
    assert!(!database_debug.contains("private-password"));

    let secret_key = "42".repeat(32);
    let cache_key = api::CacheKey::from_hex(&secret_key).unwrap();
    assert!(!format!("{cache_key:?}").contains(&secret_key));
    assert_eq!(
        format!("{:?}", api::CacheKey::from_bytes([7; 32])),
        "CacheKey([REDACTED])"
    );

    let remote = api::DatabaseUrl::new("postgres://db.example.com/app").unwrap_err();
    assert_eq!(remote.kind(), api::ErrorKind::Configuration);
    assert!(api::CacheKey::from_hex("not-a-key").is_err());
}

#[test]
fn explicit_sync_secrets_must_match_encryption_configuration() {
    let database_url = api::DatabaseUrl::new("postgres://localhost/app").unwrap();
    let cache_key = api::CacheKey::from_hex(&"42".repeat(32)).unwrap();
    let output = tempfile::tempdir().unwrap().path().join("baseline.cache");

    let missing_key = api::sync_with_secrets(
        &output,
        &api::Config::default().with_cache_encryption(true),
        None,
        &database_url,
        None,
    )
    .unwrap_err();
    assert_eq!(missing_key.kind(), api::ErrorKind::Configuration);

    let unexpected_key = api::sync_with_secrets(
        &output,
        &api::Config::default().with_cache_encryption(false),
        None,
        &database_url,
        Some(&cache_key),
    )
    .unwrap_err();
    assert_eq!(unexpected_key.kind(), api::ErrorKind::Configuration);

    let missing_cache = tempfile::tempdir().unwrap().path().join("missing.cache");
    let invalid_optional_load = api::Baseline::load_optional_with_key(
        &missing_cache,
        &api::Config::default().with_cache_encryption(false),
        &cache_key,
    )
    .unwrap_err();
    assert_eq!(invalid_optional_load.kind(), api::ErrorKind::Configuration);

    let optional = api::Baseline::load_optional_with_key(
        &missing_cache,
        &api::Config::default().with_cache_encryption(true),
        &cache_key,
    )
    .unwrap();
    assert!(!optional.is_available());
}

#[test]
fn unsafe_conservative_defaults_are_rejected_at_the_api_boundary() {
    let zero_rows = api::Config::default().with_default_rows(0);
    assert_eq!(
        zero_rows.validate().unwrap_err().kind(),
        api::ErrorKind::Configuration
    );

    let invalid_width = api::Config::default().with_toast_width_threshold_bytes(0);
    assert_eq!(
        invalid_width.validate().unwrap_err().kind(),
        api::ErrorKind::Configuration
    );

    assert!(
        api::Config::default()
            .with_assumed_postgres_version(140_000)
            .validate()
            .is_ok()
    );
    assert!(
        api::Config::default()
            .with_assumed_postgres_version(180_999)
            .validate()
            .is_ok()
    );
    for unsupported in [0, 130_999, 181_000, u32::MAX] {
        assert_eq!(
            api::Config::default()
                .with_assumed_postgres_version(unsupported)
                .validate()
                .unwrap_err()
                .kind(),
            api::ErrorKind::Configuration,
            "unsupported assumed PostgreSQL version {unsupported} was accepted"
        );
    }
}