envseal 0.3.14

Write-only secret vault with process-level access control — post-agent secret management
//! End-to-end lifecycle regression: fresh vault → store → list →
//! decrypt → audit → revoke → list, with no platform GUI calls.
//!
//! Catches the bug class that bit us during the v0.3.0 hardening
//! pass — every one of the following was found by manually driving
//! the CLI and would have been a unit-test catch:
//!
//! - Audit log rotates on hash-chain mismatch instead of bricking
//!   the vault. The dedicated `audit_rotates_on_corruption.rs`
//!   regression already covers the rotation primitive in isolation;
//!   this file proves the rotation works *during* a real lifecycle
//!   call sequence, not just under direct `audit::log_at` calls.
//! - `store_secret_in` returns the new `Vec<Signal>` shape (was
//!   `Vec<String>`). The Signal carries `id`, `severity`, `label`,
//!   `detail`, `mitigation` instead of a pre-formatted string.
//! - The audit log has exactly the events the lifecycle is supposed
//!   to emit, in the right order — no double-logging, no missing
//!   `SecretRevoked`.
//! - Revoking removes the .seal file AND records the
//!   `SecretRevoked` event in the same chain.
//! - The chain stays valid across multiple writes (regression
//!   against accidental schema drift).

use envseal::ops;
use envseal::vault::Vault;
use std::path::PathBuf;
use zeroize::Zeroizing;

