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.
604 lines
23 KiB
Rust
604 lines
23 KiB
Rust
//! The server driven through its router in-process: sign-in end to end, the
|
|
//! libraries and their 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.get_at("/api/library", token).await
|
|
}
|
|
|
|
async fn get_at(&self, uri: &str, token: &str) -> Response<Body> {
|
|
self.send(
|
|
Request::get(uri)
|
|
.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> {
|
|
self.put_at("/api/library", token, if_match, doc).await
|
|
}
|
|
|
|
async fn put_at(&self, uri: &str, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
|
|
let mut req = Request::put(uri)
|
|
.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
|
|
}
|
|
}
|
|
|
|
/// An authenticated JSON request.
|
|
fn authed(method: &str, uri: &str, token: &str, body: Option<Value>) -> Request<Body> {
|
|
let req = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(header::CONTENT_TYPE, "application/json");
|
|
req.body(body.map(|b| Body::from(b.to_string())).unwrap_or_else(Body::empty)).unwrap()
|
|
}
|
|
|
|
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);
|
|
assert_eq!(info["libraries"], true);
|
|
}
|
|
|
|
// --- 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 main = db::default_library(&s.state.db(), user_id).unwrap();
|
|
let kept = db::history_versions(&s.state.db(), main.id).unwrap();
|
|
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
|
|
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
|
|
}
|
|
|
|
// --- named libraries ------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn every_account_starts_with_a_default_library() {
|
|
let s = Server::local();
|
|
let t = s.sign_in("alice", "correct horse battery").await;
|
|
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
|
|
let list = list.as_array().unwrap();
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0]["name"], "Main");
|
|
assert_eq!(list[0]["default"], true);
|
|
assert_eq!(list[0]["version"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn libraries_can_be_created_renamed_and_deleted() {
|
|
let s = Server::local();
|
|
let t = s.sign_in("alice", "correct horse battery").await;
|
|
|
|
let resp = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " Scratch " })))).await;
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let scratch = body_json(resp).await;
|
|
assert_eq!(scratch["name"], "Scratch");
|
|
assert_eq!(scratch["default"], false);
|
|
let id = scratch["id"].as_str().unwrap().to_string();
|
|
|
|
let dup = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "scratch" })))).await;
|
|
assert_eq!(dup.status(), StatusCode::CONFLICT);
|
|
let blank = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " " })))).await;
|
|
assert_eq!(blank.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let resp = s.send(authed("PATCH", &format!("/api/libraries/{id}"), &t, Some(json!({ "name": "Testing" })))).await;
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let names: Vec<Value> = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await)
|
|
.await
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|l| l["name"].clone())
|
|
.collect();
|
|
assert_eq!(names, vec![json!("Main"), json!("Testing")]);
|
|
|
|
let main_id = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await[0]["id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let resp = s.send(authed("DELETE", &format!("/api/libraries/{main_id}"), &t, None)).await;
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT, "the default library stays");
|
|
|
|
let resp = s.send(authed("DELETE", &format!("/api/libraries/{id}"), &t, None)).await;
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
let resp = s.get_at(&format!("/api/libraries/{id}"), &t).await;
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn each_library_has_its_own_versions() {
|
|
let s = Server::local();
|
|
let t = s.sign_in("alice", "correct horse battery").await;
|
|
let scratch = body_json(s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "Scratch" })))).await).await;
|
|
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
|
|
|
|
assert_eq!(s.put_library(&t, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::OK);
|
|
assert_eq!(s.put_library(&t, Some("1"), library(&[("a", 2)])).await.status(), StatusCode::OK);
|
|
|
|
// Scratch is still at 0, whatever the default library is at.
|
|
assert_eq!(s.put_at(&uri, &t, Some("2"), library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_FAILED);
|
|
assert_eq!(s.put_at(&uri, &t, Some("0"), library(&[("x", 1)])).await.status(), StatusCode::OK);
|
|
assert_eq!(s.put_at(&uri, &t, None, library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_REQUIRED);
|
|
|
|
let got = body_json(s.get_at(&uri, &t).await).await;
|
|
assert_eq!(got["version"], 1);
|
|
assert_eq!(got["doc"]["cards"], json!({ "x": { "quantity": 1 } }));
|
|
let main = body_json(s.get_library(&t).await).await;
|
|
assert_eq!(main["version"], 2);
|
|
assert_eq!(main["doc"]["cards"]["a"]["quantity"], 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_old_address_is_the_default_library() {
|
|
let s = Server::local();
|
|
let t = s.sign_in("alice", "correct horse battery").await;
|
|
s.put_library(&t, Some("0"), library(&[("a", 1)])).await;
|
|
|
|
let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await;
|
|
let main = &list.as_array().unwrap()[0];
|
|
assert_eq!(main["version"], 1);
|
|
let got = body_json(s.get_at(&format!("/api/libraries/{}", main["id"].as_str().unwrap()), &t).await).await;
|
|
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nobody_can_reach_someone_elses_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;
|
|
let scratch = body_json(s.send(authed("POST", "/api/libraries", &alice, Some(json!({ "name": "Scratch" })))).await).await;
|
|
let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap());
|
|
|
|
assert_eq!(s.get_at(&uri, &bob).await.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(s.put_at(&uri, &bob, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::NOT_FOUND);
|
|
let rename = s.send(authed("PATCH", &uri, &bob, Some(json!({ "name": "Mine" })))).await;
|
|
assert_eq!(rename.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(s.send(authed("DELETE", &uri, &bob, None)).await.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(s.get_at(&uri, &alice).await.status(), StatusCode::OK);
|
|
}
|