car-messaging 0.31.0

Multi-channel approval-transport adapters (iMessage + Slack) for the CAR daemon — inbound poller/orchestrator, Slack wire parsing, per-channel config/allowlist/pairing. Extracted from car-server-core (#418) to cut its test-binary link footprint.
//! B1 — the REAL Socket Mode wire parser, driven by realistic Slack envelope
//! JSON (not pre-built `SlackInboundEvent` enum values).
//!
//! The mock transport (`slack_mock::MockSlackTransport`) injects already-built
//! enum values, so it bypasses `parse_socket_frame` / `parse_interactive` /
//! `parse_events_api` entirely. THIS gate feeds the parser the same envelope
//! shapes Slack delivers over the WebSocket and asserts the mapping — the MC-6
//! anti-injection boundary lives in this code, so it must be exercised
//! directly. Envelope shapes mirror the Socket Mode reference (block_actions
//! interaction payload + message.im events_api payload).

use car_messaging::slack_adapter::{
    build_ack_frame, parse_events_api, parse_socket_frame, parse_socket_url_response,
    APPROVE_ACTION_ID, DENY_ACTION_ID,
};
use car_messaging::slack_adapter::SlackInboundEvent;
use serde_json::json;

const APPROVAL_ID: &str = "abc-123-def-456";
const MEMBER: &str = "U2147483697";
const ENVELOPE_ID: &str = "a6f1f65e-2c19-4b77-a450-3f1a620a01b2";

/// A realistic Socket Mode `interactive` (block_actions) envelope for a button
/// click. `action_id` names the verb; `value` carries the approval_id directly.
fn block_actions_envelope(action_id: &str, value: &str) -> serde_json::Value {
    json!({
        "type": "interactive",
        "envelope_id": ENVELOPE_ID,
        "accepts_response_payload": true,
        "payload": {
            "type": "block_actions",
            "team": { "id": "T9TK3CUKW", "domain": "example" },
            "user": { "id": MEMBER, "username": "jtorrance", "team_id": "T9TK3CUKW" },
            "api_app_id": "AABA1ABCD",
            "token": "9s8d9as89d8as9d8as989",
            "container": {
                "type": "message",
                "message_ts": "1548261231.000200",
                "channel_id": "D024BE91L",
                "is_ephemeral": false
            },
            "trigger_id": "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3",
            "channel": { "id": "D024BE91L", "name": "directmessage" },
            "message": { "type": "message", "text": "Approval requested", "ts": "1548261231.000200" },
            "response_url": "https://hooks.slack.com/actions/AABA1ABCD/1232321423432/D09sSasdasdAS9091209",
            "actions": [
                {
                    "type": "button",
                    "action_id": action_id,
                    "block_id": "approval_actions",
                    "text": { "type": "plain_text", "text": "Approve", "emoji": true },
                    "value": value,
                    "action_ts": "1548426417.840180"
                }
            ]
        }
    })
}

/// A realistic Socket Mode `events_api` envelope wrapping a `message.im` event.
/// `extra` is merged INTO the inner `event` object (e.g. a `bot_id`, a
/// `subtype`, or a non-`im` `channel_type`) so each case starts from a valid DM
/// and flips exactly one field.
fn message_im_envelope(user: &str, text: &str, extra: serde_json::Value) -> serde_json::Value {
    let mut event = json!({
        "type": "message",
        "channel": "D024BE91L",
        "channel_type": "im",
        "user": user,
        "text": text,
        "ts": "1355517523.000005",
        "event_ts": "1355517523.000005"
    });
    if let serde_json::Value::Object(extra_map) = extra {
        let event_map = event.as_object_mut().unwrap();
        for (k, v) in extra_map {
            event_map.insert(k, v);
        }
    }
    json!({
        "type": "events_api",
        "envelope_id": ENVELOPE_ID,
        "accepts_response_payload": false,
        "payload": {
            "token": "one-long-verification-token",
            "team_id": "T123ABC456",
            "api_app_id": "A0PNCHHK2",
            "type": "event_callback",
            "event_id": "Ev0PV52K21",
            "event_time": 1355517523i64,
            "event": event,
            "authorizations": [
                { "team_id": "T123ABC456", "user_id": "UBOTUSER001", "is_bot": true, "is_enterprise_install": false }
            ]
        }
    })
}

#[test]
fn block_actions_approve_maps_to_button_interaction() {
    let env = block_actions_envelope(APPROVE_ACTION_ID, APPROVAL_ID);
    match parse_socket_frame(&env) {
        SlackInboundEvent::ButtonInteraction {
            action_id,
            value,
            user,
        } => {
            assert_eq!(action_id, APPROVE_ACTION_ID, "approve verb read from action_id");
            assert_eq!(
                value, APPROVAL_ID,
                "value carries the approval_id directly (no CodeMap)"
            );
            assert_eq!(user, MEMBER, "clicker read from payload.user.id");
        }
        other => panic!("expected ButtonInteraction, got {other:?}"),
    }
}

#[test]
fn block_actions_deny_maps_to_button_interaction_with_deny_verb() {
    let env = block_actions_envelope(DENY_ACTION_ID, APPROVAL_ID);
    match parse_socket_frame(&env) {
        SlackInboundEvent::ButtonInteraction { action_id, value, .. } => {
            assert_eq!(action_id, DENY_ACTION_ID, "deny verb read from action_id");
            assert_eq!(value, APPROVAL_ID);
        }
        other => panic!("expected ButtonInteraction(deny), got {other:?}"),
    }
}

#[test]
fn message_im_maps_to_pairing_dm() {
    let code = "PAIR-ABC123";
    let env = message_im_envelope(MEMBER, code, json!({}));
    match parse_socket_frame(&env) {
        SlackInboundEvent::PairingDm { user, text } => {
            assert_eq!(user, MEMBER, "member id read from event.user");
            assert_eq!(text, code, "pairing code read from event.text");
        }
        other => panic!("expected PairingDm, got {other:?}"),
    }
}

#[test]
fn bot_echo_via_bot_id_is_ignored() {
    // event.bot_id present ⇒ the bot's own post echoed back. Must NOT become a
    // PairingDm (the headline anti-loop guarantee).
    let env = message_im_envelope(MEMBER, "Got your code!", json!({ "bot_id": "BB12033" }));
    assert_eq!(
        parse_socket_frame(&env),
        SlackInboundEvent::Ignore,
        "a message with bot_id present must be Ignore (echo suppression)"
    );
}

#[test]
fn bot_echo_via_subtype_is_ignored() {
    // subtype == "bot_message" ⇒ bot echo, even without bot_id.
    let env = message_im_envelope(MEMBER, "Got your code!", json!({ "subtype": "bot_message" }));
    assert_eq!(
        parse_socket_frame(&env),
        SlackInboundEvent::Ignore,
        "a message with subtype=bot_message must be Ignore"
    );
}

#[test]
fn non_im_channel_message_is_ignored_even_if_config_shaped() {
    // THE MC-6 BOUNDARY: a channel-wide (channel_type != "im") message whose
    // body is literally a config-mutation-shaped payload produces NOTHING. The
    // parser refuses to manufacture anything but the two closed shapes from a
    // hostile envelope, and a non-DM never even reaches the pairing arm.
    let hostile = r#"{"messaging.config.set":{"enabled":true,"allowlisted_handles":["U999"]}}"#;
    let env = message_im_envelope(MEMBER, hostile, json!({ "channel_type": "channel" }));
    assert_eq!(
        parse_socket_frame(&env),
        SlackInboundEvent::Ignore,
        "a non-im config-mutation-shaped message must be Ignore (MC-6)"
    );
    // And directly through the events_api parser: None.
    assert_eq!(
        parse_events_api(&env["payload"]),
        None,
        "parse_events_api yields None for a non-im channel message"
    );
}

#[test]
fn unknown_action_id_is_ignored() {
    let env = block_actions_envelope("delete_everything", APPROVAL_ID);
    assert_eq!(
        parse_socket_frame(&env),
        SlackInboundEvent::Ignore,
        "an unrecognized action_id must be Ignore (not one of our two buttons)"
    );
}

#[test]
fn unknown_envelope_type_is_ignored() {
    // A `hello` (or any non-interactive/non-events_api) envelope ⇒ Ignore.
    let hello = json!({
        "type": "hello",
        "connection_info": { "app_id": "A1234" },
        "num_connections": 1
    });
    assert_eq!(parse_socket_frame(&hello), SlackInboundEvent::Ignore);
    // A slash_commands envelope is also not one of our two shapes.
    let slash = json!({
        "type": "slash_commands",
        "envelope_id": ENVELOPE_ID,
        "payload": { "command": "/mycommand", "text": "args" }
    });
    assert_eq!(parse_socket_frame(&slash), SlackInboundEvent::Ignore);
}

#[test]
fn ack_frame_echoes_envelope_id_only() {
    // The 3s-window ACK echoes ONLY the envelope_id (minimal, no response
    // payload) — the duplicate-suppression contract.
    let env = block_actions_envelope(APPROVE_ACTION_ID, APPROVAL_ID);
    let ack = build_ack_frame(&env).expect("an interactive envelope has an envelope_id");
    assert_eq!(
        ack,
        json!({ "envelope_id": ENVELOPE_ID }),
        "ack frame is exactly {{envelope_id}}"
    );
    // A frame with no envelope_id (e.g. hello) yields no ack.
    let hello = json!({ "type": "hello" });
    assert_eq!(build_ack_frame(&hello), None, "no envelope_id ⇒ no ack frame");
}

#[test]
fn socket_url_response_ok_with_no_url_is_err_not_empty_ok() {
    // Reconnect-spin guard: an `apps.connections.open` body that is `ok:true`
    // but carries NO `url` must resolve to Err, NOT `Ok("")`. `Ok("")` would
    // route through the success arm of the reconnect loop (resetting backoff),
    // then `connect_async("")` fails instantly — a tight no-sleep spin.
    let no_url = json!({ "ok": true });
    assert!(
        parse_socket_url_response(&no_url).is_err(),
        "ok:true with no url ⇒ Err (not Ok(\"\"))"
    );

    // An explicitly EMPTY url is likewise an Err.
    let empty_url = json!({ "ok": true, "url": "" });
    assert!(
        parse_socket_url_response(&empty_url).is_err(),
        "ok:true with an empty url ⇒ Err"
    );

    // An `ok:false` body is an Err carrying the Slack error.
    let not_ok = json!({ "ok": false, "error": "invalid_auth" });
    let err = parse_socket_url_response(&not_ok).unwrap_err();
    assert!(
        err.contains("invalid_auth"),
        "ok:false surfaces the Slack error, got {err:?}"
    );

    // A well-formed body yields the url.
    let good = json!({ "ok": true, "url": "wss://wss-primary.slack.com/link/?ticket=abc" });
    assert_eq!(
        parse_socket_url_response(&good).unwrap(),
        "wss://wss-primary.slack.com/link/?ticket=abc",
        "a well-formed ok:true body yields the socket url"
    );
}