wafrift-proxy 0.3.1

HTTP forward proxy with automatic WAF evasion and optional TLS interception support.
Documentation
//! Intercept tab — operator control surface for paused requests.
//!
//! Layout:
//!   ┌─ Intercept · 3 pending · MODE: ON ─────────┐
//!   │  ▶  GET   /admin       api.target.com  3s  │
//!   │     POST  /v1/users    api.target.com  1s  │
//!   │     GET   /style.css   cdn.example.com 8s  │
//!   └────────────────────────────────────────────┘
//!   r release · k kill · i toggle mode · j/k navigate

use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};

use crate::intercept;

use super::format::truncate;
use super::state::State;

pub fn draw(f: &mut Frame, area: Rect, _state: &State) {
    let pending = intercept::global_store().snapshot();
    let mode_on = intercept::intercept_mode_enabled();

    let title = format!(
        " Intercept · {} pending · MODE: {} ",
        pending.len(),
        if mode_on { "ON" } else { "OFF" },
    );
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(if mode_on {
            Color::LightGreen
        } else {
            Color::DarkGray
        }))
        .title(Span::styled(
            title,
            Style::default()
                .fg(Color::LightCyan)
                .add_modifier(Modifier::BOLD),
        ));

    if pending.is_empty() {
        let inner = block.inner(area);
        f.render_widget(block, area);
        let p = Paragraph::new(if mode_on {
            "(no requests parked yet — proxy something through this address)"
        } else {
            "(intercept mode is OFF — press `i` to toggle ON)"
        })
        .style(crate::tui::style::DIM);
        f.render_widget(p, inner);
        return;
    }

    let header = Row::new(vec![
        Cell::from("ID"),
        Cell::from("METHOD"),
        Cell::from("PATH"),
        Cell::from("HOST"),
        Cell::from("WAITING"),
    ])
    .style(
        Style::default()
            .fg(Color::LightCyan)
            .add_modifier(Modifier::BOLD),
    );

    let now = std::time::Instant::now();
    let rows: Vec<Row> = pending
        .iter()
        .map(|p| {
            let waiting_secs = now.duration_since(p.since).as_secs();
            let waiting_color = match waiting_secs {
                0..=4 => Color::Green,
                5..=14 => Color::Yellow,
                _ => Color::Red,
            };
            Row::new(vec![
                Cell::from(p.id.to_string()).style(crate::tui::style::DIM),
                Cell::from(p.method.clone()).style(Style::default().fg(Color::Cyan)),
                Cell::from(truncate(&p.path, 36).to_string())
                    .style(Style::default().fg(Color::White)),
                Cell::from(truncate(&p.host, 28).to_string())
                    .style(Style::default().fg(Color::Yellow)),
                Cell::from(format!("{waiting_secs}s")).style(Style::default().fg(waiting_color)),
            ])
        })
        .collect();

    let widths = [
        Constraint::Length(6),
        Constraint::Length(7),
        Constraint::Percentage(40),
        Constraint::Percentage(35),
        Constraint::Length(8),
    ];
    let table = Table::new(rows, widths).header(header).block(block);
    f.render_widget(table, area);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::intercept::{InterceptStore, set_intercept_mode};

    #[tokio::test]
    async fn snapshot_appears_in_global_store() {
        // Sanity: the render path reads from the global singleton.
        // After a register, the snapshot must be non-empty.
        let store = InterceptStore::new();
        let (_, _rx) = store.register("h", "GET", "/");
        let snap = store.snapshot();
        assert_eq!(snap.len(), 1);
        let _ = snap; // suppress unused on no-trait-asserts
    }

    #[test]
    fn intercept_mode_toggle_round_trips() {
        let initial = intercept::intercept_mode_enabled();
        set_intercept_mode(false);
        assert!(!intercept::intercept_mode_enabled());
        intercept::toggle_intercept_mode();
        assert!(intercept::intercept_mode_enabled());
        intercept::toggle_intercept_mode();
        assert!(!intercept::intercept_mode_enabled());
        // restore
        set_intercept_mode(initial);
    }

    // ── Render-output tests (added 2026-05) ───────────────
    //
    // The Intercept tab reads from the GLOBAL intercept store
    // singleton — tests that need to control "pending count" can't
    // do so without polluting global state across parallel tests.
    // We focus the render tests on the EMPTY-state path (which is
    // a stable global behaviour) and on the mode-toggle visuals.

    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    fn render(width: u16, height: u16) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("backend");
        let state = State::new();
        terminal
            .draw(|f| {
                let area = f.area();
                draw(f, area, &state);
            })
            .expect("draw");
        let buffer = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buffer.area().height {
            let mut line = String::new();
            for x in 0..buffer.area().width {
                line.push_str(buffer[(x, y)].symbol());
            }
            out.push_str(line.trim_end());
            out.push('\n');
        }
        out
    }

    /// Serial-execution guard: the intercept tests below MUST NOT
    /// run concurrently because they mutate the global mode flag.
    fn serial_guard() -> std::sync::MutexGuard<'static, ()> {
        use std::sync::{Mutex, OnceLock};
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    #[test]
    fn empty_state_with_mode_on_shows_hint_to_proxy() {
        let _g = serial_guard();
        let prior = intercept::intercept_mode_enabled();
        set_intercept_mode(true);
        let buf = render(120, 6);
        // Mode label + empty hint.
        assert!(buf.contains("MODE: ON"), "mode banner missing: {buf}");
        assert!(buf.contains("no requests parked"));
        set_intercept_mode(prior);
    }

    #[test]
    fn empty_state_with_mode_off_shows_hint_to_toggle() {
        let _g = serial_guard();
        let prior = intercept::intercept_mode_enabled();
        set_intercept_mode(false);
        let buf = render(120, 6);
        assert!(buf.contains("MODE: OFF"));
        assert!(buf.contains("intercept mode is OFF"));
        set_intercept_mode(prior);
    }

    #[test]
    fn empty_state_title_shows_zero_pending() {
        let _g = serial_guard();
        let prior = intercept::intercept_mode_enabled();
        set_intercept_mode(true);
        // Drain any leftover pending requests so the count is
        // deterministic.  The store doesn't expose a clear() but
        // pending must be empty at the start of a test run.
        let buf = render(120, 6);
        assert!(buf.contains("Intercept"));
        // Match the count word + "pending" suffix.
        assert!(buf.contains("pending"));
        set_intercept_mode(prior);
    }

    #[test]
    fn render_does_not_panic_on_narrow_width() {
        let _g = serial_guard();
        let prior = intercept::intercept_mode_enabled();
        set_intercept_mode(false);
        let _ = render(30, 6);
        set_intercept_mode(prior);
    }

    #[test]
    fn render_does_not_panic_on_tall_height() {
        let _g = serial_guard();
        let prior = intercept::intercept_mode_enabled();
        set_intercept_mode(true);
        let _ = render(120, 50);
        set_intercept_mode(prior);
    }

    #[test]
    fn waiting_color_bands_are_well_defined() {
        // Property: every 0..=4 second value lights green,
        // 5..=14 yellow, 15+ red.  This documents the band edges.
        for secs in 0..=4 {
            let color = match secs {
                0..=4 => Color::Green,
                5..=14 => Color::Yellow,
                _ => Color::Red,
            };
            assert_eq!(color, Color::Green, "secs {secs} should be green");
        }
        for secs in 5..=14 {
            let color = match secs {
                0..=4 => Color::Green,
                5..=14 => Color::Yellow,
                _ => Color::Red,
            };
            assert_eq!(color, Color::Yellow, "secs {secs} should be yellow");
        }
        for secs in [15_u64, 30, 60, 300] {
            let color = match secs {
                0..=4 => Color::Green,
                5..=14 => Color::Yellow,
                _ => Color::Red,
            };
            assert_eq!(color, Color::Red, "secs {secs} should be red");
        }
    }

    #[test]
    fn snapshot_distinguishes_concurrent_intercepts() {
        // Two registrations in the same store yield two pending entries.
        let store = InterceptStore::new();
        let (_id1, _rx1) = store.register("h1", "GET", "/a");
        let (_id2, _rx2) = store.register("h2", "POST", "/b");
        let snap = store.snapshot();
        assert_eq!(snap.len(), 2);
        // Both methods + paths preserved.
        let methods: Vec<&str> = snap.iter().map(|p| p.method.as_str()).collect();
        assert!(methods.contains(&"GET"));
        assert!(methods.contains(&"POST"));
    }

    #[test]
    fn empty_snapshot_on_fresh_store() {
        let store = InterceptStore::new();
        assert!(store.snapshot().is_empty());
    }

    #[test]
    fn mode_toggle_initial_round_trip_preserves_invariant() {
        let _g = serial_guard();
        // Capture, set OFF, set ON, set OFF — each transition
        // must be observable via the getter.
        let initial = intercept::intercept_mode_enabled();
        for value in [false, true, false, true] {
            set_intercept_mode(value);
            assert_eq!(intercept::intercept_mode_enabled(), value);
        }
        set_intercept_mode(initial);
    }
}