Files
continuum-proxy/tests/uplink_protocol.rs
octoturge ad150cd29e Send the edge token on the upgrade, not in the hello
The uplink put its edge JWT in a `token` field on the `hello` message.
It now goes in an `Authorization: Bearer` header on the WebSocket upgrade
request, which the backend verifies before the socket exists - so a bad
token is an HTTP 401 here rather than a connection that opens and is then
closed, and the credential stays out of the URL, where it would land in
every access and proxy log on the way. `Authorization` is the header
those tools already know to redact.

`farm_id` stays on the hello even though the token carries the same
claim: the backend checks the two against each other rather than
trusting the message, so sending it is how a configuration mismatch gets
caught instead of silently working against the wrong farm.

The cost is that tokio-tungstenite no longer derives the handshake from
a URL for us - a request we build ourselves needs its own `Host`,
`Connection`, `Upgrade`, `Sec-WebSocket-Version` and
`Sec-WebSocket-Key`, and a handshake missing any of them is rejected
rather than ignored. `host_header` errors rather than guessing if the URL
has no authority, because a wrong `Host` fails the handshake far less
legibly than this does.

Verified against a real backend rather than assumed: a valid token
handshakes to HTTP 101 and the server reads the farm from the token, and
a forged one is refused with 401 before any socket exists.

64 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok.
2026-10-17 14:00:05 +00:00

302 lines
14 KiB
Rust

