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:
475
crates/mtgc-server/tests/api.rs
Normal file
475
crates/mtgc-server/tests/api.rs
Normal 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("&", "&");
|
||||
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");
|
||||
}
|
||||
57
crates/mtgc-server/tests/cli.rs
Normal file
57
crates/mtgc-server/tests/cli.rs
Normal 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();
|
||||
}
|
||||
161
crates/mtgc-server/tests/sync_e2e.rs
Normal file
161
crates/mtgc-server/tests/sync_e2e.rs
Normal 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user