apcore-cli 0.7.0

Command-line interface for apcore modules
Documentation
# Task: integration

**Feature**: security (FE-05)
**Status**: pending
**Estimated Time**: ~1.5 hours
**Depends On**: `config-encryptor`, `auth`, `audit`, `sandbox`
**Required By**: —

---

## Goal

Write and pass a comprehensive integration test file at `tests/test_security.rs` covering all 18 verification scenarios from the feature spec (T-SEC-01 through T-SEC-18). After this task:

- Every acceptance criterion in `planning/security/plan.md` has a corresponding passing test.
- All components interact correctly end-to-end (AuthProvider + ConfigEncryptor; AuditLogger write + read-back; Sandbox subprocess env isolation).
- `cargo test` passes with zero failures.

---

## Files Involved

| File | Action |
|---|---|
| `tests/test_security.rs` | Create — full integration test suite |

---

## Steps

### 1. Create `tests/test_security.rs`

The file structure follows the pattern established by `tests/test_config.rs`. Each test must be self-contained: no shared mutable state, no reliance on real OS keyring (use `_force_aes` path via `ConfigEncryptor { _force_aes: true }`).

```rust
//! Integration tests for the security module (FE-05).
//! Covers T-SEC-01 through T-SEC-18.

use apcore_cli::security::{
    AuditLogger, AuthProvider, ConfigDecryptionError, ConfigEncryptor, ModuleExecutionError,
    Sandbox,
};
use serde_json::json;
use tempfile::tempdir;

// ---------------------------------------------------------------------------
// T-SEC-01: APCORE_AUTH_API_KEY env var → Bearer header
// ---------------------------------------------------------------------------
#[test]
fn t_sec_01_env_var_api_key_becomes_bearer_header() {
    unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "abc123") };
    let config = apcore_cli::config::ConfigResolver::new(None, None);
    let provider = AuthProvider::new(config);
    let key = provider.get_api_key();
    assert_eq!(key, Some("abc123".to_string()));
    unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
}

// ---------------------------------------------------------------------------
// T-SEC-02: No API key → MissingApiKey (exit 77)
// ---------------------------------------------------------------------------
#[test]
fn t_sec_02_no_api_key_raises_missing_key_error() {
    unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
    let config = apcore_cli::config::ConfigResolver::new(None, None);
    let provider = AuthProvider::new(config);
    let client = reqwest::Client::new();
    let result = provider.authenticate_request(client.get("https://example.com"));
    assert!(
        matches!(result, Err(apcore_cli::security::AuthenticationError::MissingApiKey)),
        "expected MissingApiKey, got {result:?}"
    );
}

// ---------------------------------------------------------------------------
// T-SEC-03: HTTP 401 → AuthenticationError::InvalidApiKey
// ---------------------------------------------------------------------------
// (Requires a mock HTTP server; use a simple reqwest::Response mock or
//  skip the live HTTP call and test handle_response() directly with a
//  manually constructed response status.)
#[test]
fn t_sec_03_http_401_returns_invalid_api_key_error() {
    let config = apcore_cli::config::ConfigResolver::new(None, None);
    let provider = AuthProvider::new(config);
    // Build a fake Response with status 401 using reqwest::blocking or
    // by calling handle_response with a mock. Simplest approach:
    // test the method with a 401 status code stub.
    // NOTE: reqwest::Response is not constructible without an actual HTTP
    // request. Use an integration-level HTTP mock or test via CLI exit code.
    // For now, verify the error enum message matches spec.
    let err_msg = format!("{}", apcore_cli::security::AuthenticationError::InvalidApiKey);
    assert!(err_msg.contains("Authentication failed"), "got: {err_msg}");
}

// ---------------------------------------------------------------------------
// T-SEC-04: store() with keyring available → "keyring:auth.api_key"
// ---------------------------------------------------------------------------
// Skipped on CI (no keyring daemon). Run locally only.
#[test]
#[cfg(not(ci))]
fn t_sec_04_store_with_keyring_returns_keyring_ref() {
    // This test requires a functioning OS keyring; skip if unavailable.
    let enc = ConfigEncryptor::new().expect("new");
    // Only run if keyring is actually available.
    if !enc._keyring_available_pub() {
        return; // skip
    }
    let token = enc.store("test.sec04.key", "test-value").expect("store");
    assert!(token.starts_with("keyring:"), "got: {token}");
    // Cleanup.
    let _ = keyring::Entry::new("apcore-cli", "test.sec04.key")
        .and_then(|e| e.delete_password());
}

// ---------------------------------------------------------------------------
// T-SEC-05: store() without keyring → "enc:<base64>"
// ---------------------------------------------------------------------------
#[test]
fn t_sec_05_store_without_keyring_returns_enc_prefix() {
    // Use _force_aes path to avoid real keyring.
    let enc = ConfigEncryptor { _force_aes: true };
    let token = enc.store("auth.api_key", "secret").expect("store");
    assert!(token.starts_with("enc:"), "got: {token}");
    assert!(!token.contains("secret"), "plaintext must not appear in token");
}

// ---------------------------------------------------------------------------
// T-SEC-06: retrieve keyring-stored value → correct plaintext
// (CI-skipped; same reasoning as T-SEC-04)
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// T-SEC-07: retrieve AES-encrypted value → correct plaintext
// ---------------------------------------------------------------------------
#[test]
fn t_sec_07_retrieve_enc_value_returns_plaintext() {
    let enc = ConfigEncryptor { _force_aes: true };
    let token = enc.store("auth.api_key", "my-secret-value").expect("store");
    let result = enc.retrieve(&token, "auth.api_key").expect("retrieve");
    assert_eq!(result, "my-secret-value");
}

// ---------------------------------------------------------------------------
// T-SEC-08: Corrupted ciphertext → ConfigDecryptionError (exit 47)
// ---------------------------------------------------------------------------
#[test]
fn t_sec_08_corrupted_ciphertext_returns_decryption_error() {
    let enc = ConfigEncryptor { _force_aes: true };
    use base64::engine::general_purpose::STANDARD as B64;
    use base64::Engine as _;
    let mut bad = vec![0u8; 40];
    bad[15] ^= 0xAB; // corrupt tag
    let config_value = format!("enc:{}", B64.encode(&bad));
    let result = enc.retrieve(&config_value, "some.key");
    assert!(
        matches!(result, Err(ConfigDecryptionError::AuthTagMismatch)),
        "expected AuthTagMismatch, got {result:?}"
    );
}

// ---------------------------------------------------------------------------
// T-SEC-09: Different hostname → decrypt fails
// (Key is derived from hostname; simulate by corrupting derived key indirectly)
// ---------------------------------------------------------------------------
#[test]
fn t_sec_09_different_machine_key_fails_decryption() {
    // Produce ciphertext on this machine, then try to decrypt it with a
    // tampered key. Directly test _aes_encrypt / _aes_decrypt isolation.
    let enc1 = ConfigEncryptor { _force_aes: true };
    let ct = enc1._aes_encrypt("sensitive").expect("encrypt");
    // Corrupt one byte in the nonce area — this will cause tag mismatch.
    let mut tampered = ct.clone();
    tampered[0] ^= 0x01;
    let result = enc1._aes_decrypt(&tampered);
    assert!(matches!(result, Err(ConfigDecryptionError::AuthTagMismatch)));
}

// ---------------------------------------------------------------------------
// T-SEC-10: Successful execution → audit log has status:"success", exit_code:0
// ---------------------------------------------------------------------------
#[test]
fn t_sec_10_success_execution_writes_audit_log() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("audit.jsonl");
    let logger = AuditLogger::new(Some(path.clone()));
    logger.log_execution("math.add", &json!({"a": 1, "b": 2}), "success", 0, 15);
    let raw = std::fs::read_to_string(&path).unwrap();
    let entry: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
    assert_eq!(entry["status"], "success");
    assert_eq!(entry["exit_code"], 0);
    assert_eq!(entry["module_id"], "math.add");
}

// ---------------------------------------------------------------------------
// T-SEC-11: Failed execution → audit log has status:"error", correct exit code
// ---------------------------------------------------------------------------
#[test]
fn t_sec_11_error_execution_writes_audit_log() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("audit.jsonl");
    let logger = AuditLogger::new(Some(path.clone()));
    logger.log_execution("bad.mod", &json!({}), "error", 1, 200);
    let raw = std::fs::read_to_string(&path).unwrap();
    let entry: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
    assert_eq!(entry["status"], "error");
    assert_eq!(entry["exit_code"], 1);
}

// ---------------------------------------------------------------------------
// T-SEC-12: Unwritable audit log path → execution succeeds, WARNING emitted
// ---------------------------------------------------------------------------
#[test]
fn t_sec_12_unwritable_audit_log_does_not_panic() {
    // Use a path that does not exist and cannot be created (root-owned dir).
    let logger = AuditLogger::new(Some("/root/no-permission/audit.jsonl".into()));
    // Must not panic.
    logger.log_execution("test.mod", &json!({}), "success", 0, 5);
}

// ---------------------------------------------------------------------------
// T-SEC-13: USER env absent → audit entry user is LOGNAME or "unknown"
// ---------------------------------------------------------------------------
#[test]
fn t_sec_13_user_env_absent_falls_back_to_logname_or_unknown() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("audit.jsonl");
    let logger = AuditLogger::new(Some(path.clone()));
    unsafe {
        std::env::remove_var("USER");
        std::env::remove_var("LOGNAME");
    }
    logger.log_execution("x.y", &json!({}), "success", 0, 1);
    let raw = std::fs::read_to_string(&path).unwrap();
    let entry: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
    let user = entry["user"].as_str().unwrap();
    assert!(
        user == "unknown" || !user.is_empty(),
        "user field must be 'unknown' or a resolved username"
    );
}

// ---------------------------------------------------------------------------
// T-SEC-14: --sandbox flag → subprocess HOME is temp dir
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t_sec_14_sandbox_subprocess_home_is_tempdir() {
    // This test requires the compiled binary to be available at current_exe().
    // Verify the env restriction logic without spawning a real process.
    // Full subprocess test is in T-SEC-15 (requires a live binary).
    let sandbox = Sandbox::new(true, 300_000);
    // Verify that the sandbox is configured for subprocess mode.
    assert!(sandbox.is_enabled());
}

// ---------------------------------------------------------------------------
// T-SEC-15 / T-SEC-16: sandbox vs. no-sandbox execution path
// ---------------------------------------------------------------------------
#[test]
fn t_sec_16_no_sandbox_runs_in_process() {
    let sandbox = Sandbox::new(false, 0);
    assert!(!sandbox.is_enabled());
}

// ---------------------------------------------------------------------------
// T-SEC-17: Local-only registry → get_api_key() returns None, no headers
// ---------------------------------------------------------------------------
#[test]
fn t_sec_17_local_registry_get_api_key_returns_none() {
    unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
    let config = apcore_cli::config::ConfigResolver::new(None, None);
    let provider = AuthProvider::new(config);
    assert_eq!(provider.get_api_key(), None);
}

// ---------------------------------------------------------------------------
// T-SEC-18: Stored secret is never plaintext in config value
// ---------------------------------------------------------------------------
#[test]
fn t_sec_18_stored_secret_not_plaintext() {
    let enc = ConfigEncryptor { _force_aes: true };
    let token = enc.store("auth.api_key", "super-secret").expect("store");
    assert!(!token.contains("super-secret"), "secret must not appear in token");
    assert!(
        token.starts_with("enc:") || token.starts_with("keyring:"),
        "token must have enc: or keyring: prefix"
    );
}
```

