//! Join codes over the network: a file shared on the local network joined by its code, and by //! its view-only code to look without editing (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::{IpAddr, Ipv4Addr, SocketAddr, 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, Lookup}; use crate::link::reachable; use crate::loopback_tests::{fast_limits, quick}; use crate::{NetLink, lan_ip, 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()) } /// A local network share's code joins its session from another app, and edits sync both ways. #[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"); // A view-only code too, with its own secret, and no comment code. let view = shared["share"]["viewCode"].as_str().unwrap(); let Code::Local(viewing) = code::parse(view).unwrap() else { panic!("not a local code") }; assert_eq!((viewing.ip, viewing.port), (local.ip, local.port), "the same session"); assert_ne!(viewing.secret, local.secret, "a secret of its own"); assert_eq!(shared["share"]["viewLink"], viewing.link()); assert!(shared["share"]["commentCode"].is_null()); // 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 { .. })); // The port is free again: another program's host may have taken it, and that host refuses // the code's secret. let label = late.live().status.label(); assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}"); } /// A well-formed code with the wrong secret is refused by the host, a mistyped one never connects, /// and there is no comment code on the local network. #[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(); // The last symbol contains only checksum bits. Changing payload bits can collide with // the eight-bit checksum, so it does not guarantee a malformed code. let last = typo.last_mut().unwrap(); *last = if *last == '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 comment code doesn't exist here, and says where it does. let e = host.execute("collab.share", &json!({"access": "comment"})).unwrap_err().to_string(); assert!(e.contains("share it online"), "{e}"); } /// A local network share started for looking only shows its view code: whoever joins with it sees /// every change and can't make one, the edit code (picked later) still edits, and stopping ends /// both. #[test] fn a_local_view_code_lets_people_look_and_not_edit() { let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo")); let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap(); assert_eq!(shared["share"]["access"], "view"); let (shown, edit) = (shared["code"].as_str().unwrap().to_string(), shared["share"]["editCode"].as_str().unwrap().to_string()); assert_eq!(shared["share"]["viewCode"], shown, "the code shown is the view code"); assert_ne!(shown, edit); viewer.execute("collab.join", &json!({"code": shown})).unwrap(); pump(&mut [&mut host, &mut viewer], "the viewer's join", |s| live(s[1]) && s[0].live().peers.len() == 1); let summary = viewer.live_summary(); assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true))); // The host's edit reaches the viewer; the viewer's is refused before it leaves. let before = layers(&host); host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1); let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string(); assert!(e.contains("View only"), "{e}"); // The edit code, picked now, still edits, and the viewer sees it. assert_eq!(host.execute("collab.share", &json!({"access": "edit"})).unwrap()["code"], edit); editor.execute("collab.join", &json!({"code": edit})).unwrap(); pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's join", |s| live(s[2]) && s[0].live().peers.len() == 2); assert_eq!(editor.live_summary()["access"], "edit"); editor.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap(); pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's edit", |s| layers(s[0]) == before + 2 && layers(s[1]) == before + 2); assert_eq!(viewer.live_summary()["readOnly"], true, "picking the edit code changes nothing for who joined to look"); assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active()); } /// A local network share's `link` and `url` are the ones that go with the code shown: started for /// looking only, whoever joins through either can't edit, and the session's line in `sessions` /// says the same. #[test] fn a_local_view_share_s_link_and_url_only_let_people_look() { let mut host = session("Hana"); let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap(); for key in ["link", "url"] { let way = shared[key].as_str().unwrap().to_string(); let mut viewer = session("Ada"); viewer.execute("collab.join", &json!({"link": way})).unwrap(); pump(&mut [&mut host, &mut viewer], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1); let summary = viewer.live_summary(); assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {key} {way}"); let before = layers(&host); let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string(); assert!(e.contains("View only"), "{key}: {e}"); host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1); assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host ({key})"); viewer.execute("collab.leave", &json!({})).unwrap(); pump(&mut [&mut host, &mut viewer], "the leave", |s| s[0].live().peers.is_empty()); } // The link, the URL and the code name the same way in, wherever the session is listed. let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link()))); let url = shared["url"].as_str().unwrap(); assert!(url.starts_with("ws://") && url.ends_with(&format!("&token={}", local.token())), "{url}"); assert_eq!(shared["sessions"][0]["link"], shared["link"]); // Picking the edit code makes them the edit ones too. let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap(); assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"])); let edit_token = edit["link"].as_str().unwrap().rsplit("token=").next().unwrap(); assert!(edit["url"].as_str().unwrap().ends_with(&format!("&token={edit_token}")), "{}", edit["url"]); assert_ne!((&edit["link"], &edit["url"]), (&shared["link"], &shared["url"])); assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); } /// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never /// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL /// and address, and its view code and edit link both join it. #[test] fn a_share_on_every_interface_names_an_address_others_reach() { let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo")); let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap(); let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()); assert_eq!(IpAddr::from(viewing.ip), at); let address = format!("{at}:{}", viewing.port); assert_eq!(shared["address"], address); let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port)); for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] { let way = way.as_str().unwrap_or_default(); assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}"); } viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap(); editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap(); pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2); assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit"))); assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); } /// A listener on one address is reached at it; one on every interface at an address of its own /// family, on its port. #[test] fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() { let one: SocketAddr = "192.168.1.20:47900".parse().unwrap(); assert_eq!(reachable(one), one); for any in ["0.0.0.0:47900", "[::]:47901"] { let any: SocketAddr = any.parse().unwrap(); let at = reachable(any); assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}"); } } /// An invite naming only `server` (what a join code is asked at). fn code_at(server: &Endpoint) -> Invite { Invite { server: server.clone(), file: String::new(), token: String::new() } } #[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, Some(Lookup::Code("K7M2QX9TD".into()))); let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap(); assert_eq!((invite.server, found), (Endpoint::plain("127.0.0.1:47900"), Some(Lookup::Code("K7M2QX9TD".into())))); // 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 }; // read() returns Result, not a lock guard; this test server deliberately // ignores whatever the client sent first. let _ = ws.read(); // NOSONAR 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(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &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(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &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(&code_at(&server), &Lookup::Code(junk.into()), &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