opencrabs 0.3.47

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! Tests for `redact_secrets()` — the function that scrubs sensitive data
//! from tool output before it's displayed in the TUI and channels.
//!
//! Covers: env var assignments, piped secrets, IPv4 addresses,
//! known key prefixes, hex tokens, and mixed alphanumeric tokens.

use crate::utils::redact_tool_input;
use crate::utils::sanitize::redact_secrets;

// ── Environment variable assignments ───────────────────────────────

#[test]
fn redacts_env_var_pass_assignment() {
    let input = r#"ADMIN_PASS="fuNZEIYc2isz0txisiWTKg8A""#;
    let out = redact_secrets(input);
    assert!(
        !out.contains("fuNZEIYc2isz0txisiWTKg8A"),
        "password leaked: {out}"
    );
    assert!(
        out.contains("ADMIN_PASS="),
        "variable name should be preserved: {out}"
    );
}

#[test]
fn redacts_env_var_secret_assignment() {
    let input = "NEW_SECRET=mgd4EjM8oTrmvWPEbqKys7q2c5H6N7";
    let out = redact_secrets(input);
    assert!(
        !out.contains("mgd4EjM8oTrmvWPEbqKys7q2c5H6N7"),
        "secret leaked: {out}"
    );
}

#[test]
fn redacts_env_var_token_assignment() {
    let input = "API_TOKEN=abc123def456ghi789jkl012mno345";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abc123def456ghi789jkl012mno345"),
        "token leaked: {out}"
    );
}

#[test]
fn redacts_env_var_apikey_assignment() {
    let input = "MY_APIKEY=sk_live_abcdef1234567890abcdef";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abcdef1234567890abcdef"),
        "api key leaked: {out}"
    );
}

#[test]
fn redacts_env_var_credential_assignment() {
    let input = "DB_CREDENTIAL=super_secret_password_12345";
    let out = redact_secrets(input);
    assert!(
        !out.contains("super_secret_password_12345"),
        "credential leaked: {out}"
    );
}

#[test]
fn redacts_env_var_auth_assignment() {
    let input = "SERVICE_AUTH=bearer_token_abcdefghijklmnop";
    let out = redact_secrets(input);
    assert!(
        !out.contains("bearer_token_abcdefghijklmnop"),
        "auth value leaked: {out}"
    );
}

// ── Piped secrets ──────────────────────────────────────────────────

#[test]
fn redacts_piped_secret_double_quotes() {
    let input =
        r#"echo "mgd4EjM8oTrmvWPEbqKys7q2c5H6N7" | docker login -u robot$harbor --password-stdin"#;
    let out = redact_secrets(input);
    assert!(
        !out.contains("mgd4EjM8oTrmvWPEbqKys7q2c5H6N7"),
        "piped secret leaked: {out}"
    );
    assert!(out.contains("echo"), "echo command preserved: {out}");
    assert!(
        out.contains("docker login"),
        "docker command preserved: {out}"
    );
}

#[test]
fn redacts_piped_secret_single_quotes() {
    let input = "echo 'superSecretToken1234567890ab' | kubectl apply -f -";
    let out = redact_secrets(input);
    assert!(
        !out.contains("superSecretToken1234567890ab"),
        "piped secret leaked: {out}"
    );
}

#[test]
fn does_not_redact_short_echo_values() {
    let input = r#"echo "hello" | cat"#;
    let out = redact_secrets(input);
    assert!(
        out.contains("hello"),
        "short non-secret should not be redacted: {out}"
    );
}

// ── IPv4 addresses ─────────────────────────────────────────────────

#[test]
fn redacts_server_ip() {
    let input = "Connected to 198.51.100.23 on port 443";
    let out = redact_secrets(input);
    assert!(!out.contains("198.51.100.23"), "server IP leaked: {out}");
    assert!(
        out.contains("[IP_REDACTED]"),
        "IP should be replaced with [IP_REDACTED]: {out}"
    );
}

#[test]
fn redacts_multiple_ips() {
    let input = "Primary: 10.0.1.5, Secondary: 192.168.1.100";
    let out = redact_secrets(input);
    assert!(!out.contains("10.0.1.5"), "first IP leaked: {out}");
    assert!(!out.contains("192.168.1.100"), "second IP leaked: {out}");
}

#[test]
fn preserves_localhost() {
    let input = "Listening on 127.0.0.1:8080";
    let out = redact_secrets(input);
    assert!(
        out.contains("127.0.0.1"),
        "localhost should be preserved: {out}"
    );
}