//! The other half of the `/ws/edge/v1` contract test.
//!
//! `continuum-backend/tests/edge_protocol.test.ts` checks that what this
//! gateway sends is what the backend accepts. This checks the reverse: that
//! every message the backend sends is one this gateway can actually read, and
//! that what this gateway writes matches the schema it claims to follow.
//!
//! Worth having on both sides because the two ends had drifted into entirely
//! different protocols - a handshake the gateway never sent, telemetry shaped
//! one printer per message instead of a batch, printer ids a gateway has no
//! way to know - and nothing failed anywhere. Both test files are hand-kept
//! mirrors; the canonical shape is
//! `continuum-schemas/schemas/continuum/v1/edge/`.
use continuum_proxy::printer::PrintControl;
use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
/// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend
/// changes one of these, this test is where it should break.
const BACKEND_MESSAGES: &[&str] = &[
r#"{"type":"hello_ack","session_id":"3f2b1c5e-0000-4000-8000-000000000000"}"#,
r#"{"type":"hello_error","message":"invalid edge token"}"#,
r#"{"type":"heartbeat_ack","seq":7}"#,
r#"{"type":"command","command_id":"9a7d2f10-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#,
r#"{"type":"command","command_id":"9a7d2f11-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"send_gcode_line","line":"M106 S0"}}"#,
r#"{"type":"command","command_id":"9a7d2f12-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"pause"}}"#,
r#"{"type":"command","command_id":"9a7d2f13-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"resume"}}"#,
r#"{"type":"command","command_id":"9a7d2f14-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"stop"}}"#,
];
#[test]
fn every_backend_message_deserializes() {
for raw in BACKEND_MESSAGES {
let parsed: Result<ServerMessage, _> = serde_json::from_str(raw);
assert!(parsed.is_ok(), "backend sends this and the gateway cannot read it:\n {raw}\n {:?}", parsed.err());
}
}
#[test]
fn a_command_arrives_with_its_payload_intact() {
let raw = r#"{"type":"command","command_id":"cmd-1","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#;
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
ServerMessage::Command { command_id, printer_id, command } => {
assert_eq!(command_id, "cmd-1");
// The gateway's own id from printers.toml, not a cloud UUID - the
// backend translates on the way out.
assert_eq!(printer_id, "p1p");
match command {
PrinterCommand::SetFanSpeed { percent } => assert_eq!(percent, 40),
other => panic!("wrong command variant: {other:?}"),
}
}
other => panic!("wrong message variant: {other:?}"),
}
}
/// Each control verb has to arrive as the right `PrintControl`, not merely
/// parse. A silent mis-mapping here would send a stop where a pause was
/// asked for, which on a real machine is the difference between a print that
/// can be resumed and one that is over.
#[test]
fn each_print_control_verb_maps_to_its_own_action() {
for (verb, expected) in [
("pause", PrintControl::Pause),
("resume", PrintControl::Resume),
("stop", PrintControl::Stop),
] {
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
);
match serde_json::from_str::<ServerMessage>(&raw).expect("should parse") {
ServerMessage::Command { command: PrinterCommand::ControlPrint { control }, .. } => {
assert_eq!(control, expected, "'{verb}' mapped to the wrong action");
// And the wire word the vendor layer sends has to match the
// wire word that arrived - `as_str` is what builds the Bambu
// command name.
assert_eq!(control.as_str(), verb);
}
other => panic!("wrong variant for '{verb}': {other:?}"),
}
}
}
/// "cancel" is the trap: it is Moonraker's word, and the Klipper
/// implementation behind `control_print` really does send a cancel - but the
/// wire word is "stop". A verb only one vendor recognises must be rejected
/// here rather than reaching a gateway that cannot map it.
#[test]
fn a_vendor_specific_control_verb_is_rejected() {
for verb in ["cancel", "abort", "Pause", ""] {
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
);
assert!(
serde_json::from_str::<ServerMessage>(&raw).is_err(),
"'{verb}' parsed as a print control verb and should not have"
);
}
}
/// The schema's `StartPrint` variant, in the shape the backend dispatches.
/// Every field has to arrive intact: `url` is what gets downloaded and
/// `file_name` is what the printer is told to call it, so a swapped or
/// dropped one prints the wrong thing or nothing.
#[test]
fn a_start_print_command_arrives_with_its_fields_intact() {
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => {
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
assert_eq!(file_name, "plate_1.3mf");
assert_eq!(
url,
"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"
);
}
other => panic!("wrong variant: {other:?}"),
}
}
/// The URL is a bearer credential until it expires, and `dispatch` logs
/// every command with `{:?}`.
#[test]
fn a_start_print_url_does_not_appear_in_debug_output() {
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"j","file_name":"plate_1.3mf","url":"https://objects.example.com/x?X-Amz-Signature=deadbeef"}}"#;
let parsed = serde_json::from_str::<ServerMessage>(raw).expect("should parse");
let shown = format!("{parsed:?}");
assert!(!shown.contains("deadbeef"), "signature leaked into a log line: {shown}");
assert!(shown.contains("plate_1.3mf"), "the file name should still be logged: {shown}");
}
/// Each field is load-bearing and none has a sensible default: a command
/// missing its URL must be rejected here, not discovered at the printer.
#[test]
fn a_start_print_missing_any_required_field_is_rejected() {
let full = [
("job_id", r#""job_id":"5b0e7c1a-0000-4000-8000-000000000000""#),
("file_name", r#""file_name":"plate_1.3mf""#),
("url", r#""url":"https://objects.example.com/jobs/abc.3mf""#),
];
for missing in 0..full.len() {
let fields: Vec<&str> =
full.iter().enumerate().filter(|(i, _)| *i != missing).map(|(_, (_, f))| *f).collect();
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"start_print",{}}}}}"#,
fields.join(",")
);
assert!(
serde_json::from_str::<ServerMessage>(&raw).is_err(),
"start_print without '{}' parsed and should not have:\n {raw}",
full[missing].0
);
}
}
#[test]
fn unknown_messages_are_rejected_rather_than_silently_accepted() {
// The old protocol. A gateway that accepted this would ack a handshake
// that never happened.
assert!(serde_json::from_str::<ServerMessage>(r#"{"type":"auth_ok","farmId":"f"}"#).is_err());
}
/// The bug this test exists for: serde emits `"job_name": null` for an empty
/// `Option` unless told not to, and the backend's validator reads a missing
/// key and an explicit null as different things. An idle printer has no job
/// name, so every batch containing one was rejected whole.
#[test]
fn empty_telemetry_fields_are_omitted_not_null() {
let msg = ClientMessage::Telemetry {
printers: vec![PrinterTelemetry {
printer_id: "p1p".into(),
name: "P1P".into(),
state: Some("IDLE".into()),
percent: Some(0),
// An idle printer is in no stage, so this is the second field
// after `job_name` that must be omitted rather than nulled.
stage: None,
layer: Some(0),
total_layers: Some(0),
remaining_minutes: Some(0),
job_name: None,
nozzle_temp: Some(23.375),
nozzle_target: Some(0.0),
bed_temp: Some(22.40625),
bed_target: Some(0.0),
print_error: Some(0),
active_alerts: Some(0),
// The common case, and the third field that must be omitted: a
// P1P reports that it has a camera without giving a URL for it.
camera_stream: None,
}],
};
let json = serde_json::to_string(&msg).expect("should serialize");
assert!(!json.contains("null"), "telemetry should omit empty fields, got:\n {json}");
assert!(!json.contains("job_name"), "an absent job name should not appear at all");
// The fields that *do* have values must still be there - an assertion on
// the absence of "null" alone would pass on an empty object.
assert!(json.contains(r#""state":"IDLE""#));
assert!(json.contains(r#""bed_temp":22.40625"#));
assert!(!json.contains("stage"), "an absent stage should not appear at all");
}
/// A stage of 0 means "printing" and must survive the wire, because
/// `skip_serializing_if = "Option::is_none"` on an `Option<i32>` is one
/// `unwrap_or_default` away from turning "printing" into "no stage". The
/// distinction matters: a printer at 14% and layer 0 is mid-bed-levelling if
/// the stage is 54 and genuinely stuck if it is 0.
#[test]
fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
let msg = ClientMessage::Telemetry {
printers: vec![PrinterTelemetry {
printer_id: "p1p".into(),
name: "P1P".into(),
state: Some("RUNNING".into()),
percent: Some(14),
stage: Some(0),
layer: Some(1),
total_layers: Some(150),
remaining_minutes: Some(39),
job_name: Some("cube-test".into()),
nozzle_temp: Some(255.2),
nozzle_target: Some(255.0),
bed_temp: Some(79.3),
bed_target: Some(80.0),
print_error: Some(0),
active_alerts: Some(0),
camera_stream: Some("p1p".into()),
}],
};
let json = serde_json::to_string(&msg).expect("should serialize");
assert!(json.contains(r#""stage":0"#), "a zero stage must be on the wire, got:\n {json}");
}
#[test]
fn a_successful_command_result_omits_the_error() {
let json = serde_json::to_string(&ClientMessage::CommandResult {
command_id: "cmd-1".into(),
ok: true,
error: None,
})
.expect("should serialize");
assert_eq!(json, r#"{"type":"command_result","command_id":"cmd-1","ok":true}"#);
}
#[test]
fn a_failed_command_result_carries_the_reason() {
let json = serde_json::to_string(&ClientMessage::CommandResult {
command_id: "cmd-1".into(),
ok: false,
error: Some("no printer with id 'nope' in this gateway's fleet".into()),
})
.expect("should serialize");
assert!(json.contains(r#""ok":false"#));
assert!(json.contains("no printer with id 'nope'"));
}
/// A gateway with no configured stream base URL must omit the field, for the
/// same reason the telemetry fields are omitted: the backend reads a missing
/// key and an explicit null differently, and an unconfigured gateway is the
/// default case, not an edge case.
#[test]
fn a_hello_without_a_stream_base_url_omits_it() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: None,
})
.expect("should serialize");
assert!(!json.contains("stream_base_url"), "got:\n {json}");
// Without this the assertion above would also pass on an empty object.
assert!(json.contains(r#""gateway_id":"gw-1""#));
}
#[test]
fn a_hello_carries_the_stream_base_url_when_one_is_configured() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: Some("http://gateway.local:1984".into()),
})
.expect("should serialize");
assert!(json.contains(r#""stream_base_url":"http://gateway.local:1984""#), "got:\n {json}");
}
/// The credential must not be in the handshake message. It travels in an
/// `Authorization` header on the upgrade instead, and a `token` field here
/// would mean the backend parsing a message from a peer that has proved
/// nothing - which is what this used to do.
#[test]
fn a_hello_carries_no_credential() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: None,
})
.expect("should serialize");
assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}");
assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#);
}