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";
const ORCID: &str = "0000-0003-1419-2405";
struct Server {
router: Router,
app: app::AppState,
_dir: tempfile::TempDir,
db: std::sync::Arc<Db>,
}
async fn server() -> Server {
server_with_orcid(None).await
}
async fn server_with_orcid(orcid: Option<&str>) -> 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(),
orcid: orcid.map(String::from),
};
let app = App::new(cfg, db.clone(), storage, keys).await.unwrap();
Server {
router: app::router(app.clone()),
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}");
}
let resp = s.call("GET", "/", None, &[], Body::empty()).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(header_str(&resp, "zotero-server-id").len(), 32);
}
async fn sign_in(s: &Server, key: &str, origin: &str) -> Response {
let form = serde_urlencoded::to_string([("key", key)]).unwrap();
s.call(
"POST",
"/ui/session",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("origin", origin),
],
Body::from(form),
)
.await
}
fn session_cookie(resp: &Response) -> String {
header_str(resp, "set-cookie")
.split(';')
.next()
.unwrap()
.to_string()
}
async fn signed_in(s: &Server) -> String {
let resp = sign_in(s, RO, PUBLIC).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
session_cookie(&resp)
}
#[tokio::test]
async fn pages_say_what_they_are_built_with() {
let s = profile_server().await;
for (uri, accept, built_with, tagline) in [
("/".to_string(), "en", "Built with", "Self-hosted"),
(format!("/{ORCID}"), "en", "Built with", "Self-hosted"),
(
format!("/{ORCID}"),
"de",
"Erstellt mit",
"Selbst gehostete",
),
] {
let page = s
.call(
"GET",
&uri,
None,
&[("accept-language", accept)],
Body::empty(),
)
.await;
let html = body_text(page).await;
let (header, footer) = html.split_once("<footer").unwrap();
assert!(header.contains(tagline), "{uri}");
assert!(!footer.contains(tagline), "{uri}");
for shown in [
built_with,
"https://crates.io/crates/defter",
"https://front-matter.de",
"https://opensource.org/license/mit",
"mailto:info@front-matter.de",
"https://hachyderm.io/@mfenner",
] {
assert!(html.contains(shown), "{shown} missing from {uri}");
}
}
}
#[tokio::test]
async fn the_dashboard_needs_an_api_key() {
let s = server().await;
let page = s.call("GET", "/", None, &[], Body::empty()).await;
assert_eq!(page.status(), StatusCode::UNAUTHORIZED);
assert_eq!(header_str(&page, "cache-control"), "no-store");
let html = body_text(page).await;
assert!(html.contains(r#"<form method="post" action="/ui/session""#));
for secret in ["memory://", "Connect Zotero", "Library version", "Sign out"] {
assert!(!html.contains(secret), "{secret} shown without signing in");
}
let status = s.call("GET", "/ui/status", None, &[], Body::empty()).await;
assert_eq!(status.status(), StatusCode::UNAUTHORIZED);
let poll = s
.call("GET", "/", None, &[("hx-request", "true")], Body::empty())
.await;
assert_eq!(poll.status(), StatusCode::UNAUTHORIZED);
assert_eq!(header_str(&poll, "hx-refresh"), "true");
let wrong = sign_in(&s, "guess", PUBLIC).await;
assert_eq!(wrong.status(), StatusCode::FORBIDDEN);
assert!(header_str(&wrong, "set-cookie").is_empty());
assert!(body_text(wrong).await.contains("not an API key"));
let foreign = sign_in(&s, RW, "https://evil.example").await;
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
assert!(header_str(&foreign, "set-cookie").is_empty());
let ok = sign_in(&s, RO, PUBLIC).await;
assert_eq!(ok.status(), StatusCode::SEE_OTHER);
assert_eq!(header_str(&ok, "location"), "/");
let set_cookie = header_str(&ok, "set-cookie").to_string();
assert!(set_cookie.starts_with("defter_session="), "{set_cookie}");
for attribute in ["HttpOnly", "SameSite=Lax", "Path=/"] {
assert!(set_cookie.contains(attribute), "{set_cookie}");
}
let cookie = session_cookie(&ok);
let page = s
.call("GET", "/", None, &[("cookie", &cookie)], Body::empty())
.await;
assert_eq!(page.status(), StatusCode::OK);
let html = body_text(page).await;
assert!(html.contains("Connect Zotero"));
assert!(html.contains(r#"<form method="post" action="/ui/logout">"#));
for headers in [
vec![("cookie", cookie.as_str())],
vec![("zotero-api-key", RO)],
] {
let status = s
.call("GET", "/ui/status", None, &headers, Body::empty())
.await;
assert_eq!(status.status(), StatusCode::OK);
assert_eq!(body_json(status).await["name"], "defter");
}
let out = s
.call(
"POST",
"/ui/logout",
None,
&[("cookie", &cookie), ("origin", PUBLIC)],
Body::empty(),
)
.await;
assert_eq!(out.status(), StatusCode::SEE_OTHER);
assert!(header_str(&out, "set-cookie").contains("Max-Age=0"));
let after = s
.call("GET", "/", None, &[("cookie", &cookie)], Body::empty())
.await;
assert_eq!(after.status(), StatusCode::UNAUTHORIZED);
}
async fn profile_server() -> Server {
let s = server_with_orcid(Some(ORCID)).await;
s.app.person.set(
serde_json::from_value(json!({
"given_name": "Ada",
"family_name": "Example",
"description": "<b>First</b> paragraph.",
"country": "DE",
"affiliations": [{"name": "Front Matter", "role": "Founder"}],
}))
.unwrap(),
);
let resp = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([
{"key": "ARTICLEA", "itemType": "journalArticle", "title": "Article work",
"date": "2020", "DOI": "10.1234/article", "inPublications": true,
"abstractNote": "The abstract.", "language": "en", "rights": "CC BY 4.0",
"creators": [{"firstName": "Ada", "lastName": "Author", "creatorType": "author"}],
"tags": [{"tag": "secret tag"}]},
{"key": "BLGPSTAA", "itemType": "blogPost", "title": "Blog work",
"date": "2021-05-01", "inPublications": true, "tags": [{"tag": "Featured"}]},
{"key": "LIBRARYA", "itemType": "book", "title": "Library work", "date": "1999"},
{"key": "PDFWEBAA", "itemType": "attachment", "parentItem": "ARTICLEA",
"linkMode": "imported_url", "contentType": "application/pdf",
"url": "https://publisher.example/article.pdf", "filename": "article.pdf"},
{"key": "PDFSTRGE", "itemType": "attachment", "parentItem": "BLGPSTAA",
"linkMode": "imported_url", "contentType": "application/pdf",
"url": "https://zotero.fsn1.your-objectstorage.com/blog.pdf"},
{"key": "PDFDISKA", "itemType": "attachment", "parentItem": "BLGPSTAA",
"linkMode": "imported_file", "contentType": "application/pdf",
"filename": "blog.pdf"},
{"key": "PDFPRIVA", "itemType": "attachment", "parentItem": "LIBRARYA",
"linkMode": "imported_url", "contentType": "application/pdf",
"url": "https://private.example/library.pdf"},
{"key": "TRASHEDA", "itemType": "book", "title": "Trashed work", "date": "1998",
"inPublications": true, "deleted": 1},
]),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["failed"], json!({}), "{body}");
s
}
async fn visit(s: &Server, uri: &str) -> Response {
s.call("GET", uri, None, &[], Body::empty()).await
}
#[tokio::test]
async fn the_public_profile_shows_my_publications() {
let s = profile_server().await;
let uri = format!("/{ORCID}");
let page = visit(&s, &uri).await;
assert_eq!(page.status(), StatusCode::OK);
assert!(!header_str(&page, "content-security-policy").is_empty());
let html = body_text(page).await;
for shown in [
r#"<html lang="en">"#,
"Ada Example",
"Germany",
"Founder",
"Article work",
"Blog work",
"The abstract.",
"May 1, 2021",
"English",
"CC BY 4.0",
"https://doi.org/10.1234/article",
"https://publisher.example/article.pdf",
"Article work. https://doi.org/10.1234/article",
"<b>First",
] {
assert!(html.contains(shown), "{shown} missing from {html}");
}
for hidden in [
"Library work",
"Trashed work",
">1999<",
">1998<",
"secret tag",
"noindex",
"Sign out",
"commonmeta.org",
"your-objectstorage.com",
"blog.pdf",
"private.example",
"/file",
r#"noreferrer" class="hover:underline">Article work"#,
] {
assert!(!html.contains(hidden), "{hidden} shown");
}
for other in [
"/0000-0002-1825-0097".to_string(),
"/nonsense".to_string(),
format!("/users/{ORCID}"),
] {
let resp = visit(&s, &other).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{other}");
}
let items = visit(&s, "/users/1/items").await;
assert_eq!(items.status(), StatusCode::FORBIDDEN);
assert_eq!(visit(&s, "/api").await.status(), StatusCode::OK);
let none = server().await;
assert_eq!(visit(&none, &uri).await.status(), StatusCode::NOT_FOUND);
}
async fn html_has_facet(s: &Server, uri: &str, query: &str) -> bool {
let html = body_text(visit(s, uri).await).await;
html.contains(&format!("{uri}?{query}"))
}
#[tokio::test]
async fn the_public_profile_narrows_works_by_facets_and_search() {
let s = profile_server().await;
let uri = format!("/{ORCID}");
let html = body_text(visit(&s, &uri).await).await;
assert!(html.contains(&format!("{uri}?year=2020")), "{html}");
assert!(html.contains(&format!("{uri}?type=blogPost")));
let html = body_text(visit(&s, &format!("{uri}?type=blogPost")).await).await;
assert!(html.contains("Blog work"));
assert!(!html.contains("Article work"));
assert!(
html.contains(&format!(r#"href="{uri}" aria-current="true""#)),
"{html}"
);
assert!(html.contains(&format!("{uri}?year=2020&type=blogPost")));
let html = body_text(visit(&s, &format!("{uri}?year=2020&year=2021")).await).await;
assert!(html.contains("Article work") && html.contains("Blog work"));
let html = body_text(visit(&s, &format!("{uri}?year=2020&type=blogPost")).await).await;
assert!(html.contains("0 works"));
assert!(html.contains("No results found"));
let html = body_text(visit(&s, &format!("{uri}?q=BLOG")).await).await;
assert!(html.contains("Blog work"));
assert!(!html.contains("Article work"));
assert!(html.contains(r#"value="BLOG""#), "{html}");
assert!(html.contains(&format!("{uri}?q=BLOG&year=2021")));
assert!(html_has_facet(&s, &uri, "content=pdf").await);
let html = body_text(visit(&s, &format!("{uri}?content=pdf")).await).await;
assert!(html.contains("Article work"));
assert!(!html.contains("Blog work"));
assert!(html.contains(r#"<input type="hidden" name="content" value="pdf">"#));
let ris = body_text(visit(&s, &format!("/{ORCID}/export?format=ris&content=pdf")).await).await;
assert!(
ris.contains("Article work") && !ris.contains("Blog work"),
"{ris}"
);
let html = body_text(visit(&s, &format!("{uri}?featured=1")).await).await;
assert!(html.contains("Blog work"));
assert!(!html.contains("Article work"));
assert!(html.contains("Featured only"));
assert!(html.contains(&format!(r#"href="{uri}" aria-current="true""#)));
for q in ["secret", "Library", "abstract"] {
let html = body_text(visit(&s, &format!("{uri}?q={q}")).await).await;
assert!(html.contains("0 works"), "{q}");
}
let html = body_text(visit(&s, &format!("{uri}?year=1999")).await).await;
assert!(!html.contains("Library work"));
let html = body_text(visit(&s, &format!("{uri}?page=nope")).await).await;
assert!(html.contains("Article work"));
let page = visit(&s, &format!("{uri}?page=4294967295")).await;
assert_eq!(page.status(), StatusCode::OK);
assert!(!body_text(page).await.contains("Article work"));
}
#[tokio::test]
async fn htmx_gets_only_the_list_of_works() {
let s = profile_server().await;
let uri = format!("/{ORCID}?type=blogPost");
let page = s
.call("GET", &uri, None, &[("hx-request", "true")], Body::empty())
.await;
assert_eq!(page.status(), StatusCode::OK);
assert_eq!(header_str(&page, "vary"), "HX-Request, Accept-Language");
let html = body_text(page).await;
assert!(
html.trim_start().starts_with(r#"<section id="works""#),
"{html}"
);
assert!(html.contains("Blog work"));
for frame in ["<html", "Ada Example", "Founder"] {
assert!(!html.contains(frame), "{frame} in {html}");
}
assert!(html.contains(r#"id="work-search""#), "{html}");
assert!(html.contains(r#"<input type="hidden" name="type" value="blogPost">"#));
let page = s
.call(
"GET",
&uri,
None,
&[
("hx-request", "true"),
("hx-history-restore-request", "true"),
],
Body::empty(),
)
.await;
assert!(body_text(page).await.contains("<html"));
}
#[tokio::test]
async fn the_public_profile_speaks_the_visitors_language() {
let s = profile_server().await;
let uri = format!("/{ORCID}");
let german = |lang: &'static str| {
let (s, uri) = (&s, &uri);
async move {
let page = s
.call(
"GET",
uri,
None,
&[("accept-language", lang)],
Body::empty(),
)
.await;
body_text(page).await
}
};
let html = german("de-DE,de;q=0.9").await;
for shown in [
r#"<html lang="de">"#,
"Vorname",
"Deutschland",
"Zugehörigkeiten",
"2 Werke",
"Blogbeitrag veröffentlicht 1. Mai 2021",
"Englisch",
"Veröffentlicht unter den Bedingungen der",
"Selbst gehostete",
"Exportieren",
] {
assert!(html.contains(shown), "{shown} missing from {html}");
}
let html = german("de-AT").await;
assert!(html.contains(r#"<html lang="de">"#));
assert!(!html.contains("de-AT"));
let page = s
.call(
"GET",
&format!("{uri}?lang=en"),
None,
&[("accept-language", "de")],
Body::empty(),
)
.await;
let html = body_text(page).await;
assert!(html.contains("Given name"));
assert!(
html.contains(&format!("{uri}?year=2020&lang=en")),
"{html}"
);
let html = body_text(visit(&s, &format!("{uri}?lang=xx")).await).await;
assert!(html.contains("Given name"));
assert!(!html.contains("lang=xx"));
}
#[tokio::test]
async fn the_public_profile_pages_its_works() {
let s = profile_server().await;
let items: Vec<Value> = (0..30u8)
.map(|i| {
json!({"key": format!("PAGEDA{}{}", (b'A' + i / 10) as char, (b'A' + i % 10) as char),
"itemType": "book", "title": format!("Paged {i}"),
"date": "2000", "inPublications": true})
})
.collect();
let resp = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "1")],
json!(items),
)
.await;
assert_eq!(resp.status(), StatusCode::OK, "{}", body_text(resp).await);
let uri = format!("/{ORCID}");
let first = body_text(visit(&s, &uri).await).await;
assert!(first.contains(&format!("{uri}?page=2")));
assert!(first.contains("page 1 of 4"));
assert!(!first.contains("Previous page"));
assert!(first.contains("Next page"));
let last = body_text(visit(&s, &format!("{uri}?page=4")).await).await;
assert!(last.contains("Previous page"));
assert!(!last.contains("Next page"));
assert!(last.contains(r#"aria-current="page""#));
assert!(last.contains("31."));
}
#[tokio::test]
async fn the_public_profile_exports_what_the_list_shows() {
let s = profile_server().await;
let uri = format!("/{ORCID}/export");
let resp = visit(&s, &format!("{uri}?format=ris")).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
header_str(&resp, "content-type"),
"text/plain; charset=utf-8"
);
assert_eq!(
header_str(&resp, "content-disposition"),
format!("attachment; filename=\"{ORCID}.ris\"")
);
let ris = body_text(resp).await;
assert!(
ris.contains("Article work") && ris.contains("Blog work"),
"{ris}"
);
assert!(!ris.contains("Library work") && !ris.contains("Trashed work"));
assert!(!ris.contains("secret tag"), "{ris}");
let bare = body_text(visit(&s, &format!("{uri}?featured=1")).await).await;
assert!(
bare.contains("Blog work") && !bare.contains("Article work"),
"{bare}"
);
let csl = body_text(visit(&s, &format!("{uri}?format=csl&year=2020")).await).await;
assert!(
csl.contains("Article work") && !csl.contains("Blog work"),
"{csl}"
);
let citation = visit(&s, &format!("{uri}?format=citation&style=ieee")).await;
assert_eq!(citation.status(), StatusCode::OK);
let citation = body_text(citation).await;
assert!(citation.contains("Article work"), "{citation}");
assert!(!citation.contains('<'), "{citation}");
for bad in ["?format=nonsense", "?format=citation&style=../../etc"] {
let resp = visit(&s, &format!("{uri}{bad}")).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{bad}");
}
let other = visit(&s, "/0000-0002-1825-0097/export").await;
assert_eq!(other.status(), StatusCode::NOT_FOUND);
crate::profile::set_hidden(&s.app, true).await.unwrap();
assert_eq!(visit(&s, &uri).await.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_owner_can_hide_the_public_profile() {
let s = profile_server().await;
let uri = format!("/{ORCID}");
let set_hidden = |cookie: String, origin: &'static str, hidden: &'static str| {
let s = &s;
async move {
s.call(
"POST",
"/ui/profile",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("cookie", &cookie),
("origin", origin),
],
Body::from(format!("hidden={hidden}")),
)
.await
.status()
}
};
assert_eq!(
set_hidden(String::new(), PUBLIC, "true").await,
StatusCode::UNAUTHORIZED
);
let read_only = signed_in(&s).await;
assert_eq!(
set_hidden(read_only.clone(), PUBLIC, "true").await,
StatusCode::FORBIDDEN
);
let owner = session_cookie(&sign_in(&s, RW, PUBLIC).await);
assert_eq!(
set_hidden(owner.clone(), "https://evil.example", "true").await,
StatusCode::FORBIDDEN
);
assert_eq!(visit(&s, &uri).await.status(), StatusCode::OK);
let dashboard = |cookie: String| {
let s = &s;
async move {
let page = s
.call("GET", "/", None, &[("cookie", &cookie)], Body::empty())
.await;
body_text(page).await
}
};
let html = dashboard(read_only).await;
assert!(html.contains("Public profile"));
assert!(!html.contains("Hide profile"));
let html = dashboard(owner.clone()).await;
assert!(html.contains("Hide profile"));
assert!(html.contains("It lists 2 works"));
assert!(html.contains(&format!("{PUBLIC}/{ORCID}")));
assert_eq!(
set_hidden(owner.clone(), PUBLIC, "true").await,
StatusCode::SEE_OTHER
);
assert_eq!(visit(&s, &uri).await.status(), StatusCode::NOT_FOUND);
assert!(dashboard(owner.clone()).await.contains("Show profile"));
assert_eq!(
set_hidden(owner, PUBLIC, "false").await,
StatusCode::SEE_OTHER
);
assert_eq!(visit(&s, &uri).await.status(), StatusCode::OK);
}
#[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 no_item = s
.form(
"/users/1/items/GGGGGGGG/file",
&[("if-none-match", "*")],
&auth_form,
)
.await;
assert_eq!(no_item.status(), StatusCode::NOT_FOUND);
let not_stored = s
.form(
"/users/1/items/FFFFFFFF/file",
&[("if-match", md5.as_str())],
&auth_form,
)
.await;
assert_eq!(not_stored.status(), StatusCode::PRECONDITION_FAILED);
assert!(not_stored.headers().get("last-modified-version").is_none());
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());
s.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "2")],
json!([{"key": "HHHHHHHH", "itemType": "book", "title": "x"}]),
)
.await;
for precondition in [("if-none-match", "*"), ("if-match", md5.as_str())] {
let again = s
.form("/users/1/items/FFFFFFFF/file", &[precondition], &auth_form)
.await;
assert_eq!(header_str(&again, "last-modified-version"), "2");
assert_eq!(body_json(again).await, json!({"exists": 1}));
}
let other_file = [
("md5", "0123456789abcdef0123456789abcdef"),
("filename", "a.pdf"),
("filesize", "3"),
("mtime", "1700000000001"),
];
for precondition in [
("if-none-match", "*"),
("if-match", "fedcba9876543210fedcba9876543210"),
] {
let stale = s
.form("/users/1/items/FFFFFFFF/file", &[precondition], &other_file)
.await;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(header_str(&stale, "last-modified-version"), "2");
}
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 snapshots_upload_as_zip_and_download_compressed() {
let s = server().await;
s.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "SSSSSSSS", "itemType": "attachment", "linkMode": "imported_url",
"contentType": "text/html", "filename": "page.html"}]),
)
.await;
let html = b"<html>snapshot</html>";
let zip = b"PK\x03\x04 not really a zip, but its own bytes";
let md5 = format!("{:x}", md5::compute(html));
let zip_md5 = format!("{:x}", md5::compute(zip));
let size = zip.len().to_string();
let auth = body_json(
s.form(
"/users/1/items/SSSSSSSS/file",
&[("if-none-match", "*")],
&[
("md5", md5.as_str()),
("filename", "page.html"),
("filesize", &size),
("mtime", "1700000000000"),
("zipMD5", &zip_md5),
("zipFilename", "SSSSSSSS.zip"),
],
)
.await,
)
.await;
let path = auth["url"].as_str().unwrap().strip_prefix(PUBLIC).unwrap();
let sent = s.call("POST", path, None, &[], Body::from(&zip[..])).await;
assert_eq!(sent.status(), StatusCode::CREATED);
let registered = s
.form(
"/users/1/items/SSSSSSSS/file",
&[("if-none-match", "*")],
&[("upload", auth["uploadKey"].as_str().unwrap())],
)
.await;
assert_eq!(registered.status(), StatusCode::NO_CONTENT);
let item = body_json(s.get("/users/1/items/SSSSSSSS").await).await;
assert_eq!(item["data"]["md5"], md5.as_str());
let redirect = s.get("/users/1/items/SSSSSSSS/file").await;
assert_eq!(header_str(&redirect, "zotero-file-md5"), md5);
assert_eq!(header_str(&redirect, "zotero-file-compressed"), "Yes");
let location = header_str(&redirect, "location").to_string();
let download = s
.call(
"GET",
location.strip_prefix(PUBLIC).unwrap(),
None,
&[],
Body::empty(),
)
.await;
assert_eq!(
header_str(&download, header::CONTENT_TYPE.as_str()),
"application/zip"
);
assert_eq!(body_text(download).await.as_bytes(), zip);
}
#[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"), "same-origin");
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!(html.contains(r#"type="password" name="key""#));
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
let approve = |key: &str| {
let form =
serde_urlencoded::to_string([("session", token.as_str()), ("key", key)]).unwrap();
s.call(
"POST",
"/login",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("origin", PUBLIC),
],
Body::from(form),
)
};
for (key, why) in [
("", "not an API key"),
("guess", "not an API key"),
(RO, "may only read"),
] {
let refused = approve(key).await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{key:?}");
let html = body_text(refused).await;
assert!(html.contains(why), "{key:?}: {html}");
assert!(html.contains(&format!(r#"name="session" value="{token}""#)));
assert_eq!(
body_json(poll_session(&s, &session_uri).await).await["status"],
"pending"
);
}
let form = serde_urlencoded::to_string([("session", token.as_str()), ("key", RW)]).unwrap();
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 = approve(RW).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 cookie = signed_in(&s).await;
let index = s
.call("GET", "/", None, &[("cookie", &cookie)], 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", Some(RW), &[], 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 cookie = signed_in(&s).await;
let poll = s
.call(
"GET",
"/",
None,
&[("hx-request", "true"), ("cookie", &cookie)],
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"),
("cookie", &cookie),
],
Body::empty(),
)
.await;
assert!(body_text(restore).await.starts_with("<!DOCTYPE html>"));
}