#[test]
fn preserves_zero_address() {
    let input = "Binding to 0.0.0.0:3000";
    let out = redact_secrets(input);
    assert!(
        out.contains("0.0.0.0"),
        "0.0.0.0 should be preserved: {out}"
    );
}

// ── Existing patterns (regression) ─────────────────────────────────

#[test]
fn redacts_bearer_token() {
    let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test";
    let out = redact_secrets(input);
    assert!(
        !out.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
        "JWT leaked: {out}"
    );
}

#[test]
fn redacts_api_key_assignment() {
    let input = "api_key=sk-proj-abcdef1234567890abcdef1234567890";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abcdef1234567890abcdef1234567890"),
        "API key leaked: {out}"
    );
}

#[test]
fn redacts_github_pat() {
    let input = "Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12";
    let out = redact_secrets(input);
    assert!(
        !out.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"),
        "GitHub PAT leaked: {out}"
    );
}

#[test]
fn redacts_long_hex_token() {
    let input = "secret: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
    let out = redact_secrets(input);
    assert!(
        !out.contains("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"),
        "hex token leaked: {out}"
    );
}

// ── Combined patterns ──────────────────────────────────────────────

#[test]
fn redacts_mixed_output_with_ips_and_secrets() {
    let input = r#"Deploying to 203.0.113.4 with ADMIN_PASS="EXAMPLE_pw_a1b2c3d4e5" and token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"#;
    let out = redact_secrets(input);
    assert!(!out.contains("203.0.113.4"), "IP leaked: {out}");
    assert!(
        !out.contains("EXAMPLE_pw_a1b2c3d4e5"),
        "password leaked: {out}"
    );
    assert!(
        !out.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"),
        "GitHub PAT leaked: {out}"
    );
}

#[test]
fn preserves_non_sensitive_output() {
    let input = "Build completed successfully in 42 seconds with 0 errors";
    let out = redact_secrets(input);
    assert_eq!(out, input, "non-sensitive output should be unchanged");
}

#[test]
fn does_not_panic_on_multibyte_char_after_prefix_match() {
    // Repro of the Ctrl+O TUI render panic from 2026-06-01 03:22:
    //   `start byte index 1868 is not a char boundary; it is inside
    //   '→' (bytes 1866..1869) of ...`
    // Cause: when redact_secrets matched a KEY_PREFIX but the suffix
    // was too short to redact, `search_from` advanced by
    // `"[REDACTED]".len()` (10 ASCII bytes) without snapping to a
    // UTF-8 char boundary. The next iteration's slice
    // `&result[search_from..]` panicked when those 10 bytes landed
    // inside a multi-byte character like `→` (3 bytes).
    //
    // Construct an input that:
    //   1. Contains a key prefix (`sk-`) followed by a SHORT suffix
    //      that won't be redacted (forcing the bug path, not the
    //      replace path that always produces an ASCII-only "[REDACTED]"
    //      output).
    //   2. Places a multi-byte char near `after + "[REDACTED]".len()`
    //      from the prefix match position.
    //
    // Before the fix this would panic the redact_secrets call —
    // and via the channel/TUI sanitiser pipeline, panic the render
    // thread when the user expanded a thinking block (Ctrl+O).
    let input = "sk-x → next text here that follows the arrow → and more arrows →→→";
    let _out = redact_secrets(input); // must not panic
}

#[test]
fn handles_multibyte_chars_at_various_offsets_after_prefix() {
    // Property-style coverage: shift the multi-byte char to several
    // offsets past a short-suffix prefix match. Any one of these
    // could pre-fix land inside a char and panic.
    for pad_bytes in 0..=15 {
        let padding: String = "x".repeat(pad_bytes);
        let input = format!("sk-{padding}→ rest of text continues");
        let _out = redact_secrets(&input); // must not panic at any offset
    }
}

#[test]
fn cyrillic_emoji_cjk_in_post_prefix_window_do_not_panic() {
    // Three different multi-byte character widths past a short
    // suffix that didn't trigger replacement:
    //   2-byte (Cyrillic 'я'), 3-byte (CJK '中'), 4-byte (emoji 🦀)
    let inputs = ["sk-z я тест", "sk-z 中文测试", "sk-z 🦀🦀🦀 опенкрабс"];
    for input in inputs {
        let _out = redact_secrets(input); // must not panic
    }
}

