par-term 0.39.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
Documentation
//! Tests for the session logger.

use super::core::{REDACTION_MARKER, SessionLogger};
use super::writers::{contains_password_prompt, html_escape, strip_ansi_escapes};
use crate::config::SessionLogFormat;
use tempfile::TempDir;

#[test]
fn test_strip_ansi_escapes() {
    // Simple text
    assert_eq!(strip_ansi_escapes(b"hello world"), "hello world");

    // CSI sequence (color)
    assert_eq!(strip_ansi_escapes(b"\x1b[32mgreen\x1b[0m"), "green");

    // OSC sequence (title)
    assert_eq!(strip_ansi_escapes(b"\x1b]0;title\x07text"), "text");

    // Multiple sequences
    assert_eq!(
        strip_ansi_escapes(b"\x1b[1;32mBold Green\x1b[0m Normal"),
        "Bold Green Normal"
    );
}

#[test]
fn test_html_escape() {
    assert_eq!(html_escape("<script>"), "&lt;script&gt;");
    assert_eq!(html_escape("a & b"), "a &amp; b");
    assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
}

#[test]
fn test_session_logger_plain() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Plain,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.start().unwrap();
    logger.record_output(b"Hello, World!\n");
    logger.record_output(b"\x1b[32mGreen text\x1b[0m\n");
    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    assert!(content.contains("Hello, World!"));
    assert!(content.contains("Green text"));
    assert!(!content.contains("\x1b")); // No escape sequences
}

/// SEC-009: the session title comes from the tab title, which a remote process
/// sets via OSC 0/2. An unescaped title breaks out of `<title>` and executes when
/// the log is opened in a browser.
#[test]
fn test_html_header_escapes_remote_controlled_title() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Html,
        temp_dir.path(),
        (80, 24),
        Some("</title><script>alert('xss')</script>".to_string()),
    )
    .unwrap();

    logger.start().unwrap();
    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    assert!(
        !content.contains("<script>"),
        "title must not be able to open a script tag: {content}"
    );
    assert!(
        content.contains("&lt;/title&gt;&lt;script&gt;"),
        "title must appear HTML-escaped: {content}"
    );
}

#[test]
fn test_session_logger_asciicast() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Asciicast,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.start().unwrap();
    logger.record_output(b"Hello\n");
    std::thread::sleep(std::time::Duration::from_millis(10));
    logger.record_output(b"World\n");
    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    let lines: Vec<&str> = content.lines().collect();

    // First line should be header
    assert!(lines[0].contains("\"version\":2"));
    assert!(lines[0].contains("\"width\":80"));
    assert!(lines[0].contains("\"height\":24"));

    // Should have output events
    assert!(lines.len() >= 3);
}

#[test]
fn test_password_prompt_detection() {
    assert!(contains_password_prompt("Password:"));
    assert!(contains_password_prompt("[sudo] password for user:"));
    assert!(contains_password_prompt("Enter passphrase for key:"));
    assert!(contains_password_prompt("Enter PIN:"));
    assert!(contains_password_prompt("some output\nPassword:"));
    assert!(contains_password_prompt("Verification code:"));
    assert!(contains_password_prompt("(current) UNIX password:"));
    // Case insensitive
    assert!(contains_password_prompt("PASSWORD:"));
    assert!(contains_password_prompt("Enter Password:"));
    // Should not match normal output
    assert!(!contains_password_prompt("user@host:~$"));
    assert!(!contains_password_prompt("Hello, World!"));
    assert!(!contains_password_prompt("ls -la"));
}

#[test]
fn test_password_prompt_with_ansi_escapes() {
    // Password prompt with ANSI color codes
    let data = b"\x1b[1;31m[sudo] password for user:\x1b[0m ";
    let stripped = strip_ansi_escapes(data);
    assert!(contains_password_prompt(&stripped));
}

