Files
mtgc/crates/mtgc-server/tests/api.rs
Garrett Mills de55fc634a 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.
2026-10-08 22:32:08 -05:00

476 lines
17 KiB
Rust

//! 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");
}