prodex 0.55.0

OpenAI profile pooling and safe auto-rotate for Codex CLI and Claude Code
Documentation
use super::*;
use crate::TestEnvVarGuard;
use std::time::{SystemTime, UNIX_EPOCH};

fn temp_dir(name: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let dir = env::temp_dir().join(format!(
        "prodex-audit-log-{name}-{}-{nanos:x}",
        std::process::id()
    ));
    fs::create_dir_all(&dir).unwrap();
    dir
}

fn audit_event_line(epoch: i64, component: &str, action: &str, details: Value) -> String {
    format!(
        "{{\"recorded_at\":\"2026-04-08T00:00:00+00:00\",\"recorded_at_epoch\":{epoch},\"pid\":10,\"component\":\"{component}\",\"action\":\"{action}\",\"outcome\":\"success\",\"details\":{details}}}\n",
    )
}

#[test]
fn audit_log_path_uses_env_override() {
    let dir = temp_dir("path");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());

    assert_eq!(audit_log_path(), dir.join(AUDIT_LOG_FILE_NAME));

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn append_audit_event_writes_json_line() {
    let dir = temp_dir("append");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());

    append_audit_event(
        "profile",
        "add",
        "success",
        serde_json::json!({
            "profile_name": "main",
            "managed": true,
        }),
    )
    .unwrap();

    let content = fs::read_to_string(audit_log_path()).unwrap();
    let line = content.lines().next().unwrap();
    let value: Value = serde_json::from_str(line).unwrap();
    assert_eq!(
        value.get("component").and_then(Value::as_str),
        Some("profile")
    );
    assert_eq!(value.get("action").and_then(Value::as_str), Some("add"));
    assert_eq!(
        value.get("outcome").and_then(Value::as_str),
        Some("success")
    );
    assert_eq!(
        value
            .pointer("/details/profile_name")
            .and_then(Value::as_str),
        Some("main")
    );

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn read_recent_audit_events_applies_tail_and_filters() {
    let dir = temp_dir("query");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());

    fs::write(
        audit_log_path(),
        concat!(
            "{\"recorded_at\":\"2026-04-08T00:00:00+00:00\",\"recorded_at_epoch\":1,\"pid\":10,\"component\":\"profile\",\"action\":\"add\",\"outcome\":\"success\",\"details\":{\"profile_name\":\"main\"}}\n",
            "not-json\n",
            "{\"recorded_at\":\"2026-04-08T00:00:01+00:00\",\"recorded_at_epoch\":2,\"pid\":10,\"component\":\"profile\",\"action\":\"use\",\"outcome\":\"success\",\"details\":{\"profile_name\":\"second\"}}\n",
            "{\"recorded_at\":\"2026-04-08T00:00:02+00:00\",\"recorded_at_epoch\":3,\"pid\":10,\"component\":\"runtime\",\"action\":\"broker_start\",\"outcome\":\"success\",\"details\":{\"listen_addr\":\"127.0.0.1:12345\"}}\n"
        ),
    )
    .unwrap();

    let filtered = read_recent_audit_events(&AuditLogQuery {
        tail: 5,
        component: Some("profile".to_string()),
        action: None,
        outcome: Some("success".to_string()),
    })
    .unwrap();
    assert_eq!(filtered.len(), 2);
    assert_eq!(filtered[0].action, "add");
    assert_eq!(filtered[1].action, "use");

    let tailed = read_recent_audit_events(&AuditLogQuery {
        tail: 1,
        component: None,
        action: None,
        outcome: None,
    })
    .unwrap();
    assert_eq!(tailed.len(), 1);
    assert_eq!(tailed[0].component, "runtime");
    assert_eq!(tailed[0].action, "broker_start");

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn read_recent_audit_events_with_filters_scans_beyond_last_tail_lines() {
    let dir = temp_dir("query-filter-window");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());

    let mut content = String::new();
    content.push_str(
        "{\"recorded_at\":\"2026-04-08T00:00:00+00:00\",\"recorded_at_epoch\":1,\"pid\":10,\"component\":\"profile\",\"action\":\"use\",\"outcome\":\"success\",\"details\":{\"profile_name\":\"main\"}}\n",
    );
    for index in 0..20 {
        content.push_str(&format!(
            "{{\"recorded_at\":\"2026-04-08T00:00:{:02}+00:00\",\"recorded_at_epoch\":{},\"pid\":10,\"component\":\"runtime\",\"action\":\"broker_start\",\"outcome\":\"success\",\"details\":{{\"index\":{}}}}}\n",
            index + 1,
            index + 2,
            index
        ));
    }
    fs::write(audit_log_path(), content).unwrap();

    let events = read_recent_audit_events(&AuditLogQuery {
        tail: 5,
        component: Some("profile".to_string()),
        action: Some("use".to_string()),
        outcome: Some("success".to_string()),
    })
    .unwrap();

    assert_eq!(events.len(), 1);
    assert_eq!(events[0].component, "profile");
    assert_eq!(events[0].action, "use");

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn read_recent_audit_events_with_scope_reports_limited_search_window() {
    let dir = temp_dir("query-limited-window");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());

    let mut content = String::new();
    content.push_str(&audit_event_line(
        1,
        "profile",
        "use",
        serde_json::json!({"profile_name":"outside-window"}),
    ));
    let filler_line = audit_event_line(2, "runtime", "broker_start", serde_json::json!({}));
    while content.len() <= (AUDIT_LOG_READ_MAX_BYTES as usize + filler_line.len()) {
        content.push_str(&filler_line);
    }
    fs::write(audit_log_path(), &content).unwrap();

    let result = read_recent_audit_events_with_scope(&AuditLogQuery {
        tail: 5,
        component: Some("profile".to_string()),
        action: Some("use".to_string()),
        outcome: Some("success".to_string()),
    })
    .unwrap();

    assert!(result.events.is_empty());
    assert_eq!(result.search_scope.path, audit_log_path());
    assert_eq!(result.search_scope.log_size_bytes, content.len() as u64);
    assert_eq!(result.search_scope.searched_bytes, AUDIT_LOG_READ_MAX_BYTES);
    assert_eq!(
        result.search_scope.search_start_byte,
        result.search_scope.log_size_bytes - AUDIT_LOG_READ_MAX_BYTES
    );
    assert_eq!(
        result.search_scope.read_limit_bytes,
        AUDIT_LOG_READ_MAX_BYTES
    );
    assert!(result.search_scope.limited);

    let rendered_scope = format_audit_search_scope(&result.search_scope);
    assert!(rendered_scope.contains("limited to last 524288 bytes"));

    let output = render_audit_events_human_with_scope(
        &result.search_scope.path,
        &AuditLogQuery {
            tail: 5,
            component: Some("profile".to_string()),
            action: Some("use".to_string()),
            outcome: Some("success".to_string()),
        },
        &result.events,
        Some(&result.search_scope),
    );
    assert!(output.contains("Search scope: searched 524288"));
    assert!(output.contains("No matching audit events."));

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn read_recent_audit_events_with_scope_reports_tail_zero_empty_search() {
    let dir = temp_dir("query-tail-zero");
    let _guard = TestEnvVarGuard::set("PRODEX_AUDIT_LOG_DIR", &dir.display().to_string());
    let content = audit_event_line(1, "profile", "use", serde_json::json!({}));
    fs::write(audit_log_path(), &content).unwrap();

    let result = read_recent_audit_events_with_scope(&AuditLogQuery {
        tail: 0,
        component: None,
        action: None,
        outcome: None,
    })
    .unwrap();

    assert!(result.events.is_empty());
    assert_eq!(result.search_scope.log_size_bytes, content.len() as u64);
    assert_eq!(result.search_scope.search_start_byte, content.len() as u64);
    assert_eq!(result.search_scope.searched_bytes, 0);
    assert!(!result.search_scope.limited);

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn render_audit_events_human_shows_filters_and_details() {
    let output = render_audit_events_human(
        Path::new("/tmp/prodex-audit.log"),
        &AuditLogQuery {
            tail: 25,
            component: Some("profile".to_string()),
            action: None,
            outcome: Some("success".to_string()),
        },
        &[AuditLogEventRecord {
            recorded_at: "2026-04-08T00:00:00+00:00".to_string(),
            recorded_at_epoch: 1,
            pid: 10,
            component: "profile".to_string(),
            action: "add".to_string(),
            outcome: "success".to_string(),
            details: serde_json::json!({"profile_name":"main"}),
        }],
    );

    assert!(output.contains("Tail: 25"));
    assert!(output.contains("Filter: component=profile outcome=success"));
    assert!(output.contains("profile add success"));
    assert!(output.contains("\"profile_name\":\"main\""));
}