//! Proves the blocks `discovery` prints are blocks `fleet` can load. //! //! The two are coupled by nothing but a string - `Vendor::toml_value` has to //! match what `fleet::build_printer` matches on, and the fields rendered have //! to be the fields it requires. Unit tests on either side cannot catch a //! drift between them, and the symptom of a drift is the worst kind: an //! operator pastes in exactly what they were told to and the gateway refuses //! to start. //! //! So this renders a candidate for every vendor, fills in the credential the //! way a human would, and loads the result for real. use std::io::Write; use continuum_proxy::discovery::{Candidate, Source, Vendor}; use continuum_proxy::fleet; use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; /// Fills in the credential a human is told to supply, so the entry loads. fn with_credentials(entry: &str) -> String { entry .replace("access_code = \"\"", "access_code = \"12345678\"") .replace("api_key = \"\"", "api_key = \"an-api-key\"") } fn load(toml: &str) -> anyhow::Result> { let mut file = tempfile(); file.write_all(toml.as_bytes()).expect("write the fleet file"); fleet::load(&file.path) } /// A self-deleting temporary file, rather than a dependency for four lines. struct TempToml { path: std::path::PathBuf, handle: Option, } impl TempToml { fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> { self.handle.as_mut().expect("still open").write_all(bytes)?; self.handle.as_mut().expect("still open").flush() } } impl Drop for TempToml { fn drop(&mut self) { self.handle.take(); let _ = std::fs::remove_file(&self.path); } } fn tempfile() -> TempToml { // The pid and a counter, so two tests in one binary cannot collide. use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT: AtomicUsize = AtomicUsize::new(0); let path = std::env::temp_dir().join(format!("continuum-fleet-{}-{}.toml", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); let handle = std::fs::File::create(&path).expect("create a temp fleet file"); TempToml { path, handle: Some(handle) } } fn candidate(vendor: Vendor, host: &str) -> Candidate { let mut candidate = Candidate::new(host, vendor, Source::HttpApi); // Bambu needs one, and for every other vendor it is ignored. candidate.serial = Some("01S09B441900143".to_string()); candidate } #[test] fn every_rendered_entry_loads_as_the_printer_it_claims_to_be() { let cases = [ (Vendor::Bambu, "192.168.0.20"), (Vendor::Klipper, "192.168.0.50"), (Vendor::PrusaLink, "192.168.0.51"), (Vendor::Octoprint, "192.168.0.60"), (Vendor::PrusaSerial, "/dev/ttyACM0"), ]; for (vendor, host) in cases { let entry = candidate(vendor, host).to_toml_entry("found"); let fleet = load(&with_credentials(&entry)) .unwrap_or_else(|err| panic!("{vendor:?} rendered an entry fleet::load rejects: {err}\n{entry}")); assert_eq!(fleet.len(), 1, "{vendor:?}"); assert_eq!(fleet[0].base().id, "found", "{vendor:?}"); // The vendor string has to resolve to the matching handle, not just // to *a* handle - `octoprint` and `prusa_link` share a client, so a // mapping that collapsed them would still load and still be wrong. let matched = matches!( (vendor, &fleet[0]), (Vendor::Bambu, PrinterHandle::Bambu(_)) | (Vendor::Klipper, PrinterHandle::Klipper(_)) | (Vendor::PrusaLink | Vendor::Octoprint, PrinterHandle::OctoPrint(_)) | (Vendor::PrusaSerial, PrinterHandle::PrusaSerial(_)) ); assert!(matched, "{vendor:?} loaded as the wrong handle"); } } /// The flavour is the operator's answer to a question no probe can settle, so /// it has to survive the trip from the file into the printer. #[test] fn the_octoprint_flavour_an_operator_chose_reaches_the_client() { use continuum_proxy::printer::OctoPrintFlavour; for (vendor, expected) in [(Vendor::Octoprint, OctoPrintFlavour::Generic), (Vendor::PrusaLink, OctoPrintFlavour::PrusaLink)] { let entry = with_credentials(&candidate(vendor, "192.168.0.51").to_toml_entry("found")); let fleet = load(&entry).expect("loads"); let PrinterHandle::OctoPrint(printer) = &fleet[0] else { panic!("{vendor:?} should load as an OctoPrint-family client"); }; assert_eq!(printer.flavour, expected, "{vendor:?}"); } } /// An entry straight out of discovery, credential still blank, must be /// refused - and refused in a way that names the printer and the field. #[test] fn an_entry_with_the_credential_left_blank_is_refused_by_name() { for vendor in [Vendor::Bambu, Vendor::PrusaLink, Vendor::Octoprint] { let entry = candidate(vendor, "192.168.0.20").to_toml_entry("unfinished"); let Err(err) = load(&entry) else { panic!("{vendor:?}: a blank credential must not load:\n{entry}"); }; let message = err.to_string(); // This is the whole reason discovery prints instead of writing the // file: the failure is loud, and it says which printer and which // field. assert!(message.contains("unfinished"), "{vendor:?}: {message}"); assert!( message.contains("access_code") || message.contains("api_key"), "{vendor:?} must name the missing field: {message}" ); } } /// The other half of the loop: a printer the operator has already dealt with /// must stop being offered as a fresh find. /// /// Same coupling-by-a-string risk as the rest of this file, in the other /// direction. `fleet::configured` reads the fields discovery writes, so a /// rename on either side silently turns the filter off - and a filter that /// silently stops filtering looks exactly like a working one, except the /// console offers every printer the farm already runs. mod already_configured { use super::*; fn configured_from(toml: &str) -> fleet::Configured { let mut file = tempfile(); file.write_all(toml.as_bytes()).expect("write the fleet file"); fleet::configured(&file.path).expect("parse the fleet file") } #[test] fn a_rendered_candidate_is_recognised_in_the_file_it_was_pasted_into() { for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::Octoprint, Vendor::PrusaLink, Vendor::PrusaSerial] { let mut candidate = Candidate::new("192.168.0.77", vendor, Source::HttpApi); if vendor == Vendor::PrusaSerial { candidate.host = "/dev/ttyACM0".to_string(); } if vendor == Vendor::Bambu { candidate.serial = Some("00M09A382100000".to_string()); } let toml = with_credentials(&candidate.to_toml_entry("p")); let configured = configured_from(&toml); assert!( configured.mentions(Some(&candidate.host), candidate.serial.as_deref()), "a pasted {vendor:?} entry was not recognised as configured:\n{toml}" ); } } #[test] fn an_entry_whose_credential_is_still_blank_counts_as_configured() { // The exact moment this matters: the operator has pasted in what // discovery suggested and has not yet typed the access code off the // printer's screen. `fleet::load` refuses that entry - and must - // but re-offering the printer as a new find while they are mid-way // through adding it is noise at the worst possible time. let mut candidate = Candidate::new("192.168.0.78", Vendor::Bambu, Source::Certificate); candidate.serial = Some("00M09A382100001".to_string()); let toml = candidate.to_toml_entry("p"); assert!(load(&toml).is_err(), "a blank access_code should still fail to load"); assert!(configured_from(&toml).mentions(Some("192.168.0.78"), None)); } #[test] fn a_printer_at_a_new_address_is_recognised_by_its_serial() { // DHCP moved it. The serial is the machine; the address is a lease. let configured = configured_from( "[[printer]]\nid = \"p\"\nname = \"P1P\"\nvendor = \"bambu\"\n\ host = \"192.168.0.20\"\nsn = \"00M09A382100000\"\naccess_code = \"12345678\"\n", ); assert!(configured.mentions(Some("192.168.0.99"), Some("00M09A382100000"))); // Case is not a difference: a serial read out of a TLS certificate // has no guaranteed case. assert!(configured.mentions(None, Some("00m09a382100000"))); // And something genuinely new is still new. assert!(!configured.mentions(Some("192.168.0.99"), Some("00M09A382100002"))); } #[test] fn a_missing_fleet_file_means_nothing_is_configured_rather_than_an_error() { // A gateway with no fleet yet is the case discovery exists for, so // this is the normal path on a new box and not an edge case. let configured = fleet::configured(std::path::Path::new("/nonexistent/printers.toml")) .expect("a missing fleet file is not an error"); assert!(!configured.mentions(Some("192.168.0.20"), Some("00M09A382100000"))); } } /// The wire form a gateway reports, against the vendor and source strings the /// backend's `discovered_printer.schema.json` declares as enums. /// /// Those are a contract, not labels: an unrecognised value does not degrade /// gracefully at the far end, it makes the backend reject the frame. #[test] fn every_vendor_and_source_renders_a_value_the_backend_schema_accepts() { const VENDORS: [&str; 5] = ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"]; const SOURCES: [&str; 5] = ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"]; for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] { for source in [Source::Ssdp, Source::Certificate, Source::HttpApi, Source::UsbDescriptor, Source::SerialHandshake] { let mut candidate = Candidate::new("192.168.0.20", vendor, source); candidate.ambiguous_with = Some(Vendor::PrusaLink); let wire = candidate.to_wire(); assert!(VENDORS.contains(&wire.vendor), "vendor {:?} renders {:?}", vendor, wire.vendor); assert!(SOURCES.contains(&wire.source), "source {:?} renders {:?}", source, wire.source); assert_eq!(wire.ambiguous_with, Some("prusa_link")); assert_eq!(wire.driver_ready, vendor.driver_is_implemented()); } } } /// A candidate must not carry a credential up to the cloud, and the check is /// on the serialised bytes rather than the struct's fields. /// /// Reading the fields would only prove what the type already says. The risk /// is someone later adding a field to `Candidate` - an access code recovered /// from somewhere, a pinned certificate - and it reaching the wire because /// `to_wire` was never the place anyone thought to look. #[test] fn no_credential_shaped_field_reaches_the_wire() { let mut candidate = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate); candidate.serial = Some("00M09A382100000".to_string()); candidate.name = Some("P1P".to_string()); candidate.bind_state = Some("free".to_string()); let json = serde_json::to_string(&candidate.to_wire()).expect("serialise"); for forbidden in ["access_code", "api_key", "password", "token", "secret", "bind_state"] { assert!(!json.contains(forbidden), "{forbidden} reached the wire: {json}"); } // And the fields that should be there, are - or this test would pass on // an empty object. assert!(json.contains("00M09A382100000") && json.contains("\"vendor\":\"bambu\""), "{json}"); }