holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
//! End-to-end coverage for the retention/GC **executor** — `Holger::execute_repo_retention`
//! (and the plan-then-execute convenience `run_repo_retention`). The planner's
//! algebra is pinned in `holger_server_lib::retention`; the adapter (list → plan)
//! in `retention_adapter.rs`. This file proves the *acting* side:
//!
//! * dry-run (the default `GcOptions`) reclaims **nothing** — the store is intact;
//! * an executed plan drops **exactly** the plan's delete-set and keeps the rest;
//! * a protected/latest generation is **never** dropped (even if a corrupted plan
//!   flags it delete — defense in depth);
//! * every actual reclaim is **historized** to the audit warehouse.

use std::sync::Arc;

use holger_server_lib::audit::{AuditAction, MemoryAuditLog};
use holger_server_lib::retention::{
    ArtifactRecord, GcOptions, GcOutcome, RetentionDecision, RetentionPlan, RetentionPolicy,
    RetentionReason,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use traits::ArtifactId;

/// A live, writable file-backed `rust` repo in a temp dir, plus the temp guard
/// (dropped by the caller to clean up).
fn live_repo() -> (Holger, tempfile::TempDir) {
    let dir = tempfile::tempdir().unwrap();
    let d = dir.path().display();
    let ron = format!(
        r#"(
    exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
    storage_endpoints: [ ( ron_name: "inert", ron_storage_type: "znippy", ron_path: "{d}/inert/" ) ],
    repositories: [
        ( ron_name: "rust-dev", ron_repo_type: "rust", ron_data_dir: Some("{d}/rust-dev"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: Some(( ron_storage_endpoint: "inert", ron_exposed_endpoint: "dev" )) ),
    ],
)
"#
    );
    use std::io::Write;
    let mut f = tempfile::NamedTempFile::new().unwrap();
    f.write_all(ron.as_bytes()).unwrap();
    let mut holger = read_ron_config(f.path()).expect("RON must parse");
    holger.instantiate_backends().expect("backend builds");
    wire_holger(&mut holger).expect("wires");
    (holger, dir)
}

fn id(name: &str, version: &str) -> ArtifactId {
    ArtifactId {
        namespace: Some("crates".into()),
        name: name.into(),
        version: version.into(),
    }
}

/// Seed five serde versions; return the Holger.
fn seed_serde(holger: &Holger) {
    for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
        holger
            .put("rust-dev", &id("serde", v), format!("serde-{v}").as_bytes())
            .unwrap();
    }
}

#[test]
fn dry_run_reclaims_nothing() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    let policy = RetentionPolicy::new().keep_last(2); // would reclaim 3 oldest
    let audit = MemoryAuditLog::new();
    let report = holger
        .run_repo_retention("rust-dev", &policy, 1_700_000_000, &GcOptions::dry_run(), &audit, None)
        .expect("dry-run executes");

    assert!(report.dry_run);
    assert_eq!(report.deleted_count, 0, "dry-run deletes nothing");
    assert_eq!(report.reclaimed_bytes, 0);
    assert_eq!(report.would_delete_count, 3, "but reports it WOULD reclaim 3");
    assert!(report.actions.iter().all(|a| matches!(a.outcome, GcOutcome::WouldDelete)
        || !a.reasons.contains(&RetentionReason::SupersededByNewer)));

    // Every seeded version is still on disk.
    for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
            "dry-run must leave {v} in the store"
        );
    }
    // Nothing historized (nothing reclaimed).
    assert!(audit.events().is_empty(), "dry-run historizes no delete");
}

