Files
Garrett Mills 70c89a5644 Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.

The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.

Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:40 +00:00

618 lines
24 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("&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);
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 a_library_with_deck_card_tags_is_stored() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let doc = json!({
"version": 5,
"cards": {},
"decks": { "u1": { "name": "Fub", "cards": { "a": { "quantity": 1, "tags": ["ramp"] } } } }
});
assert_eq!(s.put_library(&t, Some("0"), doc).await.status(), StatusCode::OK);
let got = body_json(s.get_library(&t).await).await;
assert_eq!(got["doc"]["decks"]["u1"]["cards"]["a"]["tags"], json!(["ramp"]));
}
#[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);
}