// --- Query-param / lowercase key=value + URL-password coverage ---
// Added 2026-06-07: redact_secrets is the redactor for ALL channel output,
// the DB writeback, and build output, but it only caught UPPERCASE env
// vars. Provider-error URLs with `?api_key=...` query params and
// `https://user:pass@` credentials slipped through (a short, unknown-prefix
// token evaded every other pass). These pin the folded-in coverage.

#[test]
fn redact_secrets_query_param_api_key() {
    let text = "fetch failed: https://api.example.com/v1/sync?api_key=sk-shortABC123&page=2";
    let out = redact_secrets(text);
    assert!(
        !out.contains("sk-shortABC123"),
        "api_key query param leaked: {out}"
    );
    assert!(out.contains("api_key=[REDACTED]"), "got: {out}");
    // The rest of the URL stays for context.
    assert!(out.contains("api.example.com"));
    assert!(
        out.contains("page=2"),
        "non-secret param should survive: {out}"
    );
}

#[test]
fn redact_secrets_query_param_token_and_secret() {
    assert!(!redact_secrets("?token=abc123def456 done").contains("abc123def456"));
    assert!(!redact_secrets("client_secret=xyz789short more").contains("xyz789short"));
    assert!(!redact_secrets("password = hunter2plain end").contains("hunter2plain"));
}

#[test]
fn redact_secrets_url_embedded_password() {
    let text = "clone failed for https://alice:s3cr3tpw@git.example.com/repo.git";
    let out = redact_secrets(text);
    assert!(!out.contains("s3cr3tpw"), "URL password leaked: {out}");
    assert!(out.contains("alice:[REDACTED]@"), "got: {out}");
    assert!(out.contains("git.example.com"));
}

#[test]
fn redact_secrets_does_not_overredact_benign_keys() {
    // Non-secret `*_key=` / bare `key=` patterns must survive — only the
    // explicit sensitive allowlist is redacted.
    for benign in [
        "primary_key=5",
        "cache_key=user_42",
        "sort_key=name",
        "idempotency_key=order-123",
    ] {
        assert_eq!(
            redact_secrets(benign),
            benign,
            "benign key wrongly redacted: {benign}"
        );
    }
}

#[test]
fn redact_secrets_keyval_preserves_existing_placeholder() {
    // An already-redacted token placeholder must not be re-mangled by the
    // key=value pass (HEX/MIXED run first).
    let text = "auth_token=aa83802d35bb2c4471e7e96f4eaeafa6c96fe42f set";
    let out = redact_secrets(text);
    assert!(!out.contains("aa83802d"), "secret leaked: {out}");
    assert!(
        !out.contains("[REDACTED]]"),
        "placeholder got mangled: {out}"
    );
}

#[test]
fn redacts_labeled_password_with_colon() {
    let text = "The credentials are:\n- Email: ace@badireto.pt\n- Password: g3Jklf2!&bF6";
    let out = redact_secrets(text);
    assert!(!out.contains("ace@badireto.pt"), "email leaked: {out}");
    assert!(!out.contains("g3Jklf2!&bF6"), "password leaked: {out}");
    assert!(
        out.contains("Email: [REDACTED]"),
        "email not redacted: {out}"
    );
    assert!(
        out.contains("Password: [REDACTED]"),
        "password not redacted: {out}"
    );
}

#[test]
fn redacts_labeled_token_with_colon() {
    let text = "Token: abc123def456ghi789";
    let out = redact_secrets(text);
    assert!(
        out.contains("Token: [REDACTED]"),
        "token not redacted: {out}"
    );
    assert!(!out.contains("abc123def456"), "token leaked: {out}");
}

#[test]
fn redacts_labeled_api_key_with_colon() {
    let text = "API Key: sk-proj-mrRb3y9swLqHv8ZzB9lPH0_V7RPruzdbnXJf34DxU2RCdQnhCYjS99Tj";
    let out = redact_secrets(text);
    assert!(out.contains("[REDACTED]"), "api key not redacted: {out}");
    assert!(!out.contains("mrRb3y"), "api key leaked: {out}");
}

#[test]
fn redacts_email_in_sensitive_key_json() {
    let input = serde_json::json!({"email": "user@example.com", "name": "John"});
    let out = redact_tool_input(&input);
    assert_eq!(out["email"], "[REDACTED]");
    assert_eq!(out["name"], "John");
}