tencrypt-core 0.1.0

Core types and state-machine logic for tencrypt certificate workflows
Documentation
//! AAA-3 integration tests.
//!
//! These tests exercise the public API of tencrypt-core from the outside
//! (integration test binary), mirroring what a calling service would do.
//! They verify three high-assurance properties:
//!
//!   1. **Reconcile idempotency** — calling `reconcile_step` with the same
//!      `CertState` any number of times always returns the same decision.
//!   2. **Full issuance workflow** — `dry_run_issue` walks every expected
//!      state transition and terminates at `Issued`.
//!   3. **Policy resolution determinism** — `resolve_backoff` + jittered
//!      backoff always return values within their declared bounds regardless
//!      of input ordering or repetition.

use tencrypt_core::{
    cert_state_from_str, cert_state_names, dry_run_issue, new_request, next_backoff_delay,
    next_backoff_delay_jittered, reconcile_step, resolve_backoff, BackoffConfig, BackoffState,
    CertState, DomainPolicy, ReconcileDecision,
};

// ── 1. Reconcile idempotency ──────────────────────────────────────────────────

#[test]
fn reconcile_every_state_is_idempotent() {
    // For every named state, calling reconcile_step twice must return
    // identical results.
    for name in cert_state_names() {
        let state = cert_state_from_str(name)
            .unwrap_or_else(|| panic!("cert_state_from_str failed for {:?}", name));
        let first = reconcile_step(state);
        let second = reconcile_step(state);
        assert_eq!(
            first, second,
            "reconcile_step({:?}) is not idempotent",
            name
        );
    }
}

#[test]
fn reconcile_all_states_covered() {
    // Every state produces one of the four known decisions — no panics, no
    // missing match arms.
    let valid_decisions = [
        ReconcileDecision::Proceed,
        ReconcileDecision::Wait,
        ReconcileDecision::Done,
        ReconcileDecision::RetryOrAbandon,
    ];
    for name in cert_state_names() {
        let state = cert_state_from_str(name).unwrap();
        let decision = reconcile_step(state);
        assert!(
            valid_decisions.contains(&decision),
            "unexpected decision {:?} for state {:?}",
            decision,
            name
        );
    }
}

#[test]
fn reconcile_terminal_states_do_not_proceed() {
    // `Issued` and `Revoked` are terminal — reconciler must never return
    // `Proceed` for them.
    assert_ne!(
        reconcile_step(CertState::Issued),
        ReconcileDecision::Proceed
    );
    assert_ne!(
        reconcile_step(CertState::Revoked),
        ReconcileDecision::Proceed
    );
}

#[test]
fn reconcile_failed_never_returns_done() {
    // A failed cert must not silently appear done — it needs explicit handling.
    assert_ne!(reconcile_step(CertState::Failed), ReconcileDecision::Done);
}

// ── 2. Full issuance workflow ─────────────────────────────────────────────────

#[test]
fn full_dry_run_workflow_reaches_issued() {
    let req = new_request(
        "app.example.com".to_string(),
        "integration-test".to_string(),
    );
    let report = dry_run_issue(&req).expect("dry_run_issue must succeed for valid hostname");

    assert_eq!(report.hostname, "app.example.com");
    assert_eq!(report.status, "success");
    assert!(
        !report.transitions.is_empty(),
        "expected at least one transition"
    );

    let last = report.transitions.last().unwrap();
    assert_eq!(
        last.to,
        CertState::Issued,
        "final transition must reach Issued"
    );
}

#[test]
fn dry_run_expected_transition_sequence() {
    let req = new_request(
        "seq.example.com".to_string(),
        "integration-test".to_string(),
    );
    let report = dry_run_issue(&req).unwrap();

    // Transitions must start from Requested.
    assert_eq!(report.transitions[0].from, CertState::Requested);

    // Each transition's `to` must equal the next transition's `from`
    // (no skipped states).
    for window in report.transitions.windows(2) {
        assert_eq!(
            window[0].to, window[1].from,
            "transition chain is broken between {:?} and {:?}",
            window[0].to, window[1].from
        );
    }
}

#[test]
fn dry_run_rejects_invalid_hostname() {
    let req = new_request("localhost".to_string(), "integration-test".to_string());
    assert!(
        dry_run_issue(&req).is_err(),
        "dry_run_issue must reject hostname without a dot"
    );
}

// ── 3. Policy resolution determinism ─────────────────────────────────────────

#[test]
fn resolve_backoff_is_deterministic_across_calls() {
    let default = BackoffConfig::default();
    let policies = vec![
        DomainPolicy {
            hostname_pattern: "a.example.com".to_string(),
            backoff: Some(BackoffConfig {
                max_retries: 2,
                base_delay_ms: 200,
                max_delay_ms: 4_000,
            }),
        },
        DomainPolicy {
            hostname_pattern: "b.example.com".to_string(),
            backoff: None,
        },
    ];

    for hostname in ["a.example.com", "b.example.com", "unknown.example.com"] {
        let first = resolve_backoff(hostname, &policies, &default);
        let second = resolve_backoff(hostname, &policies, &default);
        assert_eq!(
            first.max_retries, second.max_retries,
            "resolve_backoff is not deterministic for {:?}",
            hostname
        );
    }
}

#[test]
fn jittered_backoff_never_exceeds_max_delay() {
    // Property: for every (attempt, jitter) combination the returned delay
    // must not exceed max_delay_ms.
    let cfg = BackoffConfig {
        max_retries: 10,
        base_delay_ms: 100,
        max_delay_ms: 5_000,
    };
    for attempt in 0..cfg.max_retries {
        for jitter_ms in [0u64, 100, 1_000, 10_000, u64::MAX / 2] {
            let state = BackoffState { attempt };
            if let Some(d) = next_backoff_delay_jittered(&cfg, &state, jitter_ms) {
                assert!(
                    d.as_millis() <= cfg.max_delay_ms as u128,
                    "jittered delay {} ms exceeded max_delay_ms {} for attempt={} jitter={}",
                    d.as_millis(),
                    cfg.max_delay_ms,
                    attempt,
                    jitter_ms
                );
            }
        }
    }
}

#[test]
fn plain_backoff_never_exceeds_max_delay() {
    let cfg = BackoffConfig {
        max_retries: 10,
        base_delay_ms: 1_000,
        max_delay_ms: 8_000,
    };
    for attempt in 0..cfg.max_retries {
        let state = BackoffState { attempt };
        if let Some(d) = next_backoff_delay(&cfg, &state) {
            assert!(
                d.as_millis() <= cfg.max_delay_ms as u128,
                "delay {} ms exceeded max_delay_ms {}",
                d.as_millis(),
                cfg.max_delay_ms
            );
        }
    }
}

#[test]
fn backoff_exhausted_at_exactly_max_retries() {
    let cfg = BackoffConfig {
        max_retries: 3,
        base_delay_ms: 1_000,
        max_delay_ms: 60_000,
    };
    // Attempt 2 (0-indexed) is the last allowed retry.
    assert!(next_backoff_delay(&cfg, &BackoffState { attempt: 2 }).is_some());
    // Attempt 3 is exhausted.
    assert!(next_backoff_delay(&cfg, &BackoffState { attempt: 3 }).is_none());
}

#[test]
fn cert_state_names_round_trip() {
    // Every name returned by cert_state_names must successfully round-trip
    // through cert_state_from_str.
    for name in cert_state_names() {
        let state = cert_state_from_str(name);
        assert!(
            state.is_some(),
            "cert_state_from_str returned None for {:?}",
            name
        );
    }
}