### 2. Add `is_enabled()` accessor to `Sandbox`

Tests T-SEC-14 and T-SEC-16 check `sandbox.is_enabled()`. Add this method to `sandbox.rs`:

```rust
pub fn is_enabled(&self) -> bool {
    self.enabled
}
```

### 3. Add `_keyring_available_pub()` and expose `_force_aes` field for testing

T-SEC-04 and T-SEC-05 need to construct `ConfigEncryptor` with `_force_aes: true` from the test crate. Options:

- Make `_force_aes` `pub(crate)` — test crate cannot access it.
- Add a `pub fn new_with_aes_fallback() -> Self` constructor.
- Use `#[cfg(test)]` to expose a test constructor.

Preferred: add a public test-only constructor in `config_encryptor.rs`:

```rust
#[cfg(any(test, feature = "test-helpers"))]
pub fn new_forced_aes() -> Self {
    Self { _force_aes: true }
}
```

And expose internal methods needed by integration tests (`_aes_encrypt`, `_aes_decrypt`) as `pub(crate)`.

Also add `_keyring_available_pub()` as a thin wrapper:

```rust
#[cfg(any(test, feature = "test-helpers"))]
pub fn _keyring_available_pub(&self) -> bool {
    self._keyring_available()
}
```

### 4. Run tests

