use std::collections::HashMap;
use std::io::Write;
use std::time::Duration;
use axum::{
Router,
body::Body,
http::{Request, StatusCode, header},
response::Response,
};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::app::{self, App, Config};
use crate::auth::{Access, Keys};
use crate::db::{self, Db};
use crate::storage::{Backend, Storage};
const RW: &str = "rw-key";
const RO: &str = "ro-key";
const PUBLIC: &str = "http://defter.test";
struct Server {
router: Router,
_dir: tempfile::TempDir,
db: std::sync::Arc<Db>,
}
async fn server() -> Server {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("defter.sqlite3")).unwrap();
let storage = Storage::new(&Backend::Memory, Duration::from_secs(60), false).unwrap();
let keys = Keys::new(HashMap::from([
(RW.to_string(), Access { write: true }),
(RO.to_string(), Access { write: false }),
]))
.unwrap();
let cfg = Config {
user_id: 1,
username: "tester".into(),
public_url: PUBLIC.into(),
login_authorized_user: None,
max_upload: 1024 * 1024,
schema_url: "http://127.0.0.1:9/unreachable".into(),
};
let app = App::new(cfg, db.clone(), storage, keys).await.unwrap();
Server {
router: app::router(app),
_dir: dir,
db,
}
}
impl Server {
async fn send(&self, req: Request<Body>) -> Response {
self.router.clone().oneshot(req).await.unwrap()
}
async fn call(
&self,
method: &str,
uri: &str,
key: Option<&str>,
headers: &[(&str, &str)],
body: Body,
) -> Response {
let mut req = Request::builder().method(method).uri(uri);
if let Some(key) = key {
req = req.header("zotero-api-key", key);
}
for (name, value) in headers {
req = req.header(*name, *value);
}
self.send(req.body(body).unwrap()).await
}
async fn get(&self, uri: &str) -> Response {
self.call("GET", uri, Some(RW), &[], Body::empty()).await
}
async fn json(
&self,
method: &str,
uri: &str,
headers: &[(&str, &str)],
body: Value,
) -> Response {
let mut all = vec![("content-type", "application/json")];
all.extend_from_slice(headers);
self.call(method, uri, Some(RW), &all, Body::from(body.to_string()))
.await
}
async fn form(&self, uri: &str, headers: &[(&str, &str)], form: &[(&str, &str)]) -> Response {
let mut all = vec![("content-type", "application/x-www-form-urlencoded")];
all.extend_from_slice(headers);
let body = serde_urlencoded::to_string(form).unwrap();
self.call("POST", uri, Some(RW), &all, Body::from(body))
.await
}
}
async fn body_json(resp: Response) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes)
.unwrap_or_else(|_| panic!("not JSON: {}", String::from_utf8_lossy(&bytes)))
}
async fn body_text(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
}
fn header_str<'a>(resp: &'a Response, name: &str) -> &'a str {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
}
fn gzip(value: &Value) -> Vec<u8> {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(value.to_string().as_bytes()).unwrap();
encoder.finish().unwrap()
}
#[tokio::test]
async fn the_api_root_identifies_the_server() {
let s = server().await;
for uri in ["/api", "/api/", "/"] {
let resp = s.call("GET", uri, None, &[], Body::empty()).await;
assert_eq!(resp.status(), StatusCode::OK, "{uri}");
assert_eq!(header_str(&resp, "zotero-server-id").len(), 32, "{uri}");
}
}
#[tokio::test]
async fn keys_guard_the_data_api() {
let s = server().await;
let none = s
.call("GET", "/users/1/items", None, &[], Body::empty())
.await;
assert_eq!(none.status(), StatusCode::FORBIDDEN);
let bad = s
.call("GET", "/users/1/items", Some("nope"), &[], Body::empty())
.await;
assert_eq!(bad.status(), StatusCode::FORBIDDEN);
let other_user = s.get("/users/2/items").await;
assert_eq!(other_user.status(), StatusCode::FORBIDDEN);
assert_eq!(s.get("/api/users/0/items").await.status(), StatusCode::OK);
let ro_read = s
.call("GET", "/users/1/items", Some(RO), &[], Body::empty())
.await;
assert_eq!(ro_read.status(), StatusCode::OK);
let ro_write = s
.call(
"POST",
"/users/1/items",
Some(RO),
&[("if-unmodified-since-version", "0")],
Body::from("[]"),
)
.await;
assert_eq!(ro_write.status(), StatusCode::FORBIDDEN);
let bearer = s
.call(
"GET",
"/keys/current",
None,
&[("authorization", "Bearer ro-key")],
Body::empty(),
)
.await;
let body = body_json(bearer).await;
assert_eq!(body["userID"], 1);
assert_eq!(body["access"]["user"]["write"], false);
}
#[tokio::test]
async fn a_zotero_client_syncs_items() {
let s = server().await;
let batch = json!([
{"key": "AAAAAAAA", "itemType": "book", "title": "Rust", "creators": [{"lastName": "Klabnik"}]},
{"key": "BBBBBBBB", "itemType": "attachment", "parentItem": "AAAAAAAA",
"filename": "rust.pdf", "linkMode": "imported_file", "contentType": "application/pdf"},
]);
let resp = s
.call(
"POST",
"/users/1/items",
Some(RW),
&[
("content-type", "application/json"),
("content-encoding", "gzip"),
("if-unmodified-since-version", "0"),
],
Body::from(gzip(&batch)),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(header_str(&resp, "last-modified-version"), "1");
let result = body_json(resp).await;
assert_eq!(result["success"]["0"], "AAAAAAAA");
let attachment = result["successful"]["1"]["data"].as_object().unwrap();
assert_eq!(
attachment.keys().next().map(String::as_str),
Some("linkMode")
);
let versions = body_json(
s.get("/users/1/items?format=versions&since=0&includeTrashed=1")
.await,
)
.await;
assert_eq!(versions, json!({"AAAAAAAA": 1, "BBBBBBBB": 1}));
let top = body_json(s.get("/users/1/items/top?format=versions&since=0").await).await;
assert_eq!(top, json!({"AAAAAAAA": 1}));
let empty = s.get("/users/1/items?format=versions&since=1").await;
assert_eq!(empty.status(), StatusCode::OK);
assert_eq!(body_json(empty).await, json!({}));
let fetched = body_json(
s.get("/users/1/items?itemKey=AAAAAAAA,BBBBBBBB&format=json")
.await,
)
.await;
assert_eq!(fetched.as_array().unwrap().len(), 2);
let stale = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "AAAAAAAA", "title": "x"}]),
)
.await;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(header_str(&stale, "last-modified-version"), "1");
let no_precondition = s
.call(
"DELETE",
"/users/1/items?itemKey=BBBBBBBB",
Some(RW),
&[],
Body::empty(),
)
.await;
assert_eq!(no_precondition.status(), StatusCode::PRECONDITION_REQUIRED);
let deleted = s
.call(
"DELETE",
"/users/1/items?itemKey=BBBBBBBB",
Some(RW),
&[("if-unmodified-since-version", "1")],
Body::empty(),
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
let log = body_json(s.get("/users/1/deleted?since=1").await).await;
assert_eq!(log["items"], json!(["BBBBBBBB"]));
let settings = s
.call(
"GET",
"/users/1/settings",
Some(RW),
&[("if-modified-since-version", "2")],
Body::empty(),
)
.await;
assert_eq!(settings.status(), StatusCode::NOT_MODIFIED);
let groups = body_json(s.get("/users/1/groups?format=versions").await).await;
assert_eq!(groups, json!({}));
}
#[tokio::test]
async fn commonmeta_sync_reads_and_writes_single_items() {
let s = server().await;
let created = s
.json(
"POST",
"/api/users/0/items",
&[],
json!([{"itemType": "journalArticle", "title": "Deep learning", "DOI": "10.1038/nature14539"}]),
)
.await;
assert_eq!(created.status(), StatusCode::OK);
let created = body_json(created).await;
let key = created["successful"]["0"]["key"]
.as_str()
.unwrap()
.to_string();
let version = created["successful"]["0"]["version"].as_i64().unwrap();
let found = body_json(
s.get("/api/users/0/items?format=json&qmode=everything&q=nature14539")
.await,
)
.await;
assert_eq!(found[0]["key"], key.as_str());
let item_url = format!("/api/users/0/items/{key}");
let patched = s
.json(
"PATCH",
&item_url,
&[("if-unmodified-since-version", &version.to_string())],
json!({"volume": "521"}),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
let item = s.get(&item_url).await;
let item_version: i64 = header_str(&item, "last-modified-version").parse().unwrap();
let item = body_json(item).await;
assert_eq!(item["data"]["title"], "Deep learning");
assert_eq!(item["data"]["volume"], "521");
assert_eq!(item["version"], item_version);
s.json(
"POST",
"/api/users/0/items",
&[],
json!([{"itemType": "note", "note": "x", "parentItem": key}]),
)
.await;
let stale = s
.json(
"PUT",
&item_url,
&[("if-unmodified-since-version", &version.to_string())],
json!({"itemType": "book"}),
)
.await;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let replaced = s
.json(
"PUT",
&item_url,
&[("if-unmodified-since-version", &item_version.to_string())],
json!({"itemType": "journalArticle", "title": "Deep learning", "DOI": "10.1038/nature14539"}),
)
.await;
assert_eq!(replaced.status(), StatusCode::NO_CONTENT);
let item = body_json(s.get(&item_url).await).await;
assert!(item["data"].get("volume").is_none());
let children = body_json(s.get(&format!("{item_url}/children?format=json")).await).await;
assert_eq!(children.as_array().unwrap().len(), 1);
let top = body_json(s.get("/api/users/0/items/top?format=json").await).await;
assert_eq!(top[0]["meta"]["numChildren"], 1);
let page = s.get("/api/users/0/items?limit=1").await;
assert_eq!(header_str(&page, "total-results"), "2");
assert_eq!(
header_str(&page, "link"),
format!("<{PUBLIC}/api/users/0/items?limit=1&start=1>; rel=\"next\"")
);
}
#[tokio::test]
async fn attachment_files_upload_and_download() {
let s = server().await;
s.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "FFFFFFFF", "itemType": "attachment", "linkMode": "imported_file", "filename": "a.pdf"}]),
)
.await;
let bytes = b"%PDF-1.7 hello";
let md5 = format!("{:x}", md5::compute(bytes));
let size = bytes.len().to_string();
let auth_form = [
("md5", md5.as_str()),
("filename", "a.pdf"),
("filesize", &size),
("mtime", "1700000000000"),
];
let no_precondition = s
.form("/users/1/items/FFFFFFFF/file", &[], &auth_form)
.await;
assert_eq!(no_precondition.status(), StatusCode::PRECONDITION_REQUIRED);
let auth = body_json(
s.form(
"/users/1/items/FFFFFFFF/file",
&[("if-none-match", "*")],
&auth_form,
)
.await,
)
.await;
let url = auth["url"].as_str().unwrap();
let upload_key = auth["uploadKey"].as_str().unwrap();
assert!(url.starts_with(&format!("{PUBLIC}/uploads/")));
assert_eq!(auth["prefix"], "");
let path = url.strip_prefix(PUBLIC).unwrap();
let wrong = s.call("POST", path, None, &[], Body::from("garbage")).await;
assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
let early = s
.form(
"/users/1/items/FFFFFFFF/file",
&[("if-none-match", "*")],
&[("upload", upload_key)],
)
.await;
assert_eq!(early.status(), StatusCode::BAD_REQUEST);
let sent = s
.call(
"POST",
path,
None,
&[("content-type", "application/pdf")],
Body::from(&bytes[..]),
)
.await;
assert_eq!(sent.status(), StatusCode::CREATED);
let registered = s
.form(
"/users/1/items/FFFFFFFF/file",
&[("if-none-match", "*")],
&[("upload", upload_key)],
)
.await;
assert_eq!(registered.status(), StatusCode::NO_CONTENT);
assert_eq!(header_str(®istered, "last-modified-version"), "2");
let item = body_json(s.get("/users/1/items/FFFFFFFF").await).await;
assert_eq!(item["data"]["md5"], md5.as_str());
let again = body_json(
s.form(
"/users/1/items/FFFFFFFF/file",
&[("if-none-match", "*")],
&auth_form,
)
.await,
)
.await;
assert_eq!(again, json!({"exists": 1}));
let redirect = s.get("/users/1/items/FFFFFFFF/file").await;
assert_eq!(redirect.status(), StatusCode::FOUND);
assert_eq!(header_str(&redirect, "zotero-file-md5"), md5);
assert_eq!(
header_str(&redirect, "zotero-file-modification-time"),
"1700000000000"
);
let location = header_str(&redirect, "location").to_string();
let download = s
.call(
"GET",
location.strip_prefix(PUBLIC).unwrap(),
None,
&[],
Body::empty(),
)
.await;
assert_eq!(download.status(), StatusCode::OK);
assert_eq!(
header_str(&download, header::CONTENT_DISPOSITION.as_str()),
"attachment; filename=\"a.pdf\""
);
assert_eq!(body_text(download).await.as_bytes(), bytes);
let deleted = s
.call(
"DELETE",
"/users/1/items/FFFFFFFF",
Some(RW),
&[("if-unmodified-since-version", "2")],
Body::empty(),
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert_eq!(
s.get("/users/1/items/FFFFFFFF/file").await.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn a_login_session_hands_out_the_key_after_approval() {
let s = server().await;
let created = s
.call("POST", "/keys/sessions", None, &[], Body::empty())
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let created = body_json(created).await;
let token = created["sessionToken"].as_str().unwrap().to_string();
assert_eq!(
created["loginURL"],
format!("{PUBLIC}/login?session={token}")
);
let session_uri = format!("/keys/sessions/{token}");
async fn poll_session(s: &Server, uri: &str) -> Response {
s.call("GET", uri, None, &[], Body::empty()).await
}
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
let page = s
.call(
"GET",
&format!("/login?session={token}"),
None,
&[],
Body::empty(),
)
.await;
assert_eq!(page.status(), StatusCode::OK);
assert_eq!(header_str(&page, "x-frame-options"), "DENY");
assert_eq!(header_str(&page, "cache-control"), "no-store");
assert_eq!(header_str(&page, "referrer-policy"), "no-referrer");
assert!(header_str(&page, "content-security-policy").contains("frame-ancestors 'none'"));
let html = body_text(page).await;
assert!(html.contains(r#"<form method="post" action="/login">"#));
assert!(html.contains(&format!(r#"name="session" value="{token}""#)));
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
let form = format!("session={token}");
let foreign = s
.call(
"POST",
"/login",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("origin", "https://evil.example"),
],
Body::from(form.clone()),
)
.await;
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
assert!(body_text(foreign).await.contains("another site"));
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
let approved = s
.call(
"POST",
"/login",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("origin", PUBLIC),
],
Body::from(form),
)
.await;
assert_eq!(approved.status(), StatusCode::SEE_OTHER);
assert_eq!(header_str(&approved, "location"), "/login?approved=1");
let page = s
.call(
"GET",
&format!("/login?session={token}"),
None,
&[],
Body::empty(),
)
.await;
assert!(body_text(page).await.contains("Authorized"));
let done = body_json(poll_session(&s, &session_uri).await).await;
assert_eq!(done["status"], "completed");
assert_eq!(done["apiKey"], RW);
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
}
#[tokio::test]
async fn the_login_page_handles_bad_sessions() {
let s = server().await;
let missing = s.call("GET", "/login", None, &[], Body::empty()).await;
assert_eq!(missing.status(), StatusCode::BAD_REQUEST);
assert!(body_text(missing).await.contains("No login request"));
let unknown = s
.call(
"GET",
"/login?session=%3Cscript%3E",
None,
&[],
Body::empty(),
)
.await;
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
let html = body_text(unknown).await;
assert!(html.contains("Login expired"));
assert!(!html.contains("<script>"));
let approve_unknown = s
.call(
"POST",
"/login",
None,
&[("content-type", "application/x-www-form-urlencoded")],
Body::from("session=nope"),
)
.await;
assert_eq!(approve_unknown.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_login_page_uses_the_built_stylesheet() {
let s = server().await;
let html = body_text(s.call("GET", "/login", None, &[], Body::empty()).await).await;
let href = html
.split('"')
.find(|part| part.starts_with("/assets/") && part.ends_with(".css"))
.expect("the login page links the stylesheet")
.to_string();
let css = s.call("GET", &href, None, &[], Body::empty()).await;
assert_eq!(css.status(), StatusCode::OK);
assert!(body_text(css).await.contains(".max-w-md"));
}
#[tokio::test]
async fn the_schema_is_served_from_the_database() {
let s = server().await;
let missing = s.call("GET", "/api/schema", None, &[], Body::empty()).await;
assert_eq!(missing.status(), StatusCode::BAD_GATEWAY);
s.db.write(|c| {
db::set_meta(c, "schema", r#"{"version": 1, "itemTypes": []}"#)?;
db::set_meta(c, "schema_fetched", "0")
})
.await
.unwrap();
let stale = s.call("GET", "/api/schema", None, &[], Body::empty()).await;
assert_eq!(stale.status(), StatusCode::OK);
assert_eq!(body_json(stale).await["version"], 1);
}
#[tokio::test]
async fn the_web_ui_is_served() {
let s = server().await;
let index = s.call("GET", "/", None, &[], Body::empty()).await;
assert_eq!(index.status(), StatusCode::OK);
assert_eq!(header_str(&index, "zotero-server-id").len(), 32);
assert_eq!(header_str(&index, "vary"), "HX-Request");
assert_eq!(header_str(&index, "x-frame-options"), "DENY");
let csp = header_str(&index, "content-security-policy").to_string();
let html = body_text(index).await;
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains(r#"<div id="status" hx-get="/" hx-trigger="every 30s""#));
assert!(html.contains("Connect Zotero"));
assert!(html.contains(&format!("extensions.zotero.api.url = {PUBLIC}/api/")));
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|rest| rest.split('\'').next())
.expect("the CSP carries a nonce");
assert!(html.contains(&format!(r#"<script nonce="{nonce}">"#)));
let asset = html
.split('"')
.find(|part| part.starts_with("/assets/") && part.ends_with(".js"))
.expect("the page names its script")
.to_string();
let js = s.call("GET", &asset, None, &[], Body::empty()).await;
assert_eq!(js.status(), StatusCode::OK);
assert!(header_str(&js, "cache-control").contains("immutable"));
let status = body_json(s.call("GET", "/ui/status", None, &[], Body::empty()).await).await;
assert_eq!(status["name"], "defter");
assert_eq!(status["apiUrl"], format!("{PUBLIC}/api"));
assert_eq!(status["library"]["version"], 0);
assert_eq!(status["storage"]["location"], "memory://");
}
#[tokio::test]
async fn htmx_polls_get_only_the_status_block() {
let s = server().await;
s.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "AAAAAAAA", "itemType": "book", "title": "One"}]),
)
.await;
let poll = s
.call("GET", "/", None, &[("hx-request", "true")], Body::empty())
.await;
assert_eq!(poll.status(), StatusCode::OK);
assert_eq!(header_str(&poll, "vary"), "HX-Request");
let html = body_text(poll).await;
assert!(
html.trim_start().starts_with(r#"<div id="status""#),
"{html}"
);
assert!(!html.contains("<html"));
assert!(!html.contains("Connect Zotero"));
assert!(html.contains("Library version 1"));
let restore = s
.call(
"GET",
"/",
None,
&[
("hx-request", "true"),
("hx-history-restore-request", "true"),
],
Body::empty(),
)
.await;
assert!(body_text(restore).await.starts_with("<!DOCTYPE html>"));
}