The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
260 lines
10 KiB
Rust
260 lines
10 KiB
Rust
//! 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, NOT_FOUND, 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),
|
|
}
|
|
}
|
|
|
|
/// A second working copy on the same device, for one of the account's
|
|
/// named libraries.
|
|
fn for_library(dir: &Path, name: &str, server: &str, token: &str, library_id: &str) -> Self {
|
|
Self {
|
|
db: dir.join(format!("{name}.db")),
|
|
base: SyncBase::default(),
|
|
remote: HttpRemote::for_library(server, token, library_id),
|
|
}
|
|
}
|
|
|
|
fn sync(&mut self) -> mtgc_core::Result<mtgc_core::sync::remote::CycleReport> {
|
|
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<i64> {
|
|
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}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_named_library_syncs_without_touching_the_default_one() {
|
|
let (server, state) = spawn_server();
|
|
let hash = hash_password("correct horse battery").unwrap();
|
|
db::create_local_user(&state.db(), "carol", "Carol", &hash).unwrap();
|
|
let dir = std::env::temp_dir().join(format!("mtgc-e2e-named-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
assert!(server_info(&server).unwrap().libraries);
|
|
let phone_login = sign_in(&server, "carol", "correct horse battery");
|
|
let desktop_login = sign_in(&server, "carol", "correct horse battery");
|
|
|
|
let mut phone_main = Device::new(&dir, "phone-main", &server, &phone_login.token);
|
|
phone_main.set("a", 1);
|
|
phone_main.sync().unwrap();
|
|
|
|
// The phone starts a scratch library; the desktop finds it in the list.
|
|
let scratch = phone_main.remote.create_library("Scratch").unwrap();
|
|
let mut phone_scratch = Device::for_library(&dir, "phone-scratch", &server, &phone_login.token, &scratch.id);
|
|
phone_scratch.set("x", 5);
|
|
assert!(phone_scratch.sync().unwrap().published);
|
|
|
|
let desktop_account = HttpRemote::new(&server, &desktop_login.token);
|
|
let listed = desktop_account.list_libraries().unwrap();
|
|
assert_eq!(listed.iter().map(|l| (l.name.as_str(), l.default)).collect::<Vec<_>>(), [("Main", true), ("Scratch", false)]);
|
|
let mut desktop_scratch = Device::for_library(&dir, "desktop-scratch", &server, &desktop_login.token, &listed[1].id);
|
|
desktop_scratch.sync().unwrap();
|
|
assert_eq!(desktop_scratch.qty("x"), Some(5));
|
|
assert_eq!(desktop_scratch.qty("a"), None, "nothing from Main");
|
|
|
|
desktop_scratch.set("x", 6);
|
|
desktop_scratch.sync().unwrap();
|
|
phone_scratch.sync().unwrap();
|
|
assert_eq!(phone_scratch.qty("x"), Some(6));
|
|
|
|
let mut desktop_main = Device::new(&dir, "desktop-main", &server, &desktop_login.token);
|
|
desktop_main.sync().unwrap();
|
|
assert_eq!(desktop_main.qty("a"), Some(1));
|
|
assert_eq!(desktop_main.qty("x"), None, "nothing from Scratch");
|
|
|
|
// Deleted from one device: the other's next sync says so distinctly.
|
|
desktop_account.delete_library(&scratch.id).unwrap();
|
|
let err = phone_scratch.sync().unwrap_err();
|
|
assert_eq!(err.to_string(), NOT_FOUND);
|
|
assert_eq!(phone_scratch.qty("x"), Some(6), "the local copy is kept");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|