#[test]
fn execute_drops_exactly_the_delete_set_and_keeps_the_rest() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    // keep_last 2 → keep {2.0.0, 1.10.0}, reclaim {1.2.0, 1.1.0, 1.0.0}.
    let policy = RetentionPolicy::new().keep_last(2);
    let audit = MemoryAuditLog::new();
    let plan = holger
        .plan_repo_retention("rust-dev", &policy, 1_700_000_000)
        .unwrap();
    let report = holger
        .execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
        .expect("execute");

    assert!(!report.dry_run);
    assert_eq!(report.deleted_count, 3);
    assert_eq!(report.failed_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(report.reclaimed_bytes > 0);

    // The delete-set is gone; the kept set survives.
    for v in ["1.0.0", "1.1.0", "1.2.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_none(),
            "{v} must be reclaimed"
        );
    }
    for v in ["1.10.0", "2.0.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
            "{v} (kept) must survive"
        );
    }

    // Historized: exactly one Delete audit event per reclaimed artifact.
    let evs = audit.events();
    assert_eq!(evs.len(), 3, "one GC audit record per reclaim");
    assert!(evs.iter().all(|e| e.action == AuditAction::Delete));
    assert!(evs.iter().all(|e| e.repo == "rust-dev"));
    assert!(evs.iter().all(|e| e.detail.contains("retention-gc")));
}

#[test]
fn execute_never_drops_a_protected_latest() {
    let (holger, _guard) = live_repo();
    // A single version: keep_last 1 with protection ON keeps it; nothing to drop.
    holger.put("rust-dev", &id("serde", "1.0.0"), b"only-one").unwrap();

    let policy = RetentionPolicy::new().keep_last(1); // protect_latest defaults ON
    let audit = MemoryAuditLog::new();
    let report = holger
        .run_repo_retention("rust-dev", &policy, 1_700_000_000, &GcOptions::execute(), &audit, None)
        .unwrap();

    assert_eq!(report.deleted_count, 0, "the lone latest is protected");
    assert!(
        holger.fetch("rust-dev", &id("serde", "1.0.0")).unwrap().is_some(),
        "protected latest survives execute"
    );
}

/// Defense in depth: even a hand-built plan that WRONGLY marks a protected-latest
/// record for deletion must be refused by the executor — a protected generation
/// can never be dropped through this path.
#[test]
fn executor_refuses_a_delete_flag_on_a_protected_record() {
    let (holger, _guard) = live_repo();
    holger.put("rust-dev", &id("serde", "1.0.0"), b"precious").unwrap();

    // Forge a plan: delete=true but reasons say ProtectedLatest (a corrupt plan).
    let rec = ArtifactRecord {
        id: id("serde", "1.0.0"),
        size_bytes: 8,
        created_at: None,
        protected_by_property: false,
    };
    let plan = RetentionPlan {
        decisions: vec![RetentionDecision {
            record: rec,
            delete: true,
            reasons: vec![RetentionReason::ProtectedLatest],
        }],
        kept_count: 0,
        delete_count: 1,
        kept_bytes: 0,
        reclaimed_bytes: 8,
        name_count: 1,
        evaluated_at: 1_700_000_000,
    };

    let audit = MemoryAuditLog::new();
    let report = holger
        .execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
        .unwrap();

    assert_eq!(report.deleted_count, 0, "protected record must not be dropped");
    assert_eq!(report.skipped_count, 1);
    assert!(matches!(report.actions[0].outcome, GcOutcome::Skipped(_)));
    assert!(
        holger.fetch("rust-dev", &id("serde", "1.0.0")).unwrap().is_some(),
        "the protected artifact must survive a corrupt plan"
    );
    assert!(audit.events().is_empty());
}

/// The GC run is routed through the shared jera job ledger when one is attached
/// (the same durable seam promotion uses): a `retention-gc` job is recorded.
#[test]
fn execute_records_a_jera_job_when_a_ledger_is_attached() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    let led_dir = tempfile::tempdir().unwrap();
    let jobs = Arc::new(jera::jobs::JobStore::open(led_dir.path()).expect("open jera ledger"));

    let policy = RetentionPolicy::new().keep_last(2);
    let audit = MemoryAuditLog::new();
    holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::execute(),
            &audit,
            Some(&jobs),
        )
        .expect("execute with ledger");

    let recorded = jobs
        .list(&jera::jobs::JobSelector::Kind("retention-gc".into()))
        .expect("list jobs");
    assert_eq!(recorded.len(), 1, "one retention-gc job recorded");
    assert!(recorded[0].is_terminal(), "the GC job settled");
}