gsm-core 0.4.2

Core types and platform abstractions for the Greentic messaging runtime.
Documentation
#![cfg(feature = "adaptive-cards")]

use std::fs;
use std::sync::{Arc, Mutex, MutexGuard};

use gsm_core::messaging_card::renderers::{
    override_url_allow_list, reload_url_allow_list_from_env,
};
use gsm_core::messaging_card::{MessageCard, MessageCardEngine, TelemetryEvent, TelemetryHook};
use once_cell::sync::Lazy;
use serde_json::Value;

#[test]
fn url_allow_list_blocks_disallowed_links() {
    let _guard = AllowListGuard::new(Some(vec!["https://allowed.example/".into()]));
    let (hook, events) = recording_hook();
    let engine = MessageCardEngine::bootstrap().with_telemetry(hook);
    let card = load_card("markdown_and_links.json");
    let ir = engine.normalize(&card).expect("normalize card");
    let payload = engine.render("slack", &ir).expect("render slack payload");

    let blocks = payload["blocks"]
        .as_array()
        .expect("blocks array on slack payload");
    let actions = blocks
        .iter()
        .find(|block| block.get("type") == Some(&Value::String("actions".into())))
        .expect("actions block present");
    let elements = actions["elements"]
        .as_array()
        .expect("actions array on slack payload");
    assert_eq!(
        elements.len(),
        1,
        "blocked URLs are removed from the rendered payload"
    );

    let event = last_event(&events);
    match event {
        TelemetryEvent::Rendered {
            platform,
            url_blocked_count,
            downgrade_count,
            native_count,
            ..
        } => {
            assert_eq!(platform, "slack");
            assert_eq!(url_blocked_count, 1);
            assert_eq!(downgrade_count, 0);
            assert_eq!(native_count, 1);
        }
        other => panic!("expected rendered telemetry, got {other:?}"),
    }
}

#[test]
fn sanitizer_strips_tags_and_records_metrics() {
    let _guard = AllowListGuard::new(None);
    let (hook, events) = recording_hook();
    let engine = MessageCardEngine::bootstrap().with_telemetry(hook);
    let card = load_card("markdown_and_links.json");
    let ir = engine.normalize(&card).expect("normalize card");
    let payload = engine.render("slack", &ir).expect("render slack payload");

    let blocks = payload["blocks"].as_array().expect("slack blocks array");
    let header = blocks
        .iter()
        .find(|block| block.get("type") == Some(&Value::String("header".into())))
        .expect("header block");
    let header_text = header["text"]["text"].as_str().expect("header text");
    assert!(
        !header_text.contains('<'),
        "sanitizer removes html tags from header"
    );
    assert!(
        header_text.contains("Greeter"),
        "header retains plain text content"
    );

    let footer = blocks
        .iter()
        .find(|block| block.get("type") == Some(&Value::String("context".into())))
        .expect("footer context block");
    let footer_text = footer["elements"][0]["text"].as_str().expect("footer text");
    assert!(
        !footer_text.contains('<'),
        "sanitizer removes html tags from footer"
    );

    let event = last_event(&events);
    match event {
        TelemetryEvent::Rendered {
            sanitized_count,
            downgrade_count,
            native_count,
            ..
        } => {
            assert!(
                sanitized_count >= 1,
                "sanitizer increments telemetry counter"
            );
            assert_eq!(downgrade_count, 0);
            assert_eq!(native_count, 1);
        }
        other => panic!("expected rendered telemetry, got {other:?}"),
    }
}

#[test]
fn payload_limit_sets_flag_and_warning() {
    let _guard = AllowListGuard::new(None);
    let (hook, events) = recording_hook();
    let engine = MessageCardEngine::bootstrap().with_telemetry(hook);
    let mut card = load_card("payload_overflow.json");
    if let Some(text) = card.text.as_mut() {
        *text = text.repeat(1500);
    }
    let ir = engine.normalize(&card).expect("normalize card");
    let payload = engine
        .render("telegram", &ir)
        .expect("render telegram payload");

    let truncated = payload["text"].as_str().expect("telegram text");
    assert!(
        truncated.chars().count() < card.text.as_ref().unwrap().chars().count(),
        "payload is truncated to platform limit"
    );

    let event = last_event(&events);
    match event {
        TelemetryEvent::Rendered {
            limit_exceeded,
            warnings,
            downgrade_count,
            native_count,
            ..
        } => {
            assert!(limit_exceeded, "Telemetry marks limit overflow");
            assert!(
                warnings > 0,
                "Telemetry captures warning count for truncation"
            );
            assert_eq!(downgrade_count, 0);
            assert_eq!(native_count, 1);
        }
        other => panic!("expected rendered telemetry, got {other:?}"),
    }
}

fn load_card(name: &str) -> MessageCard {
    let path = format!("tests/fixtures/security/{name}");
    let data = fs::read_to_string(path).expect("fixture missing");
    serde_json::from_str(&data).expect("invalid MessageCard fixture")
}

fn last_event(events: &Arc<Mutex<Vec<TelemetryEvent>>>) -> TelemetryEvent {
    events
        .lock()
        .expect("telemetry mutex poisoned")
        .last()
        .cloned()
        .expect("expected telemetry event")
}

fn recording_hook() -> (RecordingHook, Arc<Mutex<Vec<TelemetryEvent>>>) {
    let events = Arc::new(Mutex::new(Vec::new()));
    (
        RecordingHook {
            events: events.clone(),
        },
        events,
    )
}

#[derive(Clone)]
struct RecordingHook {
    events: Arc<Mutex<Vec<TelemetryEvent>>>,
}

impl TelemetryHook for RecordingHook {
    fn emit(&self, event: TelemetryEvent) {
        self.events
            .lock()
            .expect("telemetry mutex poisoned")
            .push(event);
    }
}

static ALLOW_LIST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));

struct AllowListGuard {
    _guard: MutexGuard<'static, ()>,
}

impl AllowListGuard {
    fn new(list: Option<Vec<String>>) -> Self {
        let guard = ALLOW_LIST_LOCK
            .lock()
            .expect("allow list test mutex poisoned");
        override_url_allow_list(list);
        Self { _guard: guard }
    }
}

impl Drop for AllowListGuard {
    fn drop(&mut self) {
        reload_url_allow_list_from_env();
    }
}