#[test]
fn test_input_redaction_on_password_prompt() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Asciicast,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.start().unwrap();

    // Normal output and input (should be recorded)
    logger.record_output(b"user@host:~$ ");
    logger.record_input(b"sudo ls\r");

    // Password prompt output
    logger.record_output(b"[sudo] password for user: ");

    // Password input (should be redacted)
    logger.record_input(b"s");
    logger.record_input(b"e");
    logger.record_input(b"c");
    logger.record_input(b"r");
    logger.record_input(b"e");
    logger.record_input(b"t");
    // Enter completes the password
    logger.record_input(b"\r");

    // Normal output after password
    logger.record_output(b"\nfile1  file2  file3\n");
    logger.record_input(b"echo done\r");

    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();

    // The actual password ("secret") should NOT appear in the log
    // Check that no input event contains the password characters individually
    assert!(
        !content.contains("secret"),
        "Password text 'secret' should not appear in the log"
    );
    // The redaction marker SHOULD appear
    assert!(
        content.contains(REDACTION_MARKER),
        "Redaction marker should appear in the log"
    );
    // Normal input should still appear
    assert!(
        content.contains("sudo ls"),
        "Normal input before prompt should be recorded"
    );
    assert!(
        content.contains("echo done"),
        "Normal input after password should be recorded"
    );
}

#[test]
fn test_input_redaction_via_echo_suppressed() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Asciicast,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.start().unwrap();

    // Normal input
    logger.record_input(b"ls\r");

    // Externally signal echo off
    logger.set_echo_suppressed(true);
    logger.record_input(b"mysecret");
    logger.record_input(b"\r");

    // Echo back on
    logger.set_echo_suppressed(false);
    logger.record_input(b"whoami\r");

    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();

    assert!(!content.contains("mysecret"), "Secret should not appear");
    assert!(
        content.contains(REDACTION_MARKER),
        "Redaction marker should appear"
    );
    assert!(content.contains("whoami"), "Normal input should appear");
}

#[test]
fn test_redaction_disabled() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Asciicast,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.set_redact_passwords(false);
    logger.start().unwrap();

    // Even with a password prompt, input should NOT be redacted
    logger.record_output(b"Password: ");
    logger.record_input(b"mysecret\r");

    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    // With redaction disabled, the password IS recorded (user's choice)
    assert!(
        content.contains("mysecret"),
        "With redaction disabled, input should be recorded"
    );
    assert!(
        !content.contains(REDACTION_MARKER),
        "No redaction marker when disabled"
    );
}

#[test]
fn test_redaction_marker_emitted_once_per_period() {
    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Asciicast,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();

    logger.start().unwrap();
    logger.record_output(b"Password: ");

    // Multiple input events during password entry
    logger.record_input(b"a");
    logger.record_input(b"b");
    logger.record_input(b"c");
    logger.record_input(b"\r");

    let path = logger.stop().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();

    // Count occurrences of the redaction marker
    let marker_count = content.matches(REDACTION_MARKER).count();
    assert_eq!(
        marker_count, 1,
        "Redaction marker should appear exactly once per password entry"
    );
}

/// QA-030: a failed write must stop logging and be reportable, rather than
/// leaving the UI claiming an active transcript that has silently truncated.
///
/// Unix-only: the failure is provoked with a read-only file descriptor, which
/// Windows reports differently.
#[cfg(unix)]
#[test]
fn write_failure_deactivates_the_logger() {
    use std::fs::OpenOptions;
    use std::io::BufWriter;

    let temp_dir = TempDir::new().unwrap();
    let mut logger = SessionLogger::new(
        SessionLogFormat::Plain,
        temp_dir.path(),
        (80, 24),
        Some("Test Session".to_string()),
    )
    .unwrap();
    logger.start().unwrap();
    assert!(logger.is_active());

    // Swap in a read-only handle to the same file so write_all fails. Capacity 0
    // makes BufWriter pass every write straight through to the descriptor.
    let read_only = OpenOptions::new()
        .read(true)
        .open(logger.output_path())
        .unwrap();
    logger.writer = Some(BufWriter::with_capacity(0, read_only));

    logger.record_output(b"this write cannot land\n");

    assert!(
        !logger.is_active(),
        "logger must not report itself active after a failed write"
    );
    assert!(
        logger.write_error().is_some(),
        "the failure must be retrievable for the UI"
    );
    assert!(
        logger.start().is_err(),
        "restarting an unwritable logger must surface the error"
    );
}