Files
continuum-proxy/tests/uplink_late_result.rs
octoturge b4948076a0
Windows discovery tool / windows (push) Successful in 1m24s
Trade the gateway token for an edge JWT on every connection
The proxy put its configured gateway_token straight into the upgrade's
Authorization header, but the backend only accepts a one-hour edge JWT
(scope "edge") there, minted by POST /auth/edge-token from the farm's edge
proxy token. Nothing in the proxy ever made that call, so a gateway
configured with the token the console hands out could not connect at all,
and one given a hand-minted JWT (which is what every test did) would have
been locked out an hour later on its first reconnect.

Each connection attempt now starts by trading gateway_token for a fresh JWT,
at an address derived from the socket URL so there is one to configure. A
401 is reported as what it means (token rotated or wrong), since retrying
will not fix it. Config's Debug no longer prints the token.

tests/uplink_late_result.rs now plays the cloud's exchange endpoint and
asserts the socket opens with the minted JWT, not the configured token.
.env.example and continuum.example.toml say what the token is.
2026-10-29 15:10:27 +00:00

180 lines
7.7 KiB
Rust

//! Does a command that outlives the answer already given for it report back?
//!
//! A `start_print` is acknowledged the moment it is accepted, because the
//! transfer takes minutes and the cloud gives up after ten seconds. So its
//! real outcome can only ever arrive late, and before the outbound queue
//! existed there was nowhere for it to arrive from: the uplink's `select!`
//! loop was the only writer and a spawned task could not reach the socket.
//! A failed start therefore reported success and the reason stayed in this
//! gateway's log, on a machine in someone else's workshop.
//!
//! This stands up a real websocket server and runs the real `uplink::run`
//! against it. **It touches no printer and makes no outbound request**: the
//! fleet is empty, and `dispatch` checks the fleet for the printer id
//! *before* downloading anything, so the command fails on a lookup.
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use continuum_proxy::config::Config;
use continuum_proxy::uplink;
/// Long enough for a loopback round trip, short enough that a regression
/// fails the test instead of hanging the suite.
const WAIT: Duration = Duration::from_secs(10);
fn config(port: u16) -> Config {
Config {
gateway_id: "test-gw".into(),
farm_id: "test-farm".into(),
gateway_token: "test-token".into(),
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
// is handed its fleet rather than loading one - but Config has no
// Default, so these have to say something.
go2rtc_bin: "/nonexistent-go2rtc".into(),
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(),
store_path: "/nonexistent/continuum.db".into(),
// Off, which is the default and also the only safe thing in a test:
// an enabled one would sweep whatever network the test host is on.
discovery: Default::default(),
}
}
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
/// callers filter; `interval` fires on its first tick, which means a
/// heartbeat arrives immediately after the hello.
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let frame = tokio::time::timeout(WAIT, ws.next())
.await
.expect("timed out waiting for a frame from the gateway")
.expect("the gateway closed the connection")
.expect("websocket error");
if let Message::Text(text) = frame {
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
}
}
}
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
/// the gateway sent its farm and long-lived token, and answers with a JWT.
/// Returns the request text so the caller can assert on it.
async fn answer_token_exchange(listener: &TcpListener) -> String {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
assert!(n > 0, "the gateway hung up before finishing its token request");
request.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&request).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let length = head
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
.unwrap_or(0);
if body.len() >= length {
break;
}
}
}
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
stream
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
.await
.unwrap();
String::from_utf8_lossy(&request).into_owned()
}
#[tokio::test]
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
let (outbound, inbox) = uplink::channel();
tokio::spawn(uplink::run(config(port), fleet, None, outbound, inbox));
// The gateway trades its long-lived token for an edge JWT first, and the
// JWT - not the token it was configured with - is what opens the socket.
let exchange = answer_token_exchange(&listener).await;
assert!(exchange.starts_with("POST /auth/edge-token "), "{exchange}");
assert!(exchange.contains(r#""farmId":"test-farm""#) && exchange.contains(r#""edgeProxyToken":"test-token""#), "{exchange}");
let (stream, _) = listener.accept().await.unwrap();
let mut upgrade_auth = String::new();
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |request: &Request, response: Response| {
upgrade_auth = request.headers().get("authorization").unwrap().to_str().unwrap().to_string();
Ok(response)
})
.await
.unwrap();
assert_eq!(upgrade_auth, "Bearer minted-edge-jwt", "the socket must be opened with the minted JWT");
let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
ws.send(Message::Text(
json!({
"type": "command",
"command_id": "cmd-1",
"printer_id": "not-in-this-fleet",
"command": {
"action": "start_print",
"job_id": "7c9e6679-0000-4000-8000-000000000000",
"file_name": "cube.gcode.3mf",
// Never fetched: the fleet lookup fails first. Port 1 so a
// regression that *did* reach the network fails fast and
// loudly rather than hanging on a real host.
"url": "http://127.0.0.1:1/never-fetched",
}
})
.to_string(),
))
.await
.unwrap();
let mut results = Vec::new();
while results.len() < 2 {
let msg = next_json(&mut ws).await;
if msg["type"] == "command_result" {
results.push(msg);
}
}
// Two results for one command, in this order, and that is the contract -
// not a bug to be deduplicated away. The first is "accepted, working on
// it", which is all the cloud ever trusted a start reply to mean; the
// second is what actually happened.
assert_eq!(results[0]["command_id"], "cmd-1");
assert_eq!(results[0]["ok"], true, "a start is acknowledged on acceptance");
assert!(results[0]["error"].is_null());
assert_eq!(results[1]["command_id"], "cmd-1", "the late result reuses the id");
assert_eq!(results[1]["ok"], false);
// The reason is the entire point of the queue: without it the cloud sees
// a job that never started printing and can say nothing about why.
let reason = results[1]["error"].as_str().expect("a failure carries its reason");
assert!(
reason.contains("not-in-this-fleet"),
"the reason should name the printer that could not be found, got: {reason}"
);
}