Replace folder sync with an HTTP sync server, keeping the app offline-first

Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.

The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.

Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.

The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -0,0 +1,475 @@
//! The server driven through its router in-process: sign-in end to end, the
//! library's compare-and-swap, and device revocation.
use axum::body::Body;
use axum::http::{header, Request, Response, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use mtgc_server::auth::local::hash_password;
use mtgc_server::auth::pkce_challenge;
use mtgc_server::{db, router, AppState, Config};
use serde_json::{json, Value};
use tower::ServiceExt;
const VERIFIER: &str = "a-perfectly-good-pkce-verifier-that-is-long-enough-0123456789";
const REDIRECT: &str = "http://127.0.0.1:43210/callback";
struct Server {
state: AppState,
}
impl Server {
fn new(vars: &[(&str, &str)]) -> Self {
let mut all = vec![("MTGC_PUBLIC_URL", "https://mtgc.example")];
all.extend_from_slice(vars);
let config = Config::from_lookup(|k| {
all.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string())
})
.unwrap();
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
Self { state }
}
fn local() -> Self {
let s = Self::new(&[]);
s.add_user("alice", "correct horse battery");
s
}
fn add_user(&self, name: &str, password: &str) {
let hash = hash_password(password).unwrap();
db::create_local_user(&self.state.db(), name, name, &hash).unwrap();
}
fn app(&self) -> Router {
router(self.state.clone())
}
async fn send(&self, req: Request<Body>) -> Response<Body> {
self.app().oneshot(req).await.unwrap()
}
/// Open the sign-in page as the app would. Returns the pending id (from
/// the cookie that binds it to this "browser") and the page.
async fn start(&self) -> (String, Response<Body>) {
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=app-state&device_name=Test%20phone",
urlencode(REDIRECT),
pkce_challenge(VERIFIER),
);
let resp = self.send(get(&uri)).await;
let cookie = resp
.headers()
.get(header::SET_COOKIE)
.expect("the sign-in should be bound to the browser")
.to_str()
.unwrap()
.to_string();
let pending = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("mtgc_login=")
.unwrap()
.to_string();
(pending, resp)
}
async fn submit_password(&self, pending: &str, cookie: Option<&str>, user: &str, pw: &str) -> String {
let body = format!(
"pending={}&username={}&password={}",
urlencode(pending),
urlencode(user),
urlencode(pw)
);
let mut req = Request::post("/auth/password")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
if let Some(c) = cookie {
req = req.header(header::COOKIE, format!("mtgc_login={c}"));
}
text(self.send(req.body(Body::from(body)).unwrap()).await).await
}
/// Sign in with a password and return the code the app would receive.
async fn code_for(&self, user: &str, pw: &str) -> String {
let (pending, _) = self.start().await;
let page = self.submit_password(&pending, Some(&pending), user, pw).await;
code_from(&page).unwrap_or_else(|| panic!("no code in:\n{page}"))
}
async fn redeem(&self, code: &str, verifier: &str) -> Response<Body> {
self.send(post_json("/auth/token", json!({ "code": code, "code_verifier": verifier }), None))
.await
}
async fn sign_in(&self, user: &str, pw: &str) -> String {
let code = self.code_for(user, pw).await;
let resp = self.redeem(&code, VERIFIER).await;
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await["token"].as_str().unwrap().to_string()
}
async fn get_library(&self, token: &str) -> Response<Body> {
self.send(
Request::get("/api/library")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
}
async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
let mut req = Request::put("/api/library")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json");
if let Some(v) = if_match {
req = req.header(header::IF_MATCH, v);
}
self.send(req.body(Body::from(doc.to_string())).unwrap()).await
}
}
fn get(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request<Body> {
let mut req = Request::post(uri).header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
req.body(Body::from(body.to_string())).unwrap()
}
async fn text(resp: Response<Body>) -> String {
String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()
}
async fn body_json(resp: Response<Body>) -> Value {
serde_json::from_str(&text(resp).await).unwrap()
}
fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
/// The code from the "returning to the app" page's link.
fn code_from(page: &str) -> Option<String> {
let start = page.find(r#"href=""#)? + 6;
let href = &page[start..start + page[start..].find('"')?];
let href = href.replace("&amp;", "&");
let u = url::Url::parse(&href).ok()?;
assert!(href.starts_with(REDIRECT), "code must go to the app: {href}");
assert_eq!(
u.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.into_owned()).as_deref(),
Some("app-state"),
"the app's state must come back with the code"
);
u.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.into_owned())
}
fn library(cards: &[(&str, i64)]) -> Value {
let cards: serde_json::Map<String, Value> =
cards.iter().map(|(id, q)| (id.to_string(), json!({ "quantity": q }))).collect();
json!({ "version": 1, "cards": cards, "decks": {} })
}
// --- sign-in ------------------------------------------------------------------
#[tokio::test]
async fn a_password_sign_in_yields_a_working_device_token() {
let s = Server::local();
let token = s.sign_in("alice", "correct horse battery").await;
let resp = s.get_library(&token).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["version"], 0, "a new account starts empty");
assert_eq!(body["doc"]["cards"], json!({}));
let me = s
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {token}")).body(Body::empty()).unwrap())
.await;
let me = body_json(me).await;
assert_eq!(me["user"]["username"], "alice");
assert_eq!(me["user"]["method"], "local");
assert_eq!(me["devices"][0]["name"], "Test phone");
assert_eq!(me["devices"][0]["current"], true);
}
#[tokio::test]
async fn usernames_are_not_case_sensitive() {
let s = Server::local();
s.sign_in(" Alice ", "correct horse battery").await;
}
#[tokio::test]
async fn a_wrong_password_and_an_unknown_user_look_the_same() {
let s = Server::local();
let (pending, _) = s.start().await;
let wrong = s.submit_password(&pending, Some(&pending), "alice", "nope").await;
let unknown = s.submit_password(&pending, Some(&pending), "mallory", "nope").await;
for page in [&wrong, &unknown] {
assert!(page.contains("Invalid username or password."));
assert!(code_from(page).is_none());
}
assert!(wrong.contains(r#"value="alice""#), "the username is kept for the retry");
}
#[tokio::test]
async fn repeated_failures_are_slowed_down() {
let s = Server::local();
let (pending, _) = s.start().await;
for _ in 0..5 {
s.submit_password(&pending, Some(&pending), "alice", "nope").await;
}
// Even the right password has to wait now.
let page = s.submit_password(&pending, Some(&pending), "alice", "correct horse battery").await;
assert!(page.contains("Too many attempts"), "{page}");
}
#[tokio::test]
async fn a_password_post_from_another_browser_is_refused() {
let s = Server::local();
let (pending, _) = s.start().await;
let no_cookie = s.submit_password(&pending, None, "alice", "correct horse battery").await;
let other = s.submit_password(&pending, Some("someone-elses"), "alice", "correct horse battery").await;
for page in [&no_cookie, &other] {
assert!(page.contains("expired"), "{page}");
assert!(code_from(page).is_none());
}
}
#[tokio::test]
async fn codes_need_the_right_verifier_and_work_once() {
let s = Server::local();
let code = s.code_for("alice", "correct horse battery").await;
assert_eq!(s.redeem(&code, "some-other-verifier-of-a-reasonable-length-0000000").await.status(), StatusCode::BAD_REQUEST);
assert_eq!(
s.redeem(&code, VERIFIER).await.status(),
StatusCode::BAD_REQUEST,
"a failed attempt burns the code"
);
let code = s.code_for("alice", "correct horse battery").await;
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::OK);
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST, "single use");
}
#[tokio::test]
async fn an_expired_code_is_refused() {
let s = Server::local();
let code = s.code_for("alice", "correct horse battery").await;
s.state.db().execute("UPDATE auth_codes SET expires_at = 0", []).unwrap();
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn codes_are_only_ever_sent_to_the_app() {
let s = Server::local();
for bad in [
"https://evil.example/callback",
"http://127.0.0.1.evil.example:80/callback",
"http://localhost:1234/callback",
"http://127.0.0.1:1234/elsewhere",
"mtgc://elsewhere",
] {
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
urlencode(bad),
pkce_challenge(VERIFIER)
);
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::BAD_REQUEST, "{bad}");
}
}
#[tokio::test]
async fn the_phone_apps_scheme_is_accepted() {
let s = Server::local();
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
urlencode("mtgc://auth/callback"),
pkce_challenge(VERIFIER)
);
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::OK);
}
#[tokio::test]
async fn with_only_oidc_the_server_skips_straight_to_the_provider() {
let s = Server::new(&[
("MTGC_LOCAL_AUTH", "false"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "secret"),
]);
let (pending, resp) = s.start().await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()[header::LOCATION].to_str().unwrap(),
format!("/auth/oidc?pending={pending}")
);
// And the password form is gone, not just hidden.
let page = s.submit_password(&pending, Some(&pending), "alice", "whatever").await;
assert!(page.contains("turned off"), "{page}");
}
#[tokio::test]
async fn with_both_methods_the_page_offers_both() {
let s = Server::new(&[
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "secret"),
("MTGC_OIDC_NAME", "Authentik"),
]);
let (_, resp) = s.start().await;
let page = text(resp).await;
assert!(page.contains("Sign in with Authentik"));
assert!(page.contains(r#"action="/auth/password""#));
}
#[tokio::test]
async fn the_server_describes_itself() {
let s = Server::local();
let info = body_json(s.send(get("/api/info")).await).await;
assert_eq!(info["server"], "mtgc");
assert_eq!(info["local_auth"], true);
assert_eq!(info["oidc"], Value::Null);
}
// --- devices ------------------------------------------------------------------
#[tokio::test]
async fn the_library_needs_a_valid_token() {
let s = Server::local();
assert_eq!(s.send(get("/api/library")).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(s.get_library("mtgc_made-up").await.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn signing_out_revokes_only_that_device() {
let s = Server::local();
let phone = s.sign_in("alice", "correct horse battery").await;
let desktop = s.sign_in("alice", "correct horse battery").await;
let resp = s.send(post_json("/auth/logout", json!({}), Some(&phone))).await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(s.get_library(&phone).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(s.get_library(&desktop).await.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_lost_device_can_be_revoked_from_another_but_not_by_someone_else() {
let s = Server::local();
s.add_user("bob", "another fine password");
let lost = s.sign_in("alice", "correct horse battery").await;
let mine = s.sign_in("alice", "correct horse battery").await;
let bobs = s.sign_in("bob", "another fine password").await;
let me = s
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {mine}")).body(Body::empty()).unwrap())
.await;
let devices = body_json(me).await["devices"].clone();
let lost_id = devices
.as_array()
.unwrap()
.iter()
.find(|d| d["current"] == false)
.unwrap()["id"]
.as_i64()
.unwrap();
let revoke = |token: String| {
Request::delete(format!("/api/devices/{lost_id}"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap()
};
assert_eq!(s.send(revoke(bobs)).await.status(), StatusCode::NOT_FOUND, "not bob's to revoke");
assert_eq!(s.get_library(&lost).await.status(), StatusCode::OK);
assert_eq!(s.send(revoke(mine)).await.status(), StatusCode::NO_CONTENT);
assert_eq!(s.get_library(&lost).await.status(), StatusCode::UNAUTHORIZED);
}
// --- the library ----------------------------------------------------------------
#[tokio::test]
async fn writes_must_name_the_version_they_replace() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let resp = s.put_library(&t, Some("\"0\""), library(&[("a", 1)])).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await["version"], 1);
// A second device still thinks it's at 0: refused, and told what is there.
let resp = s.put_library(&t, Some("0"), library(&[("b", 2)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
let current = body_json(resp).await;
assert_eq!(current["version"], 1);
assert_eq!(current["doc"]["cards"]["a"]["quantity"], 1);
let resp = s.put_library(&t, None, library(&[("b", 2)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_REQUIRED, "no blind overwrites");
let got = body_json(s.get_library(&t).await).await;
assert_eq!(got["version"], 1);
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
}
#[tokio::test]
async fn a_write_to_a_library_that_does_not_exist_yet_must_say_version_zero() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let resp = s.put_library(&t, Some("3"), library(&[("a", 1)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(body_json(resp).await["version"], 0);
}
#[tokio::test]
async fn only_valid_libraries_are_stored() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let newer = json!({ "version": 99, "cards": {}, "decks": {} });
assert_eq!(s.put_library(&t, Some("0"), newer).await.status(), StatusCode::BAD_REQUEST);
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn each_user_has_their_own_library() {
let s = Server::local();
s.add_user("bob", "another fine password");
let alice = s.sign_in("alice", "correct horse battery").await;
let bob = s.sign_in("bob", "another fine password").await;
s.put_library(&alice, Some("0"), library(&[("a", 1)])).await;
let bobs = body_json(s.get_library(&bob).await).await;
assert_eq!(bobs["version"], 0);
assert_eq!(bobs["doc"]["cards"], json!({}));
}
#[tokio::test]
async fn old_versions_are_kept_up_to_a_limit() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let total = db::HISTORY_KEEP + 10;
for v in 0..total {
let resp = s.put_library(&t, Some(&v.to_string()), library(&[("a", v)])).await;
assert_eq!(resp.status(), StatusCode::OK);
}
let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap();
let kept = db::history_versions(&s.state.db(), user_id).unwrap();
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
}

View File

@@ -0,0 +1,57 @@
//! The `user` subcommands, run as the real binary against a scratch database.
use std::io::Write;
use std::process::{Command, Output, Stdio};
fn run(db: &std::path::Path, args: &[&str], stdin: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_mtgc-server"))
.args(args)
.env("MTGC_DB", db)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
fn out(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
#[test]
fn local_accounts_can_be_added_changed_listed_and_removed() {
let dir = std::env::temp_dir().join(format!("mtgc-server-cli-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("server.db");
let o = run(&db, &["user", "add", "Alice", "--display-name", "Alice A."], "a long password\n");
assert!(o.status.success(), "{}", out(&o));
let o = run(&db, &["user", "add", "alice"], "a long password\n");
assert!(!o.status.success(), "duplicate names must be refused");
let o = run(&db, &["user", "add", "bob"], "short\n");
assert!(!o.status.success(), "short passwords must be refused");
let o = run(&db, &["user", "passwd", "alice"], "another long password\n");
assert!(o.status.success(), "{}", out(&o));
let conn = mtgc_server::db::open(&db).unwrap();
let (_, hash) = mtgc_server::db::local_user(&conn, "alice").unwrap().unwrap();
assert!(hash.starts_with("$argon2id$"));
drop(conn);
let o = run(&db, &["user", "list"], "");
assert!(out(&o).contains("alice") && out(&o).contains("Alice A."), "{}", out(&o));
let o = run(&db, &["user", "remove", "alice"], "");
assert!(!o.status.success(), "removal must be confirmed");
let o = run(&db, &["user", "remove", "alice", "--yes"], "");
assert!(o.status.success(), "{}", out(&o));
let o = run(&db, &["user", "list"], "");
assert!(!out(&o).contains("alice"), "{}", out(&o));
std::fs::remove_dir_all(&dir).unwrap();
}

View File

@@ -0,0 +1,161 @@
//! 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, 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<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, location: String::new() });
apply_doc(&conn, &doc).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 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}");
}