car-server-core 0.35.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! U2 — `messaging.status` over a real `run_dispatch` WebSocket session, plus
//! the host-gate parity for both new methods (`messaging.status`,
//! `messaging.test_send`).
//!
//! `messaging.status` returns the real runtime liveness so a host UI can render
//! ONE readiness state. Both new methods are host/local-auth gated EXACTLY like
//! the existing `messaging.*` surface — an inbound message carries no host
//! credential, so it can never call them. This test proves:
//!
//! - A NON-host connection is REJECTED on `messaging.status` AND
//!   `messaging.test_send` (host-gate parity with the config surface).
//! - As host, `messaging.status` on an enabled-but-unpaired channel returns
//!   `enabled:true, paired:false` and the full liveness shape; `watcher_running`
//!   is `false` here (this embedder test installs no channel supervisor — the
//!   real boot path installs it; the field reflects the absence honestly rather
//!   than faking liveness).
//! - As host, `messaging.test_send` on a disabled channel (Slack, off) returns a
//!   structured `{ ok:false, error }` (NOT a transport error) — the daemon
//!   validates before attempting any send. This is how the structured-result
//!   shape is proven, with NO live system side effect.
//!
//! HERMETIC: this test does NOT drive the REAL iMessage `messaging.test_send`
//! path by default — on macOS that reaches osascript/Messages.app, a live side
//! effect that can pop an Automation TCC prompt and is non-hermetic in CI. The
//! live iMessage send is opt-in only, behind the `CAR_TEST_LIVE_IMESSAGE` env
//! var. The load-bearing assertions (host-gate parity + status liveness shape)
//! never require it.

use car_memgine::MemgineEngine;
use car_server_core::{run_dispatch, ServerState, ServerStateConfig};
use futures::{SinkExt, StreamExt};
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::{accept_async, connect_async, tungstenite::Message};

type Ws =
    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;

const HOST_TOKEN: &str = "test-host-token-bbbbbbbbbbbbbbbbbbbbbbbbbbb";

fn gated_state(journal_dir: std::path::PathBuf) -> Arc<ServerState> {
    let engine = Arc::new(Mutex::new(MemgineEngine::new(None)));
    let cfg = ServerStateConfig::new(journal_dir).with_shared_memgine(engine);
    let state = Arc::new(ServerState::with_config(cfg));
    state
        .install_host_token(HOST_TOKEN.to_string())
        .expect("install host token");
    state
}

async fn spawn_dispatcher(state: Arc<ServerState>) -> SocketAddr {
    let listener = TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(
        Ipv4Addr::new(127, 0, 0, 1),
        0,
    )))
    .await
    .expect("bind loopback");
    let addr = listener.local_addr().expect("local_addr");
    tokio::spawn(async move {
        let (stream, peer) = listener.accept().await.expect("accept");
        let ws = accept_async(stream).await.expect("ws handshake");
        let (write, read) = ws.split();
        let _ = run_dispatch(read, Box::pin(write), peer.to_string(), state).await;
    });
    addr
}

async fn call(ws: &mut Ws, id: &str, method: &str, params: serde_json::Value) -> serde_json::Value {
    ws.send(Message::Text(
        serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
            .to_string()
            .into(),
    ))
    .await
    .expect("send");
    let text = ws
        .next()
        .await
        .expect("frame")
        .expect("frame ok")
        .into_text()
        .expect("text")
        .to_string();
    serde_json::from_str(&text).expect("parse")
}

