Files
ruvnet--RuView/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs
T
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
2026-07-26 14:37:56 -07:00

334 lines
13 KiB
Rust

//! Boots the REAL `sensing-server` binary and probes BOTH listeners.
//!
//! # Why this test exists
//!
//! Two authentication bypasses shipped in the ADR-272 work, and 526 green unit
//! tests could not see either, because every auth test in this crate builds its
//! OWN `Router` with a hand-picked subset of routes. A synthetic router can
//! never observe how the real one is assembled — and both defects were assembly:
//!
//! 1. The dedicated WebSocket listener (`--ws-port`) was constructed with only
//! host validation. `require_bearer` was never applied to it at all. That is
//! the port the shipped UI actually connects to
//! (`ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765), so the
//! earlier fix protected a path the browser never takes.
//! 2. `/ws/field` was `.merge()`d AFTER the auth layer on the HTTP router. In
//! axum a layer wraps only what is already registered, so merging afterwards
//! silently exempts those routes.
//!
//! Both were found by adversarial review, not by the suite. This test closes
//! that gap: it runs the actual binary, so it sees the actual wiring.
//!
//! It deliberately asserts on **ports and transports**, not on handler logic —
//! handler behaviour is covered by the unit suites. What is unique here is that
//! nothing is synthetic: real process, real listeners, real TCP.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const TOKEN: &str = "integration-test-secret";
/// Reserve a port by binding and immediately releasing it.
///
/// Mildly racy, which is why each test reserves its own set and the server is
/// given several seconds to come up: a collision surfaces as a boot failure,
/// not as a false pass.
fn free_port() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
let p = l.local_addr().unwrap().port();
drop(l);
p
}
struct Server {
child: Child,
http: u16,
ws: u16,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Server {
/// Spawn the real binary. `env` lets a test choose the auth configuration.
///
/// PANICS rather than returning `None` on failure. This used to return an
/// `Option` that every test turned into `return`, which meant all five
/// assertions were skipped precisely when the server was broken — including
/// broken BY an auth change. A boot failure printed one line that `cargo
/// test` swallows without `--nocapture` and reported `5 passed`. The only
/// test in the suite that observes real wiring disarmed itself exactly when
/// it mattered; a boot-time panic in the auth path would have shipped green.
fn start(env: &[(&str, &str)]) -> Self {
// `free_port()` releases the port before the child binds it, so under
// parallel `cargo test` execution another test's server can grab the
// same ephemeral port first (observed as `Os { code: 10048/98, kind:
// AddrInUse }` on the child's actual bind, distinct from a genuine
// auth-wiring break). Retry a bounded number of times on THAT specific
// signature only — any other boot failure (including a real wiring
// regression) still panics on the first attempt, preserving the
// fail-loud property described above.
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
if await_ready(http, ws) {
return Server { child, http, ws };
}
let mut err = String::new();
if let Some(mut s) = child.stderr.take() {
let _ = s.read_to_string(&mut err);
}
let _ = child.kill();
let _ = child.wait();
let looks_like_port_collision =
err.contains("AddrInUse") || err.contains("Address already in use")
|| err.contains("code: 10048") || err.contains("code: 98");
if looks_like_port_collision && attempt < MAX_ATTEMPTS {
continue;
}
panic!(
"sensing-server did not become ready on :{http} (http) and :{ws} (ws) \
within 30s (attempt {attempt}/{MAX_ATTEMPTS}). This is a FAILURE, not a skip — \
the wiring assertions below cannot run, and a boot-time break in the auth path \
is exactly what they exist to catch.\n\
--- server stderr ---\n{err}"
);
}
unreachable!("loop always returns or panics");
}
}
fn await_ready(http: u16, ws: u16) -> bool {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", http)).is_ok()
&& TcpStream::connect(("127.0.0.1", ws)).is_ok()
{
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
false
}
/// One raw HTTP/1.1 request; returns the status code.
fn status(port: u16, method: &str, path: &str, headers: &[(&str, &str)]) -> u16 {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut s = TcpStream::connect(addr).expect("connect");
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
for (k, v) in headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("Connection: close\r\n\r\n");
s.write_all(req.as_bytes()).expect("write");
let mut line = String::new();
BufReader::new(&mut s).read_line(&mut line).expect("status line");
line.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
}
/// A genuine WebSocket upgrade. 101 means the connection was ACCEPTED.
fn ws_upgrade(port: u16, path: &str, bearer: Option<&str>) -> u16 {
let mut headers: Vec<(&str, &str)> = vec![
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Version", "13"),
("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
];
let auth;
if let Some(b) = bearer {
auth = format!("Bearer {b}");
headers.push(("Authorization", &auth));
}
// Not `Connection: close` — that would contradict the upgrade.
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut s = TcpStream::connect(addr).expect("connect");
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
for (k, v) in &headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("\r\n");
s.write_all(req.as_bytes()).expect("write");
let mut buf = [0u8; 256];
let n = s.read(&mut buf).expect("read");
let head = String::from_utf8_lossy(&buf[..n]);
let line = head.lines().next().unwrap_or_default();
line.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
}
/// Every WebSocket path, on every listener. This list is the point of the test.
const WS_PATHS: &[&str] = &["/ws/sensing", "/ws/introspection", "/api/v1/stream/pose", "/ws/field"];
/// Non-WebSocket routes that carry sensing data and must be gated.
///
/// `/api/field` is here because it was NOT gated: it serves the same signed
/// `FieldEvent` stream as `/ws/field`, but sits outside `/api/v1/`, and the gate
/// protected `/api/v1/*` by prefix. `/ws/field` was gated in this PR and its
/// REST twin, one path segment over, returned 200 to an anonymous caller on
/// both listeners. That is why the gate is now deny-by-default.
const PROTECTED_REST_PATHS: &[&str] = &["/api/field", "/api/v1/models"];
#[test]
fn with_auth_on_no_listener_serves_sensing_data_anonymously() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
for path in PROTECTED_REST_PATHS {
assert_eq!(
status(port, "GET", path, &[]),
401,
"{label} port served {path} to an anonymous caller"
);
// And the credential must actually work, or the assertion above
// could pass because the route simply does not exist.
let ok = status(port, "GET", path, &[("Authorization", &format!("Bearer {TOKEN}"))]);
assert_ne!(ok, 401, "{label} port {path} rejected a VALID bearer");
}
}
}
#[test]
fn the_dashboard_shell_and_sign_in_stay_reachable_when_auth_is_on() {
// Deny-by-default must not lock the user out of the page that renders the
// sign-in button, or of sign-in itself. This is the other half of the
// allowlist: too tight is as broken as too loose, just louder.
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for path in ["/health", "/oauth/status"] {
assert_ne!(
status(server.http, "GET", path, &[]),
401,
"{path} must stay anonymous — sign-in depends on it"
);
}
}
#[test]
fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
// Control first: if REST is not gated, the server is misconfigured and the
// WebSocket assertions below would pass for the wrong reason.
assert_eq!(
status(server.http, "GET", "/api/v1/models", &[]),
401,
"REST must be gated, or this test proves nothing"
);
for &port_label in &["http", "ws"] {
let port = if port_label == "http" { server.http } else { server.ws };
for path in WS_PATHS {
let code = ws_upgrade(port, path, None);
assert_ne!(
code, 101,
"{port_label} port ACCEPTED an unauthenticated upgrade to {path} — \
this is the bypass that shipped twice"
);
assert_eq!(
code, 401,
"{port_label} port {path} should refuse with 401, got {code}"
);
}
}
}
#[test]
fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
// Native clients (Python, CLI, MCP) are not browser-constrained and must be
// able to authenticate a WebSocket without the ticket round-trip.
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", Some(TOKEN)),
101,
"{label} port must accept a valid bearer on the upgrade"
);
}
}
#[test]
fn with_auth_off_both_listeners_stay_open() {
// The compatibility promise: an unconfigured deployment sees no change.
let server = Server::start(&[]);
assert_eq!(status(server.http, "GET", "/api/v1/models", &[]), 200);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", None),
101,
"{label} port must stay open when no credential is configured"
);
}
}
#[test]
fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() {
let server = Server::start(&[
("RUVIEW_API_TOKEN", TOKEN),
("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"),
]);
// The hatch is scoped to WebSockets on purpose. If it ever widened to REST
// it would be a bypass wearing a migration label.
assert_eq!(
status(server.http, "GET", "/api/v1/models", &[]),
401,
"the escape hatch must not weaken REST"
);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", None),
101,
"{label} port should be open while the hatch is set"
);
}
}
#[test]
fn health_stays_anonymous_on_both_listeners() {
// Documented exemption (ADR-272): orchestrator probes are anonymous by
// design. Pinned so it is a decision, not an accident nobody re-checks.
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
status(port, "GET", "/health", &[]),
200,
"{label} port /health must remain anonymous"
);
}
}