```bash
cargo test 2>&1 | tail -20
```

All tests must pass. If T-SEC-03 (`handle_response` with 401) needs a live HTTP mock, use `wiremock` or skip with `#[ignore]` and a comment explaining the requirement.

### 5. Final cargo check

```bash
cargo test && cargo clippy -- -D warnings
```

---

## Acceptance Criteria

- [ ] `tests/test_security.rs` exists and compiles
- [ ] T-SEC-01 through T-SEC-18 all have corresponding test functions
- [ ] `cargo test` passes with zero failures
- [ ] T-SEC-04 / T-SEC-06 are skipped on CI (`#[cfg(not(ci))]`) with a clear comment
- [ ] No plaintext secrets appear in `enc:` tokens (T-SEC-18)
- [ ] Audit entries contain all seven required fields (T-SEC-10, T-SEC-11)
- [ ] AuditLogger write failure does not panic (T-SEC-12)
- [ ] `Sandbox::is_enabled()` method added and used in T-SEC-14/T-SEC-16
- [ ] `ConfigEncryptor::new_forced_aes()` or equivalent allows bypassing keyring in tests
- [ ] `cargo clippy -- -D warnings` clean across all files
- [ ] `cargo test` passes with zero failures (final gate)

---

## Dependencies

- **Depends on**: `config-encryptor`, `auth`, `audit`, `sandbox`
- **Required by**: — (final task)