#[tokio::test]
async fn status_and_test_send_are_host_gated_and_status_reports_liveness() {
    let journal = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    std::env::set_var("HOME", fake_home.path());

    let state = gated_state(journal.path().to_path_buf());
    let addr = spawn_dispatcher(state).await;
    let (mut ws, _) = connect_async(format!("ws://{addr}"))
        .await
        .expect("connect");

    // 1. NON-host connection: both new methods are rejected (host-gate parity).
    for (i, method) in ["messaging.status", "messaging.test_send"]
        .into_iter()
        .enumerate()
    {
        let resp = call(&mut ws, &format!("n{i}"), method, serde_json::json!({})).await;
        assert!(
            resp.get("error").is_some(),
            "{method} must REJECT a non-host caller, got: {resp}"
        );
        assert!(
            resp.get("result").is_none(),
            "{method} must not return a result to a non-host caller"
        );
    }

    // 2. Authenticate as host.
    let auth = call(
        &mut ws,
        "auth",
        "session.auth",
        serde_json::json!({ "host_token": HOST_TOKEN }),
    )
    .await;
    assert_eq!(auth["result"]["role"], "host", "host auth: {auth}");

    // 3. Enable iMessage (no handle yet) so status has something to report.
    let set = call(
        &mut ws,
        "h1",
        "messaging.config.set",
        serde_json::json!({ "enabled": true }),
    )
    .await;
    assert!(set.get("error").is_none(), "host config.set: {set}");

    // 4. status: enabled, NOT paired, full shape present.
    let status = call(&mut ws, "h2", "messaging.status", serde_json::json!({})).await;
    assert!(status.get("error").is_none(), "host status: {status}");
    let r = &status["result"];
    assert_eq!(r["channel"], "imessage", "status names the channel");
    assert_eq!(r["enabled"], true, "enabled reflects the config");
    assert_eq!(r["paired"], false, "no handle paired yet");
    // No supervisor installed in this embedder test ⇒ watcher_running is false
    // (honest, not faked). The real boot path installs the supervisor.
    assert_eq!(
        r["watcher_running"], false,
        "watcher_running is false without a supervisor (no fake liveness)"
    );
    assert!(
        r.get("fda_readable").is_some(),
        "fda_readable is always present (probed daemon-side)"
    );
    // No send recorded yet ⇒ last_send_* omitted (skip_serializing_if None).
    assert!(
        r.get("last_send_ok").is_none(),
        "no send recorded ⇒ last_send_ok omitted"
    );

    // 5. Pair a handle, then status flips paired:true.
    let _ = call(
        &mut ws,
        "h3",
        "messaging.config.set",
        serde_json::json!({ "add_handles": ["+15551234567"] }),
    )
    .await;
    let status2 = call(&mut ws, "h4", "messaging.status", serde_json::json!({})).await;
    assert_eq!(
        status2["result"]["paired"], true,
        "status reflects the paired handle"
    );

    // 6. test_send STRUCTURED-SHAPE proof — via the Slack (disabled) branch, which
    // returns `{ ok:false, error }` WITHOUT attempting any send. We assert the
    // structured `{ ok, error }` shape here (not over a JSON-RPC transport error)
    // with NO live system side effect.
    //
    // HERMETIC: this test deliberately does NOT drive `messaging.test_send` on the
    // (now-paired) iMessage channel by default. On macOS that path reaches
    // `RealMessageSender::send` → osascript/Messages.app — a live system side
    // effect that can pop an Automation TCC prompt and is non-hermetic in CI. The
    // structured-result shape and the host-gate are both fully proven without it.
    // The live iMessage send is opt-in only, behind `CAR_TEST_LIVE_IMESSAGE` (a
    // developer on a real Mac with Automation granted can set it to additionally
    // exercise the real send).
    let test_off = call(
        &mut ws,
        "h5",
        "messaging.test_send",
        serde_json::json!({ "channel": "slack" }),
    )
    .await;
    assert!(
        test_off.get("error").is_none(),
        "test_send returns a structured result, not a JSON-RPC error: {test_off}"
    );
    assert_eq!(
        test_off["result"]["ok"], false,
        "slack test_send is not ok (off / unsupported) — and attempts no send"
    );
    assert!(
        test_off["result"]["error"].is_string(),
        "an actionable error string is returned: {test_off}"
    );

    // 7. OPT-IN live iMessage send. Skipped by default to keep `cargo test`
    // hermetic (no osascript / no Automation TCC prompt). When opted in on a real
    // Mac, it still must return a STRUCTURED `{ ok, error }` — never a transport
    // error — proving the handler validates and records rather than throwing.
    if std::env::var("CAR_TEST_LIVE_IMESSAGE").is_ok() {
        let test = call(&mut ws, "h6", "messaging.test_send", serde_json::json!({})).await;
        assert!(
            test.get("error").is_none(),
            "live iMessage test_send returns a structured result, not a JSON-RPC error: {test}"
        );
        assert!(
            test["result"].get("ok").is_some(),
            "live iMessage test_send result carries an `ok` field: {test}"
        );
    }
}