/// Pick a vault-safe scratch directory. On Linux/macOS, envseal hard-blocks
/// vault roots under `/tmp` (the parent is world-writable, which is a
/// poisoning vector), so we route through `~/.cache/envseal-core-tests/`
/// the same way `common::vault_tempdir()` does. On Windows, the OS-default
/// tempfile location is already user-private, so we use it directly. The
/// 0o700 perm is set on the leaf so a parallel test can't read this
/// run's vault state.
fn temp_root(name: &str) -> PathBuf {
    #[cfg(unix)]
    let base = {
        let home =
            std::env::var_os("HOME").expect("tests require HOME (vault-safe temp directories)");
        let b = PathBuf::from(home)
            .join(".cache")
            .join("envseal-core-tests");
        std::fs::create_dir_all(&b).unwrap();
        b
    };
    #[cfg(not(unix))]
    let base = std::env::temp_dir();

    let dir = base.join(format!(
        "envseal-e2e-{}-{}-{}",
        name,
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
    }
    dir
}

#[test]
fn full_lifecycle_store_list_audit_revoke() {
    let root = temp_root("full_lifecycle");
    let pass = Zeroizing::new("integration-test-passphrase".to_string());

    // ── 1. Create the vault from a fresh root ─────────────────────
    let vault = Vault::open_with_passphrase(&root, &pass).expect("vault create");
    assert!(root.join("master.key").exists(), "master.key not written");

    // ── 2. Store three secrets with distinct shapes ───────────────
    // - one real-looking key (no entropy warnings expected)
    // - one obvious placeholder (placeholder Signal expected)
    // - one short value (too_short Signal expected)
    let signals_real = ops::store_secret_in(
        &vault,
        "real-key",
        b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890",
        false,
    )
    .expect("store real-key");
    let placeholder_signals =
        ops::store_secret_in(&vault, "placeholder-key", b"your-api-key-here", false)
            .expect("store placeholder-key");
    let short_signals =
        ops::store_secret_in(&vault, "short-key", b"abc", false).expect("store short-key");

    // The Signal vec contract: each element has a stable id under
    // `secret.entropy.*`. A real key produces no signals; a
    // placeholder produces a placeholder signal; a 3-byte value
    // produces too_short.
    assert!(
        !signals_real
            .iter()
            .any(|s| s.id.as_str().starts_with("secret.entropy.placeholder.")),
        "real key wrongly flagged as placeholder"
    );
    assert!(
        placeholder_signals
            .iter()
            .any(|s| s.id.as_str().starts_with("secret.entropy.placeholder.")),
        "placeholder value missed by entropy check"
    );
    assert!(
        short_signals
            .iter()
            .any(|s| s.id.as_str() == "secret.entropy.too_short"),
        "short value missed by entropy check"
    );

    // ── 3. List returns all three ─────────────────────────────────
    let names = ops::list_secret_names(&root).expect("list");
    assert_eq!(names.len(), 3, "expected 3 secrets, got {}", names.len());
    assert!(names.iter().any(|n| n == "real-key"));
    assert!(names.iter().any(|n| n == "placeholder-key"));
    assert!(names.iter().any(|n| n == "short-key"));

    // ── 4. Decrypt round-trips ────────────────────────────────────
    let plaintext = vault.decrypt("real-key").expect("decrypt real-key");
    assert_eq!(
        plaintext.as_slice(),
        b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890",
        "decrypted plaintext doesn't match what was stored"
    );

    // ── 5. Revoke one ─────────────────────────────────────────────
    ops::revoke_with_policy(&vault, "placeholder-key").expect("revoke placeholder-key");
    let names_after = ops::list_secret_names(&root).expect("list after revoke");
    assert_eq!(names_after.len(), 2);
    assert!(!names_after.iter().any(|n| n == "placeholder-key"));
    // Per-vault file is gone.
    assert!(!root.join("vault").join("placeholder-key.seal").exists());

    // ── 6. Audit chain has the right events in the right order ───
    // Event bodies are AES-GCM-sealed since 0.3.13; check via the
    // parsed reader (which decrypts under the per-vault audit key)
    // rather than substring-grep on the raw log.
    let parsed_audit = envseal::audit::read_last_parsed_at(&root, 50);
    let stored_count = parsed_audit
        .entries
        .iter()
        .filter(|p| matches!(p.event, envseal::audit::AuditEvent::SecretStored { .. }))
        .count();
    let revoked_count = parsed_audit
        .entries
        .iter()
        .filter(|p| matches!(p.event, envseal::audit::AuditEvent::SecretRevoked { .. }))
        .count();
    assert_eq!(
        stored_count, 3,
        "expected 3 stored events, got {stored_count}"
    );
    assert_eq!(
        revoked_count, 1,
        "expected 1 revoked event, got {revoked_count}"
    );

    // ── 7. Audit chain still verifies as a whole — no schema drift ─
    // verify_chain_if_exists is internal; we exercise it indirectly
    // by appending one more event and asserting it doesn't rotate
    // (rotation creates a corrupted-* sibling).
    ops::store_secret_in(&vault, "post-audit-check", b"sk-final-abc1234567", false)
        .expect("post-audit store");
    let corrupted_files: Vec<_> = std::fs::read_dir(&root)
        .unwrap()
        .filter_map(Result::ok)
        .filter(|e| {
            e.file_name()
                .to_string_lossy()
                .starts_with("audit.log.corrupted-")
        })
        .collect();
    assert!(
        corrupted_files.is_empty(),
        "audit chain rotated mid-test — schema drift in the chain hash"
    );

    let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn second_unlock_after_close_round_trips() {
    // Ensures the "open → drop → reopen with same passphrase" flow
    // every CLI invocation actually does (each `envseal …` is a
    // fresh process) doesn't subtly diverge from the in-process
    // create flow.
    let root = temp_root("reopen_round_trip");
    let pass = Zeroizing::new("a-stable-passphrase-9".to_string());

    {
        let vault = Vault::open_with_passphrase(&root, &pass).expect("create");
        let _ = ops::store_secret_in(&vault, "first-key", b"sk-test-aaaaaaaaaa1234", false)
            .expect("first store");
    }

    let vault2 = Vault::open_with_passphrase(&root, &pass).expect("reopen");
    let pt = vault2.decrypt("first-key").expect("decrypt after reopen");
    assert_eq!(pt.as_slice(), b"sk-test-aaaaaaaaaa1234");

    let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn wrong_passphrase_does_not_modify_vault() {
    let root = temp_root("wrong_pass_no_corruption");
    let good = Zeroizing::new("correct-pass-abcdefgh".to_string());
    let bad = Zeroizing::new("wrong-pass-zzzzzzzz".to_string());

    {
        let v = Vault::open_with_passphrase(&root, &good).expect("create");
        ops::store_secret_in(&v, "kept-key", b"sk-keep-abcdefghij", false).expect("store");
    }

    // Wrong passphrase fails cleanly.
    let err = Vault::open_with_passphrase(&root, &bad).expect_err("wrong passphrase must fail");
    let msg = err.to_string().to_lowercase();
    assert!(
        msg.contains("wrong passphrase") || msg.contains("decryption failed"),
        "expected wrong-passphrase error, got: {msg}"
    );

    // The vault is still openable with the right one and the secret
    // is still there — no side-effect from the failed unlock attempt.
    let v2 = Vault::open_with_passphrase(&root, &good).expect("reopen with correct");
    let pt = v2
        .decrypt("kept-key")
        .expect("decrypt after wrong-pass attempt");
    assert_eq!(pt.as_slice(), b"sk-keep-abcdefghij");

    let _ = std::fs::remove_dir_all(&root);
}