//! Join codes over the network: a file shared on the local network joined by its code (two //! engine sessions over loopback), what the browser's driver does with an online code, a server //! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out //! and resolving them is tested in its own crate, with the same link.) use std::net::{Ipv4Addr, TcpListener}; use std::sync::Arc; use std::time::{Duration, Instant}; use figcraft_collab::code::{self, Code, Local}; use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg}; use figcraft_engine::Session; use figcraft_engine::live::{Event, Status}; use serde_json::json; use tungstenite::{Bytes, Message}; use crate::driver::{Driver, Socket}; use crate::guest::GuestConfig; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; use crate::loopback_tests::{fast_limits, quick}; use crate::{NetLink, manage}; fn session(name: &str) -> Session { let mut s = Session::new(); s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick))); s.execute("pref.userName", &json!({"name": name})).unwrap(); s } /// Pump the sessions (as their windows would every frame) until `f` holds. fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) { let start = Instant::now(); loop { for s in all.iter_mut() { s.live_poll(); s.live_sync(); } if f(all) { return; } let states: Vec = all.iter().map(|s| s.live_summary().to_string()).collect(); assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}"); std::thread::sleep(Duration::from_millis(5)); } } fn live(s: &Session) -> bool { s.live().status == Status::Live && s.live().syncing() } fn layers(s: &Session) -> usize { s.doc().map_or(0, |d| d.node_count()) } #[test] fn a_local_share_is_joined_by_its_code_and_edits_sync() { let (mut host, mut guest) = (session("Hana"), session("Ada")); // This machine only, so the test needs no network; the code says where and the secret. let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap(); assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit"))); let shown = shared["code"].as_str().unwrap().to_string(); assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}"); let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") }; assert_eq!(local.ip, Ipv4Addr::LOCALHOST); assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port)); assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session"); assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network"); // Typed the way people type: lower case, spaces for the dashes. let typed = shown.to_lowercase().replace('-', " "); assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting"); pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1); assert_eq!(guest.live_summary()["access"], "edit"); // Edits go both ways. let before = layers(&host); host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1); guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap(); pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2); // The link joins the same session (an older app, or a paste). let mut third = session("Bo"); third.execute("collab.join", &json!({"link": shared["link"]})).unwrap(); pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2); // Stopping ends it: the code no longer joins. assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active()); let mut late = session("Cy"); late.execute("collab.join", &json!({"code": shown})).unwrap(); pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. })); assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status); } #[test] fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { let (mut host, mut guest) = (session("Hana"), session("Ada")); let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap(); let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; // A code for the same address with another secret: well formed, refused by the host. let guess = Local::new(local.ip, local.port, local.secret ^ 1).code(); guest.execute("collab.join", &json!({"code": guess})).unwrap(); pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. })); assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status); assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string(); assert!(e.contains("share it online"), "{e}"); } #[test] fn links_can_name_a_join_code() { let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap(); assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", "")); assert_eq!(found.as_deref(), Some("K7M2QX9TD")); let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap(); assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD"))); // A link with a file and a token is read as before. let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None)); for bad in [ "figcraft://join?server=wss://share.example/&code=", "figcraft://join?server=wss://share.example/&code=K7M", "figcraft://join?code=K7M2QX9TD", ] { assert!(Invite::parse_join(bad).is_err(), "{bad}"); } // Where a file's link is needed (team libraries), a code isn't one. let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err(); assert!(e.contains("join code") && !e.contains("K7M"), "{e}"); } /// A server from before join codes: whatever it is asked first, it can't read it (what its /// decoder says about a message it doesn't know). fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let at = Endpoint::plain(listener.local_addr().unwrap().to_string()); let t = std::thread::spawn(move || { for stream in listener.incoming().take(2).flatten() { let Ok(mut ws) = tungstenite::accept(stream) else { continue }; let _ = ws.read(); let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() }; let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap()))); let _ = ws.close(None); while ws.read().is_ok() {} } }); (at, t) } /// A server from before join codes that manages a file's links as it always did: two connections, /// the first makes links, the second (asked for codes) can't read the request. fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) { use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink}; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let at = Endpoint::plain(listener.local_addr().unwrap().to_string()); let t = std::thread::spawn(move || { let mut made = 0; for stream in listener.incoming().take(2).flatten() { let Ok(mut ws) = tungstenite::accept(stream) else { continue }; let say = |ws: &mut tungstenite::WebSocket, m: ServerMsg| { let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap()))); }; while let Ok(Message::Binary(b)) = ws.read() { match message::decode::(&b, usize::MAX) { Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())), Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => { made += 1; let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 }; say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") })))); } _ => { say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() }); break; } } } let _ = ws.close(None); while ws.read().is_ok() {} } }); (at, t) } #[test] fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { let (server, thread) = old_server(); let limits = fast_limits(); // Asking it about a code: the join fails with what to do instead. let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err(); assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}"); let mut guest = session("Ada"); guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap(); guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap(); pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. })); let why = guest.live().status.label(); assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}"); thread.join().unwrap(); // Sharing a file there: it makes the links (it always could) but no codes, without an error: // the app shows the links. let (server, thread) = old_server_with_links(); let shared = manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap(); assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none())); assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1"); thread.join().unwrap(); } #[test] fn a_server_that_cant_be_reached_says_so() { // A port nothing listens on. let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let mut guest = session("Ada"); guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap(); guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap(); pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. })); assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status); } #[test] fn an_app_hosted_session_knows_no_online_codes() { let mut host = session("Hana"); let shared = host.execute("collab.host", &json!({"port": 0})).unwrap(); let server = Endpoint::plain(shared["address"].as_str().unwrap()); let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err(); assert!(e.contains("hosted by an app"), "{e}"); // Junk in place of a code is refused the same way, and the session goes on. for junk in ["", "💥", &"Z".repeat(4000)] { assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err()); } assert_eq!(host.live_summary()["status"], "live"); } /// A socket that records what the driver does (the browser's, scripted). #[derive(Default)] struct Script { opened: Vec, sent: Vec, closes: usize, } impl Socket for Script { fn open(&mut self, url: &str) -> Result<(), String> { self.opened.push(url.to_string()); Ok(()) } fn send(&mut self, bytes: &[u8]) -> bool { self.sent.push(message::decode(bytes, usize::MAX).unwrap()); true } fn close(&mut self) { self.closes += 1; } } /// The browser's driver joining with a code at a server. fn driver(t0: web_time::Instant) -> (Driver