//! WebSocket-side smoke-test helper for the message bus. //! //! Hurl is HTTP-only — it can't do a WS upgrade, let alone read frames //! for later assertion. This binary is the WS half of the smoke test: //! opens `/api/rt/ws`, speaks JSON-RPC 2.0, and either collects events //! into a JSON file for shell assertions (`subscribe-and-collect`) or //! validates that an authz-denied subscribe returns the expected wire //! error code (`expect-denied`). //! //! Invocation (from `tests/api/rt_bus_check.sh`): //! //! ```bash //! rt-hurl-helper subscribe-and-collect \ //! --url ws://127.0.0.1:$PORT/api/rt/ws \ //! --token $USER_JWT \ //! --subscribe folder:$FOLDER_A \ //! --expect-events 1 \ //! --timeout 3s \ //! --output /tmp/rt_s1.json & //! //! rt-hurl-helper expect-denied \ //! --url ws://127.0.0.1:$PORT/api/rt/ws \ //! --token $USER2_JWT \ //! --subscribe folder:$FOLDER_A \ //! --reason no_read \ //! --timeout 2s //! ``` //! //! Exit codes: //! * 0 — expectation met. //! * 1 — expectation failed (wrong event, unexpected event, timeout //! without hitting the target, denied when expecting event, //! event when expecting denied). //! * 2 — protocol / connect error the shell can distinguish from a //! real assertion failure. //! //! JSON output shape for `subscribe-and-collect` (written to `--output`): //! //! ```jsonc //! { //! "subscribed": ["folder:..."], //! "events": [ { "topic": "folder:...", "event": "file_created", //! "data": { ... } } ], //! "timed_out": false //! } //! ``` use std::process::ExitCode; use std::time::Duration; use futures::{SinkExt, StreamExt}; use serde_json::{Value, json}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; // ════════════════════════════════════════════════════════════════════════════ // CLI parsing (minimal, dependency-free) // ════════════════════════════════════════════════════════════════════════════ struct Args { mode: Mode, url: String, /// Either `--token ` (Authorization: Bearer path — the original /// helper flow) or `--ticket ` (Sec-WebSocket-Protocol path /// — exercises F). Exactly one MUST be set; parse_args enforces. auth: WsAuth, subscribe: Vec, expect_events: Option, reason: Option, timeout: Duration, output: Option, /// Optional path the helper `touch`es the instant EVERY requested /// `--subscribe` topic has been ack'd by the server. Shell tests /// wait on this file before firing the upload that publishes to /// the topic, closing the "sleep 0.4 hoping the subscribe landed /// in time" race that occasionally dropped events on slow / /// cold-cache runs. Off by default; only used by the smoke test. ready_file: Option, } /// How the helper authenticates the WS upgrade. Mirrors the two paths /// `rt_ws_handler::authenticate_upgrade` accepts. enum WsAuth { Bearer(String), Ticket(String), } enum Mode { SubscribeAndCollect, ExpectDenied, } fn parse_duration(s: &str) -> Result { // Accept `s`, `ms`, or a bare integer (interpreted as // seconds). Kept small — hurl and shell are the only callers. let s = s.trim(); if let Some(num) = s.strip_suffix("ms") { num.parse::() .map(Duration::from_millis) .map_err(|_| format!("bad duration: {s}")) } else if let Some(num) = s.strip_suffix('s') { num.parse::() .map(Duration::from_secs) .map_err(|_| format!("bad duration: {s}")) } else { s.parse::() .map(Duration::from_secs) .map_err(|_| format!("bad duration: {s}")) } } fn parse_args() -> Result { let mut it = std::env::args().skip(1); let mode = match it.next().as_deref() { Some("subscribe-and-collect") => Mode::SubscribeAndCollect, Some("expect-denied") => Mode::ExpectDenied, Some(other) => return Err(format!("unknown mode: {other}")), None => return Err("mode is required".into()), }; let mut url = None; let mut token = None; let mut ticket = None; let mut subscribe = Vec::new(); let mut expect_events = None; let mut reason = None; let mut timeout = Duration::from_secs(3); let mut output = None; let mut ready_file = None; while let Some(flag) = it.next() { let value = it .next() .ok_or_else(|| format!("flag {flag} requires a value"))?; match flag.as_str() { "--url" => url = Some(value), "--token" => token = Some(value), "--ticket" => ticket = Some(value), "--subscribe" => subscribe.push(value), "--expect-events" => { expect_events = Some( value .parse::() .map_err(|_| format!("--expect-events not a number: {value}"))?, ); } "--reason" => reason = Some(value), "--timeout" => timeout = parse_duration(&value)?, "--output" => output = Some(value), "--ready-file" => ready_file = Some(value), other => return Err(format!("unknown flag: {other}")), } } // Exactly one credential MUST be set. Emitting a specific error // makes shell-script drift ("forgot to swap --token for --ticket") // debuggable at a glance. let auth = match (token, ticket) { (Some(_), Some(_)) => return Err("pass exactly one of --token or --ticket".into()), (Some(t), None) => WsAuth::Bearer(t), (None, Some(t)) => WsAuth::Ticket(t), (None, None) => return Err("--token or --ticket required".into()), }; Ok(Args { mode, url: url.ok_or("--url required")?, auth, subscribe, expect_events, reason, timeout, output, ready_file, }) } // ════════════════════════════════════════════════════════════════════════════ // Main // ════════════════════════════════════════════════════════════════════════════ #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { let args = match parse_args() { Ok(a) => a, Err(e) => { eprintln!("rt-hurl-helper: {e}"); return ExitCode::from(2); } }; let result = match args.mode { Mode::SubscribeAndCollect => subscribe_and_collect(args).await, Mode::ExpectDenied => expect_denied(args).await, }; match result { Ok(()) => ExitCode::SUCCESS, Err(HelperError::Expectation(msg)) => { eprintln!("rt-hurl-helper: expectation failed: {msg}"); ExitCode::from(1) } Err(HelperError::Protocol(msg)) => { eprintln!("rt-hurl-helper: protocol error: {msg}"); ExitCode::from(2) } } } // ════════════════════════════════════════════════════════════════════════════ // Errors // ════════════════════════════════════════════════════════════════════════════ enum HelperError { /// The wire behaved OK but didn't match what the test expected — /// e.g. a `subscribed` ack when we expected `denied`, or fewer /// events than requested before timeout. Exit 1: test failure. Expectation(String), /// Something is broken at the transport/JSON layer — connect /// refused, malformed frame, TLS handshake failed. Exit 2: /// infrastructure problem, not a test result. Protocol(String), } impl From for HelperError { fn from(e: E) -> Self { HelperError::Protocol(e.to_string()) } } // ════════════════════════════════════════════════════════════════════════════ // WS connection // ════════════════════════════════════════════════════════════════════════════ /// Open a WS connection to `url` with the given [`WsAuth`] applied. /// /// - `Bearer(jwt)` sets `Authorization: Bearer ` on the upgrade /// — the programmatic-client path. /// - `Ticket(uuid)` sets `Sec-WebSocket-Protocol: oxi.ticket.` /// — the browser-equivalent path used by F's smoke scenarios. /// /// The subprotocol prefix matches /// `infrastructure::services::rt_ticket_store::SUBPROTOCOL_PREFIX`; kept /// as a literal here so the test binary has no dependency on the /// application crate. async fn connect_ws( url: &str, auth: &WsAuth, ) -> Result< tokio_tungstenite::WebSocketStream>, HelperError, > { let mut req = url .into_client_request() .map_err(|e| HelperError::Protocol(format!("bad url: {e}")))?; match auth { WsAuth::Bearer(token) => { let bearer = format!("Bearer {token}"); req.headers_mut().insert( "Authorization", HeaderValue::from_str(&bearer) .map_err(|e| HelperError::Protocol(format!("bad token: {e}")))?, ); } WsAuth::Ticket(ticket) => { let subprotocol = format!("oxi.ticket.{ticket}"); req.headers_mut().insert( "Sec-WebSocket-Protocol", HeaderValue::from_str(&subprotocol) .map_err(|e| HelperError::Protocol(format!("bad ticket: {e}")))?, ); } } let (ws, _resp) = tokio_tungstenite::connect_async(req) .await .map_err(|e| HelperError::Protocol(format!("connect failed: {e}")))?; Ok(ws) } // ════════════════════════════════════════════════════════════════════════════ // Mode: subscribe-and-collect // ════════════════════════════════════════════════════════════════════════════ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { if args.subscribe.is_empty() { return Err(HelperError::Protocol( "--subscribe required for subscribe-and-collect".into(), )); } let expect_events = args.expect_events.unwrap_or(0); let mut ws = connect_ws(&args.url, &args.auth).await?; // Subscribe to every requested topic; track pending request ids so // we know when all acks have arrived before we start counting // events. let mut subscribed: Vec = Vec::new(); let mut pending_subs: std::collections::HashMap = std::collections::HashMap::new(); for (i, topic) in args.subscribe.iter().enumerate() { let req_id = (i as u64) + 1; let frame = json!({ "jsonrpc": "2.0", "id": req_id, "method": "rt.subscribe", "params": { "topic": topic }, }); ws.send(Message::Text(frame.to_string().into())).await?; pending_subs.insert(req_id, topic.clone()); } let mut events: Vec = Vec::new(); // Server-initiated eviction notifications (`rt.revoked`) — captured // separately from `rt.event` so scenarios can assert on eviction // scoping (evicted topic vs. surviving topic) without conflating // them with real content events. let mut revoked: Vec = Vec::new(); // Count server-initiated protocol Pings so scenarios can assert the // keepalive fires. tokio-tungstenite queues an auto-Pong on the next // write path, so we don't need to send one ourselves; we just observe // the frame. let mut pings_received: usize = 0; let mut timed_out = false; let deadline = tokio::time::Instant::now() + args.timeout; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { timed_out = true; break; } // Exit early: all acks received AND enough events collected. if pending_subs.is_empty() && events.len() >= expect_events { break; } let msg = match timeout(remaining, ws.next()).await { Ok(Some(Ok(m))) => m, Ok(Some(Err(e))) => { return Err(HelperError::Protocol(format!("ws error: {e}"))); } Ok(None) => { return Err(HelperError::Protocol("connection closed by peer".into())); } Err(_) => { timed_out = true; break; } }; let text = match msg { Message::Text(t) => t, Message::Ping(_) => { // Server-initiated keepalive — observable proof that the // interval is firing. tokio-tungstenite queues an // auto-Pong on the next flush; nothing to do here. pings_received += 1; continue; } _ => continue, // pong/binary/close — not asserted on }; let value: Value = serde_json::from_str(&text) .map_err(|e| HelperError::Protocol(format!("bad frame: {e}: {text}")))?; // Response to a subscribe request? if let Some(id_num) = value.get("id").and_then(|v| v.as_u64()) { let topic = pending_subs.remove(&id_num); if let Some(err) = value.get("error") { return Err(HelperError::Expectation(format!( "subscribe to {} denied: {}", topic.as_deref().unwrap_or(""), err, ))); } if let Some(topic) = topic { subscribed.push(topic); } // Every requested subscribe is now ack'd — signal the // orchestrator that publishes targeted at these topics // will land on a live subscriber. See `Args::ready_file` // for the race this closes. Empty content is fine; the // shell only checks existence, not payload. Errors are // logged to stderr but not fatal: the smoke test's // `wait_ready` timeout will surface the failure with // more context than a mid-run panic here. if pending_subs.is_empty() && let Some(path) = args.ready_file.as_deref() && let Err(e) = std::fs::write(path, b"") { eprintln!("rt-hurl-helper: could not touch --ready-file {path}: {e}"); } continue; } // Notification (id-less)? let method = value.get("method").and_then(|v| v.as_str()).unwrap_or(""); match method { "rt.event" => { if let Some(params) = value.get("params") { events.push(params.clone()); } } "rt.revoked" => { // Server evicted one of our subscriptions. Record for // the shell to assert on; do NOT increment `events` — // eviction is orthogonal to content delivery. if let Some(params) = value.get("params") { revoked.push(params.clone()); } } _ => { // Unknown notification method — ignored. `rt.pong` and // future server-pushed methods land here silently. } } } // Assertion: at least `expect_events` collected before timeout. let met = events.len() >= expect_events; // Always write output (even on failure) so the shell can diff. if let Some(path) = args.output.as_ref() { let summary = json!({ "subscribed": subscribed, "events": events, "revoked": revoked, "pings_received": pings_received, "timed_out": timed_out, }); std::fs::write(path, serde_json::to_vec_pretty(&summary).unwrap()) .map_err(|e| HelperError::Protocol(format!("write output: {e}")))?; } if !met { return Err(HelperError::Expectation(format!( "expected {} events, got {} ({}timeout)", expect_events, events.len(), if timed_out { "with " } else { "no " } ))); } Ok(()) } // ════════════════════════════════════════════════════════════════════════════ // Mode: expect-denied // ════════════════════════════════════════════════════════════════════════════ async fn expect_denied(args: Args) -> Result<(), HelperError> { let topic = args .subscribe .first() .ok_or_else(|| HelperError::Protocol("--subscribe required for expect-denied".into()))? .clone(); let mut ws = connect_ws(&args.url, &args.auth).await?; let req_id: u64 = 1; let frame = json!({ "jsonrpc": "2.0", "id": req_id, "method": "rt.subscribe", "params": { "topic": topic }, }); ws.send(Message::Text(frame.to_string().into())).await?; // Wait for the id-matched response with an `error` object. Any // notification arriving before the response is skipped — the // server should not fan out to a subscription that hasn't been // acked yet, but the check is robust to that ordering anyway. let deadline = tokio::time::Instant::now() + args.timeout; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Err(HelperError::Expectation( "timeout without a subscribe response".into(), )); } let msg = match timeout(remaining, ws.next()).await { Ok(Some(Ok(m))) => m, Ok(Some(Err(e))) => return Err(HelperError::Protocol(format!("ws error: {e}"))), Ok(None) => return Err(HelperError::Protocol("connection closed by peer".into())), Err(_) => { return Err(HelperError::Expectation( "timeout without a subscribe response".into(), )); } }; let Message::Text(text) = msg else { continue }; let value: Value = serde_json::from_str(&text) .map_err(|e| HelperError::Protocol(format!("bad frame: {e}: {text}")))?; // Match by id. let Some(id_num) = value.get("id").and_then(|v| v.as_u64()) else { continue; }; if id_num != req_id { continue; } // Expect: error object present. let Some(err) = value.get("error") else { return Err(HelperError::Expectation(format!( "expected `error` object, got: {value}" ))); }; let message = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); if let Some(want) = args.reason.as_ref() && message != want { return Err(HelperError::Expectation(format!( "expected reason `{want}`, got `{message}` (full error: {err})" ))); } return Ok(()); } }