//! The app's sync client against the real server over HTTP: the wire protocol //! end to end, with two devices that each keep a SQLite working copy. use std::path::{Path, PathBuf}; use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce}; use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED}; use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry}; use mtgc_server::auth::local::hash_password; use mtgc_server::{db, router, AppState, Config}; /// Start a server on a free port and return its URL. It runs on its own thread /// for the rest of the test process. fn spawn_server() -> (String, AppState) { let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", std_listener.local_addr().unwrap()); let public = url.clone(); let config = Config::from_lookup(move |k| (k == "MTGC_PUBLIC_URL").then(|| public.clone())).unwrap(); let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap(); let app = router(state.clone()); std_listener.set_nonblocking(true).unwrap(); std::thread::spawn(move || { tokio::runtime::Runtime::new().unwrap().block_on(async move { let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); axum::serve(listener, app).await.unwrap(); }); }); (url, state) } /// Sign in the way the app does, playing the browser's part by hand. fn sign_in(server: &str, user: &str, password: &str) -> mtgc_core::sync::account::SignedIn { let pkce = Pkce::new(); let start = start_url(server, "http://127.0.0.1:9/callback", &pkce, "st", "Test device"); let page = ureq::get(&start).call().unwrap(); let cookie = page.header("set-cookie").unwrap().split(';').next().unwrap().to_string(); let pending = cookie.strip_prefix("mtgc_login=").unwrap().to_string(); let done = ureq::post(&format!("{server}/auth/password")) .set("Cookie", &cookie) .send_form(&[("pending", &pending), ("username", user), ("password", password)]) .unwrap() .into_string() .unwrap(); let start = done.find("code=").expect("signed in") + 5; let code = &done[start..start + done[start..].find('&').unwrap()]; redeem_code(server, code, &pkce.verifier).unwrap() } struct Device { db: PathBuf, base: SyncBase, remote: HttpRemote, } impl Device { fn new(dir: &Path, name: &str, server: &str, token: &str) -> Self { Self { db: dir.join(format!("{name}.db")), base: SyncBase::default(), remote: HttpRemote::new(server, token), } } fn sync(&mut self) -> mtgc_core::Result { let path = self.db.clone(); sync_cycle(&self.remote, &mut self.base, |base, theirs| { let conn = mtgc_core::db::open_library(&path)?; merge_into(&conn, base, theirs) }) } fn set(&self, id: &str, qty: i64) { let conn = mtgc_core::db::open_library(&self.db).unwrap(); let mut doc = read_doc(&conn).unwrap(); doc.cards.insert(id.into(), CardEntry { quantity: qty, ..Default::default() }); apply_doc(&conn, &doc).unwrap(); } fn doc(&self) -> mtgc_core::sync::LibraryDoc { read_doc(&mtgc_core::db::open_library(&self.db).unwrap()).unwrap() } fn qty(&self, id: &str) -> Option { let conn = mtgc_core::db::open_library(&self.db).unwrap(); read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity) } } #[test] fn printing_data_reaches_the_other_device_intact() { // A version-2 document must be accepted by the server and come back out // exactly as stored, or one device's tracked printings would vanish. let (server, state) = spawn_server(); let hash = hash_password("correct horse battery").unwrap(); db::create_local_user(&state.db(), "bob", "Bob", &hash).unwrap(); let dir = std::env::temp_dir().join(format!("mtgc-e2e-printings-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let phone_login = sign_in(&server, "bob", "correct horse battery"); let desktop_login = sign_in(&server, "bob", "correct horse battery"); let mut phone = Device::new(&dir, "phone", &server, &phone_login.token); let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token); { let conn = mtgc_core::db::open_library(&phone.db).unwrap(); let mut doc = read_doc(&conn).unwrap(); let mut card = CardEntry { quantity: 3, ..Default::default() }; card.printings.insert(printing_key("p1", "foil"), 2); doc.cards.insert("a".into(), card); apply_doc(&conn, &doc).unwrap(); } assert_eq!(phone.doc().version, 2); assert!(phone.sync().unwrap().published); desktop.sync().unwrap(); assert_eq!(desktop.doc(), phone.doc()); assert_eq!(desktop.doc().cards["a"].printings[&printing_key("p1", "foil")], 2); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn two_devices_converge_through_the_real_server() { let (server, state) = spawn_server(); let hash = hash_password("correct horse battery").unwrap(); db::create_local_user(&state.db(), "alice", "Alice", &hash).unwrap(); let dir = std::env::temp_dir().join(format!("mtgc-e2e-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let info = server_info(&server).unwrap(); assert!(info.local_auth); let phone_login = sign_in(&server, "alice", "correct horse battery"); let desktop_login = sign_in(&server, "alice", "correct horse battery"); assert_eq!(phone_login.user.id, desktop_login.user.id, "same account, same id"); assert_eq!(phone_login.user.method, "local"); let mut phone = Device::new(&dir, "phone", &server, &phone_login.token); let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token); phone.set("a", 1); phone.set("b", 1); assert!(phone.sync().unwrap().published); desktop.sync().unwrap(); assert_eq!(desktop.qty("a"), Some(1)); // Offline edits on both, to different cards. phone.set("a", 3); desktop.set("b", 5); phone.sync().unwrap(); desktop.sync().unwrap(); phone.sync().unwrap(); for d in [&phone, &desktop] { assert_eq!(d.qty("a"), Some(3)); assert_eq!(d.qty("b"), Some(5)); } assert!(!phone.sync().unwrap().published, "settled: nothing left to send"); // Revoking the phone from the desktop signs it out. let me = desktop.remote.me().unwrap(); let phone_id = me.devices.iter().find(|d| !d.current).unwrap().id; desktop.remote.revoke_device(phone_id).unwrap(); let err = phone.sync().unwrap_err(); assert_eq!(err.to_string(), UNAUTHORIZED); desktop.remote.logout().unwrap(); assert!(desktop.sync().is_err()); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn the_app_and_server_agree_on_pkce() { let p = Pkce::new(); assert_eq!(mtgc_server::auth::pkce_challenge(&p.verifier), p.challenge); } #[test] fn an_address_that_is_not_an_mtgc_server_is_rejected() { // Something that answers HTTP but isn't us. let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); std::thread::spawn(move || { use std::io::{Read, Write}; if let Ok((mut s, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = s.read(&mut buf); let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); } }); let err = server_info(&format!("http://{addr}")).unwrap_err(); assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}"); }