use std::collections::HashMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
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::control::Control;
use crate::db::Db;
use crate::registry::{self, Libraries};
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,
_owner: std::sync::Arc<Db>,
}
async fn server() -> Server {
server_with_orcid(None).await
}
async fn server_with_orcid(orcid: Option<&str>) -> Server {
server_with(orcid, None).await
}
async fn server_with(orcid: Option<&str>, oidc: Option<crate::oidc::OidcConfig>) -> Server {
server_configured(orcid, oidc, true).await
}
async fn server_configured(
orcid: Option<&str>,
oidc: Option<crate::oidc::OidcConfig>,
key_login: bool,
) -> Server {
let dir = tempfile::tempdir().unwrap();
let control = Arc::new(Control::open(&dir.path().join("control.sqlite3")).unwrap());
control
.ensure_owner(registry::owner(1, "tester", false))
.await
.unwrap();
let libraries = Libraries::new(dir.path().join("libraries"), control.clone(), 150);
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),
oidc,
key_login,
};
let app = App::new(cfg, control, libraries, storage, keys)
.await
.unwrap();
let db = app.owner_library().await.unwrap().db.clone();
Server {
router: app::router(app.clone()),
app,
_dir: dir,
_owner: 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, &format!("{uri}?featured=0")).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",
r#"data-copy-work="ARTICLEA""#,
"<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, path: &str) -> bool {
let html = body_text(visit(s, uri).await).await;
html.contains(&format!("{path}?{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, &format!("{uri}?featured=0")).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}?featured=0" 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, &format!("{uri}?featured=0"), "content=pdf", &uri).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}?featured=0" 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, format!("{uri}?featured=0"));
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}?featured=0&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, &format!("{uri}?featured=0")).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_opens_on_up_to_five_featured_works() {
let s = profile_server().await;
let uri = format!("/{ORCID}");
let items: Vec<Value> = (0..6u8)
.map(|i| {
json!({"key": format!("FEATRDA{}", (b'A' + i) as char),
"itemType": "book", "title": format!("Featured {i}"),
"date": format!("203{i}"), "inPublications": true,
"tags": [{"tag": "featured"}]})
})
.collect();
let resp = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "1")],
json!(items),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await["failed"], json!({}));
let rendered = |html: &str| {
html.matches(r#"text-base font-semibold leading-snug"#)
.count()
};
for view in [
uri.clone(),
format!("{uri}?lang=en"),
format!("{uri}?featured=1"),
] {
let html = body_text(visit(&s, &view).await).await;
assert_eq!(rendered(&html), 5, "{view}");
assert!(html.contains("5 works"), "{view}");
assert!(
html.contains("Featured 5") && !html.contains("Featured 0"),
"{view}"
);
assert!(!html.contains("Article work"), "{view}");
assert!(!html.contains("Next page"), "{view}");
assert!(html.contains("?featured=0"), "{view}: {html}");
assert!(html.contains(r#"aria-current="true""#), "{view}");
}
let html = body_text(visit(&s, &uri).await).await;
for picked in ["year=2021", "type=blogPost", "content=pdf"] {
assert!(
html.contains(&format!(r#"href="{uri}?{picked}""#)),
"{picked}: {html}"
);
}
let html = body_text(visit(&s, &format!("{uri}?featured=1&type=book")).await).await;
assert!(html.contains(r#"aria-current="true""#));
assert!(
html.contains(&format!(r#"href="{uri}?featured=1" aria-current="true""#)),
"{html}"
);
assert!(
html.contains(&format!(r#"href="{uri}?year=2035&type=book""#)),
"{html}"
);
let html = body_text(visit(&s, &format!("{uri}?featured=1&page=2")).await).await;
assert_eq!(rendered(&html), 5);
let html = body_text(visit(&s, &format!("{uri}?featured=0")).await).await;
assert!(html.contains("8 works"), "{html}");
assert!(html.contains("Article work"));
let html = body_text(visit(&s, &format!("{uri}?year=2020")).await).await;
assert!(html.contains("Article work"), "{html}");
let html = body_text(visit(&s, &format!("{uri}?q=")).await).await;
assert!(html.contains("8 works"), "{html}");
let count = |ris: &str| ris.matches("TY -").count();
let all = body_text(visit(&s, &format!("{uri}/export?format=ris")).await).await;
assert_eq!(count(&all), 8, "{all}");
let featured = body_text(visit(&s, &format!("{uri}/export?format=ris&featured=1")).await).await;
assert_eq!(count(&featured), 5, "{featured}");
let plain = server_with_orcid(Some(ORCID)).await;
plain
.app
.person
.set(serde_json::from_value(json!({"family_name": "Example"})).unwrap());
let resp = plain
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "ARTICLEA", "itemType": "book", "title": "Plain work",
"inPublications": true}]),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let html = body_text(visit(&plain, &uri).await).await;
assert!(html.contains("Plain work"), "{html}");
assert!(!html.contains("Featured only"), "{html}");
}
#[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}");
let one = body_text(visit(&s, &format!("{uri}?format=ris&key=ARTICLEA")).await).await;
assert!(
one.contains("Article work") && !one.contains("Blog work"),
"{one}"
);
assert_eq!(one.matches("TY -").count(), 1, "{one}");
let one = body_text(
visit(
&s,
&format!("{uri}?format=citation&style=ieee&key=BLGPSTAA"),
)
.await,
)
.await;
assert!(one.contains("Blog work"), "{one}");
for private in ["LIBRARYA", "TRASHEDA", "UNKNOWNA", "PDFWEBAA"] {
let resp = visit(&s, &format!("{uri}?format=ris&key={private}")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{private}");
}
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);
}
#[derive(Default)]
struct MockIdp {
nonce: String,
challenge: String,
id_claims: Value,
userinfo: Value,
}
async fn mock_idp() -> (String, Arc<Mutex<MockIdp>>) {
use axum::{Form, Json, extract::State, routing};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let idp = Arc::new(Mutex::new(MockIdp::default()));
let (i1, i2) = (issuer.clone(), issuer.clone());
let app = Router::new()
.route(
"/.well-known/openid-configuration",
routing::get(move || {
let issuer = i1.clone();
async move {
Json(json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/auth"),
"token_endpoint": format!("{issuer}/token"),
"userinfo_endpoint": format!("{issuer}/userinfo"),
"end_session_endpoint": format!("{issuer}/logout"),
}))
}
}),
)
.route(
"/token",
routing::post(
move |State(idp): State<Arc<Mutex<MockIdp>>>,
Form(form): Form<HashMap<String, String>>| {
let issuer = i2.clone();
async move {
let idp = idp.lock().unwrap();
let verifier = form.get("code_verifier").cloned().unwrap_or_default();
if form.get("code").map(String::as_str) != Some("good-code")
|| crate::oidc::challenge(&verifier) != idp.challenge
|| form.get("client_secret").map(String::as_str) != Some("s3cret")
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid_grant"})),
);
}
let mut claims = json!({
"iss": issuer, "aud": "defter", "exp": 4_000_000_000u64,
"nonce": idp.nonce, "sub": "abc",
});
for (k, v) in idp.id_claims.as_object().into_iter().flatten() {
claims[k] = v.clone();
}
let part = |v: &Value| {
base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
v.to_string(),
)
};
let id_token =
format!("{}.{}.sig", part(&json!({"alg": "RS256"})), part(&claims));
(
StatusCode::OK,
Json(json!({"id_token": id_token, "access_token": "at"})),
)
}
},
),
)
.route(
"/userinfo",
routing::get(|State(idp): State<Arc<Mutex<MockIdp>>>| async move {
Json(idp.lock().unwrap().userinfo.clone())
}),
)
.with_state(idp.clone());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(issuer, idp)
}
const OTHER_ORCID: &str = "0000-0002-1825-0097";
async fn oidc_server(orcid: Option<&str>) -> (Server, Arc<Mutex<MockIdp>>) {
let (issuer, idp) = mock_idp().await;
let cfg = crate::oidc::OidcConfig::new(
&issuer,
"defter",
Some("s3cret".to_string()),
ORCID,
"orcid",
"Keycloak",
)
.unwrap();
(server_with(orcid, Some(cfg)).await, idp)
}
async fn oidc_start(s: &Server, idp: &Mutex<MockIdp>, query: &str) -> (String, String) {
let resp = visit(s, &format!("/ui/oidc/login{query}")).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let cookie = session_cookie(&resp);
let location = header_str(&resp, "location").to_string();
let (_, q) = location.split_once('?').unwrap();
let params: HashMap<String, String> = serde_urlencoded::from_str(q).unwrap();
assert_eq!(params["response_type"], "code");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["redirect_uri"], format!("{PUBLIC}/ui/oidc/callback"));
{
let mut idp = idp.lock().unwrap();
idp.nonce = params["nonce"].clone();
idp.challenge = params["code_challenge"].clone();
}
(params["state"].clone(), cookie)
}
async fn oidc_finish(s: &Server, state: &str, cookie: &str) -> Response {
s.call(
"GET",
&format!("/ui/oidc/callback?state={state}&code=good-code"),
None,
&[("cookie", cookie)],
Body::empty(),
)
.await
}
fn set_cookies(resp: &Response) -> Vec<String> {
resp.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect()
}
fn signed_in_cookie(resp: &Response) -> Option<String> {
set_cookies(resp)
.into_iter()
.find(|c| c.starts_with("defter_session=") && !c.contains("Max-Age=0"))
.map(|c| c.split(';').next().unwrap().to_string())
}
#[tokio::test]
async fn an_allowed_account_signs_in_through_the_provider() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": format!("https://orcid.org/{ORCID}")});
assert_eq!(
visit(&s, &format!("/{ORCID}")).await.status(),
StatusCode::NOT_FOUND
);
let html = body_text(visit(&s, "/").await).await;
assert!(html.contains("Sign in with Keycloak"), "{html}");
let (state, cookie) = oidc_start(&s, &idp, "").await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(header_str(&resp, "location"), "/");
let session = signed_in_cookie(&resp).expect("a session");
assert!(
set_cookies(&resp)
.iter()
.any(|c| c.starts_with("defter_oidc=;"))
);
s.app
.person
.set(serde_json::from_value(json!({"family_name": "Example"})).unwrap());
let page = s
.call("GET", "/", None, &[("cookie", &session)], Body::empty())
.await;
assert_eq!(page.status(), StatusCode::OK);
assert!(body_text(page).await.contains("Hide profile"));
assert_eq!(
visit(&s, &format!("/{ORCID}")).await.status(),
StatusCode::OK
);
let again = oidc_finish(&s, &state, &cookie).await;
assert_eq!(again.status(), StatusCode::FORBIDDEN);
assert!(signed_in_cookie(&again).is_none());
}
#[tokio::test]
async fn the_orcid_claim_may_come_from_userinfo() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().userinfo = json!({"sub": "abc", "orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert!(signed_in_cookie(&resp).is_some());
idp.lock().unwrap().userinfo = json!({"sub": "other", "orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(signed_in_cookie(&resp).is_none());
}
#[tokio::test]
async fn others_get_no_session_from_the_provider() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().userinfo = json!({"sub": "abc"});
for (claims, message) in [
(
json!({"orcid": OTHER_ORCID}),
"not allowed into this library",
),
(json!({"orcid": "not an orcid"}), "no ORCID iD"),
(json!({}), "no ORCID iD"),
(json!({"orcid": ORCID, "aud": "other"}), "did not work"),
(
json!({"orcid": ORCID, "iss": "https://evil.example"}),
"did not work",
),
(json!({"orcid": ORCID, "nonce": "other"}), "did not work"),
(json!({"orcid": ORCID, "exp": 1}), "did not work"),
] {
idp.lock().unwrap().id_claims = claims.clone();
let (state, cookie) = oidc_start(&s, &idp, "").await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{claims}");
assert!(signed_in_cookie(&resp).is_none(), "{claims}");
let html = body_text(resp).await;
assert!(html.contains(message), "{claims}: {html}");
}
assert_eq!(
visit(&s, &format!("/{OTHER_ORCID}")).await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
visit(&s, &format!("/{ORCID}")).await.status(),
StatusCode::NOT_FOUND
);
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
for (uri, sent) in [
(
format!("/ui/oidc/callback?state={state}&code=good-code"),
"defter_oidc=other",
),
(
format!("/ui/oidc/callback?state={state}&code=good-code"),
"",
),
(
"/ui/oidc/callback?code=good-code".to_string(),
cookie.as_str(),
),
(
"/ui/oidc/callback?state=unknown&code=good-code".to_string(),
"defter_oidc=unknown",
),
] {
let headers: &[(&str, &str)] = if sent.is_empty() {
&[]
} else {
&[("cookie", sent)]
};
let resp = s.call("GET", &uri, None, headers, Body::empty()).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{uri} {sent}");
assert!(signed_in_cookie(&resp).is_none(), "{uri} {sent}");
}
let (state, cookie) = oidc_start(&s, &idp, "").await;
let wrong = s
.call(
"GET",
&format!("/ui/oidc/callback?state={state}&code=wrong"),
None,
&[("cookie", &cookie)],
Body::empty(),
)
.await;
assert_eq!(wrong.status(), StatusCode::FORBIDDEN);
let (state, cookie) = oidc_start(&s, &idp, "").await;
let denied = s
.call(
"GET",
&format!("/ui/oidc/callback?state={state}&error=access_denied"),
None,
&[("cookie", &cookie)],
Body::empty(),
)
.await;
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
assert!(signed_in_cookie(&denied).is_none());
}
#[tokio::test]
async fn orcid_setting_keeps_the_profile_and_oidc_is_optional() {
let (s, idp) = oidc_server(Some(OTHER_ORCID)).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
assert!(signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).is_some());
s.app
.person
.set(serde_json::from_value(json!({"family_name": "Example"})).unwrap());
assert_eq!(
visit(&s, &format!("/{OTHER_ORCID}")).await.status(),
StatusCode::OK
);
assert_eq!(
visit(&s, &format!("/{ORCID}")).await.status(),
StatusCode::NOT_FOUND
);
let s = server().await;
assert!(
!body_text(visit(&s, "/").await)
.await
.contains("Sign in with")
);
for uri in ["/ui/oidc/login", "/ui/oidc/callback?state=a&code=b"] {
assert_eq!(
visit(&s, uri).await.status(),
StatusCode::NOT_FOUND,
"{uri}"
);
}
}
#[tokio::test]
async fn a_saved_owner_needs_to_stay_on_the_list() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
assert!(signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).is_some());
assert_eq!(
crate::profile::owner_orcid(&s.app)
.await
.unwrap()
.as_deref(),
Some(ORCID)
);
let (issuer, _) = mock_idp().await;
let cfg = Config {
oidc: Some(
crate::oidc::OidcConfig::new(&issuer, "defter", None, OTHER_ORCID, "orcid", "K")
.unwrap(),
),
..s.app.cfg.clone()
};
let storage = Storage::new(&Backend::Memory, Duration::from_secs(60), false).unwrap();
let keys = Keys::new(HashMap::from([(RW.to_string(), Access { write: true })])).unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let libraries = Libraries::new(elsewhere.path().to_path_buf(), s.app.control.clone(), 1);
let app = App::new(cfg, s.app.control.clone(), libraries, storage, keys)
.await
.unwrap();
assert_eq!(crate::profile::owner_orcid(&app).await.unwrap(), None);
crate::profile::claim_ownership(&app, OTHER_ORCID)
.await
.unwrap();
assert_eq!(
crate::profile::owner_orcid(&app).await.unwrap().as_deref(),
Some(OTHER_ORCID)
);
}
#[tokio::test]
async fn a_provider_sign_in_approves_zotero_logins() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let created = s
.call("POST", "/keys/sessions", None, &[], Body::empty())
.await;
let created = body_json(created).await;
let token = created["sessionToken"].as_str().unwrap().to_string();
let uri = format!("/login?session={token}");
let html = body_text(visit(&s, &uri).await).await;
assert!(html.contains(r#"name="key""#));
assert!(
html.contains(&format!("/ui/oidc/login?session={token}")),
"{html}"
);
let form = |session: &str, cookie: Option<&str>| {
let body = serde_urlencoded::to_string([("session", session)]).unwrap();
let mut headers = vec![
(
"content-type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("origin".to_string(), PUBLIC.to_string()),
];
if let Some(cookie) = cookie {
headers.push(("cookie".to_string(), cookie.to_string()));
}
(body, headers)
};
let post = |body: String, headers: Vec<(String, String)>| {
let s = &s;
async move {
let refs: Vec<(&str, &str)> = headers
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
s.call("POST", "/login", None, &refs, Body::from(body))
.await
}
};
let (body, headers) = form(&token, None);
assert_eq!(post(body, headers).await.status(), StatusCode::FORBIDDEN);
let by_key = session_cookie(&sign_in(&s, RW, PUBLIC).await);
let (body, headers) = form(&token, Some(&by_key));
assert_eq!(post(body, headers).await.status(), StatusCode::FORBIDDEN);
let pending = body_json(
s.call(
"GET",
&format!("/keys/sessions/{token}"),
None,
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(pending["status"], "pending");
let (state, cookie) = oidc_start(&s, &idp, &format!("?session={token}")).await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert_eq!(header_str(&resp, "location"), uri);
let session = signed_in_cookie(&resp).unwrap();
let page = s
.call("GET", &uri, None, &[("cookie", &session)], Body::empty())
.await;
let html = body_text(page).await;
assert!(!html.contains(r#"name="key""#), "{html}");
assert!(html.contains("Approve"));
let (body, mut headers) = form(&token, Some(&session));
headers[1].1 = "https://evil.example".to_string();
assert_eq!(post(body, headers).await.status(), StatusCode::FORBIDDEN);
let (body, headers) = form(&token, Some(&session));
assert_eq!(post(body, headers).await.status(), StatusCode::SEE_OTHER);
let done = body_json(
s.call(
"GET",
&format!("/keys/sessions/{token}"),
None,
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(done["status"], "completed");
assert_eq!(done["apiKey"], RW);
}
async fn log_out(s: &Server, cookie: &str, origin: &str) -> Response {
s.call(
"POST",
"/ui/logout",
None,
&[("cookie", cookie), ("origin", origin)],
Body::empty(),
)
.await
}
#[tokio::test]
async fn signing_out_ends_the_session_at_the_provider_too() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "").await;
let session = signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).unwrap();
let foreign = log_out(&s, &session, "https://evil.example").await;
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
let dashboard = s
.call("GET", "/", None, &[("cookie", &session)], Body::empty())
.await;
assert_eq!(dashboard.status(), StatusCode::OK);
let out = log_out(&s, &session, PUBLIC).await;
assert_eq!(out.status(), StatusCode::OK);
assert!(header_str(&out, "set-cookie").contains("Max-Age=0"));
assert_eq!(header_str(&out, "cache-control"), "no-store");
assert_eq!(header_str(&out, "referrer-policy"), "no-referrer");
let html = body_text(out).await;
assert!(html.contains(r#"http-equiv="refresh""#), "{html}");
assert!(html.contains("/logout?client_id=defter"), "{html}");
assert!(html.contains("id_token_hint="), "{html}");
let back =
serde_urlencoded::to_string([("post_logout_redirect_uri", format!("{PUBLIC}/"))]).unwrap();
assert!(html.contains(&back), "{html}");
let after = s
.call("GET", "/", None, &[("cookie", &session)], Body::empty())
.await;
assert_eq!(after.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
log_out(&s, &session, PUBLIC).await.status(),
StatusCode::SEE_OTHER
);
}
#[tokio::test]
async fn signing_out_of_a_key_session_stays_here() {
let (s, _idp) = oidc_server(None).await;
let session = session_cookie(&sign_in(&s, RW, PUBLIC).await);
let out = log_out(&s, &session, PUBLIC).await;
assert_eq!(out.status(), StatusCode::SEE_OTHER);
assert_eq!(header_str(&out, "location"), "/");
}
#[tokio::test]
async fn without_key_login_only_the_provider_signs_in() {
let (issuer, idp) = mock_idp().await;
let cfg = crate::oidc::OidcConfig::new(
&issuer,
"defter",
Some("s3cret".to_string()),
ORCID,
"orcid",
"Keycloak",
)
.unwrap();
let s = server_configured(None, Some(cfg), false).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let html = body_text(visit(&s, "/").await).await;
assert!(html.contains("Sign in with Keycloak"), "{html}");
assert!(!html.contains(r#"name="key""#), "{html}");
let refused = sign_in(&s, RW, PUBLIC).await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
assert!(signed_in_cookie(&refused).is_none());
let created = s
.call("POST", "/keys/sessions", None, &[], Body::empty())
.await;
let token = body_json(created).await["sessionToken"]
.as_str()
.unwrap()
.to_string();
let html = body_text(visit(&s, &format!("/login?session={token}")).await).await;
assert!(!html.contains(r#"name="key""#), "{html}");
assert!(!html.contains(">Approve<"), "{html}");
assert!(html.contains("Sign in with Keycloak to approve"), "{html}");
let form = serde_urlencoded::to_string([("session", token.as_str()), ("key", RW)]).unwrap();
let post = |cookie: Option<String>| {
let (s, form) = (&s, form.clone());
async move {
let mut headers = vec![
(
"content-type",
"application/x-www-form-urlencoded".to_string(),
),
("origin", PUBLIC.to_string()),
];
if let Some(cookie) = cookie {
headers.push(("cookie", cookie));
}
let refs: Vec<(&str, &str)> = headers.iter().map(|(a, b)| (*a, b.as_str())).collect();
s.call("POST", "/login", None, &refs, Body::from(form))
.await
}
};
assert_eq!(post(None).await.status(), StatusCode::FORBIDDEN);
let status_uri = format!("/keys/sessions/{token}");
let pending = body_json(s.call("GET", &status_uri, None, &[], Body::empty()).await).await;
assert_eq!(pending["status"], "pending");
assert_eq!(s.get("/users/1/items").await.status(), StatusCode::OK);
let status = s
.call(
"GET",
"/ui/status",
None,
&[("zotero-api-key", RO)],
Body::empty(),
)
.await;
assert_eq!(status.status(), StatusCode::OK);
let (state, cookie) = oidc_start(&s, &idp, &format!("?session={token}")).await;
let session = signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).unwrap();
let page = s
.call(
"GET",
&format!("/login?session={token}"),
None,
&[("cookie", &session)],
Body::empty(),
)
.await;
assert!(body_text(page).await.contains("Approve"));
assert_eq!(post(Some(session)).await.status(), StatusCode::SEE_OTHER);
let done = body_json(s.call("GET", &status_uri, None, &[], Body::empty()).await).await;
assert_eq!(done["status"], "completed");
}
#[tokio::test]
async fn pages_use_the_browsers_language_until_one_is_picked() {
let s = server().await;
let page = |headers: Vec<(&'static str, &'static str)>, uri: &'static str| {
let s = &s;
async move { s.call("GET", uri, None, &headers, Body::empty()).await }
};
let de = page(vec![("accept-language", "de-DE,de;q=0.9")], "/").await;
assert!(set_cookies(&de).is_empty());
let vary: Vec<_> = de
.headers()
.get_all("vary")
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
assert!(vary.iter().any(|v| v == "Cookie"), "{vary:?}");
assert!(vary.iter().any(|v| v == "Accept-Language"), "{vary:?}");
let html = body_text(de).await;
assert!(html.contains(r#"<html lang="de">"#));
assert!(html.contains("API-Schlüssel"), "{html}");
assert!(html.contains(r#"<option value="de" selected>"#), "{html}");
let html = body_text(page(vec![("accept-language", "fr")], "/").await).await;
assert!(html.contains("API key"), "{html}");
assert!(html.contains(r#"<option value="en" selected>"#));
let picked = page(vec![("accept-language", "de")], "/?lang=en").await;
let cookie = set_cookies(&picked)
.into_iter()
.find(|c| c.starts_with("defter_lang="))
.unwrap();
assert!(cookie.starts_with("defter_lang=en;"), "{cookie}");
assert!(
cookie.contains("Max-Age=31536000") && cookie.contains("Path=/"),
"{cookie}"
);
assert!(body_text(picked).await.contains("API key"));
let html = body_text(
page(
vec![("accept-language", "de"), ("cookie", "defter_lang=en")],
"/",
)
.await,
)
.await;
assert!(html.contains("API key"), "{html}");
let html = body_text(page(vec![("cookie", "defter_lang=en")], "/?lang=de").await).await;
assert!(html.contains("API-Schlüssel"));
let html = body_text(
page(
vec![("accept-language", "de"), ("cookie", "defter_lang=xx")],
"/",
)
.await,
)
.await;
assert!(html.contains("API-Schlüssel"));
let unknown = page(vec![], "/?lang=xx").await;
assert!(set_cookies(&unknown).is_empty());
let html = body_text(page(vec![], "/").await).await;
let css = html
.split("href=\"")
.find(|p| p.starts_with("/assets/"))
.unwrap();
let css = css.split('"').next().unwrap().to_string();
let asset = s.call("GET", &css, None, &[], Body::empty()).await;
assert_eq!(asset.status(), StatusCode::OK);
assert!(
asset
.headers()
.get_all("vary")
.iter()
.all(|v| v != "Cookie")
);
}
#[tokio::test]
async fn the_dashboard_and_login_pages_speak_german() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": OTHER_ORCID});
const DE: (&str, &str) = ("accept-language", "de");
let session = session_cookie(&sign_in(&s, RW, PUBLIC).await);
let headers = [("cookie", session.as_str()), DE];
let html = body_text(s.call("GET", "/", None, &headers, Body::empty()).await).await;
for shown in [
"Bibliotheksversion",
"Einträge",
"Zotero verbinden",
"Dateispeicher",
"Abmelden",
] {
assert!(html.contains(shown), "{shown} missing from {html}");
}
assert!(!html.contains("Connect Zotero"), "{html}");
let poll = [("cookie", session.as_str()), ("hx-request", "true"), DE];
let html = body_text(s.call("GET", "/", None, &poll, Body::empty()).await).await;
assert!(html.contains("Bibliotheksversion"), "{html}");
let form = serde_urlencoded::to_string([("key", "guess")]).unwrap();
let wrong = s
.call(
"POST",
"/ui/session",
None,
&[
("content-type", "application/x-www-form-urlencoded"),
("origin", PUBLIC),
DE,
],
Body::from(form),
)
.await;
assert!(body_text(wrong).await.contains("kein API-Schlüssel"));
let (state, cookie) = oidc_start(&s, &idp, "").await;
let refused = s
.call(
"GET",
&format!("/ui/oidc/callback?state={state}&code=good-code"),
None,
&[("cookie", &cookie), DE],
Body::empty(),
)
.await;
assert!(body_text(refused).await.contains("keinen Zugang"));
let created = s
.call("POST", "/keys/sessions", None, &[], Body::empty())
.await;
let token = body_json(created).await["sessionToken"]
.as_str()
.unwrap()
.to_string();
let uri = format!("/login?session={token}");
let html = body_text(s.call("GET", &uri, None, &[DE], Body::empty()).await).await;
for shown in [
"Zotero zulassen?",
"Bestätigen",
"Mit Keycloak anmelden und bestätigen",
] {
assert!(html.contains(shown), "{shown} missing from {html}");
}
assert!(html.contains(&format!(
r#"<input type="hidden" name="session" value="{token}">"#
)));
assert!(html.contains(r#"action="/login""#));
let picked = format!("{uri}&lang=en");
let html = body_text(visit(&s, &picked).await).await;
assert!(html.contains("Authorize Zotero?"), "{html}");
}
#[tokio::test]
async fn a_returning_login_must_be_a_live_one() {
let (s, idp) = oidc_server(None).await;
idp.lock().unwrap().id_claims = json!({"orcid": ORCID});
let (state, cookie) = oidc_start(&s, &idp, "?session=//evil.example").await;
let resp = oidc_finish(&s, &state, &cookie).await;
assert_eq!(header_str(&resp, "location"), "/");
}
#[tokio::test]
async fn clients_echoing_a_setting_do_not_make_new_versions() {
let s = server().await;
let feeds = json!({"https://a.example/rss": ["A", 3, 30, 60]});
let post = |version: String, value: Value| {
let s = &s;
async move {
let headers = [("if-unmodified-since-version", version.as_str())];
s.json(
"POST",
"/users/1/settings",
&headers,
json!({"feeds": {"value": value}}),
)
.await
}
};
let added = post("0".to_string(), feeds.clone()).await;
assert_eq!(added.status(), StatusCode::NO_CONTENT);
assert_eq!(header_str(&added, "last-modified-version"), "1");
for _ in 0..3 {
let echo = post("1".to_string(), feeds.clone()).await;
assert_eq!(echo.status(), StatusCode::NO_CONTENT);
assert_eq!(header_str(&echo, "last-modified-version"), "1");
}
let since = s
.call(
"GET",
"/users/1/settings?since=1",
Some(RW),
&[("if-modified-since-version", "1")],
Body::empty(),
)
.await;
assert_eq!(since.status(), StatusCode::NOT_MODIFIED);
let more = json!({"https://a.example/rss": ["A", 3, 30, 60], "https://b.example/rss": ["B", 3, 30, 60]});
let change = post("1".to_string(), more).await;
assert_eq!(change.status(), StatusCode::NO_CONTENT);
assert_eq!(header_str(&change, "last-modified-version"), "2");
let stale = post("1".to_string(), feeds).await;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
}
#[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!({}));
let groups = body_json(s.get("/users/1/groups").await).await;
assert_eq!(groups, json!([]));
let groups = body_json(s.get("/users/1/groups?limit=25&start=0").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 a_key_reaches_only_its_own_library_and_versions_served_are_kept() {
let s = server().await;
for uri in ["/users/2/items", "/api/users/2/items", "/users/2/settings"] {
let resp = s.get(uri).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{uri}");
}
let created = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([{"key": "AAAAAAAA", "itemType": "book", "title": "x"}]),
)
.await;
assert_eq!(created.status(), StatusCode::OK);
assert_eq!(s.get("/api/users/0/items").await.status(), StatusCode::OK);
let library = crate::control::LibraryRef::User(1);
assert_eq!(s.app.control.version_high(library).await.unwrap(), 1);
}
#[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.app
.control
.set_meta(vec![
("schema", r#"{"version": 1, "itemTypes": []}"#.to_string()),
("schema_fetched", "0".to_string()),
])
.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>"));
}