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 {
server_admins(orcid, oidc, key_login, &[]).await
}
const QUOTA: u64 = 1024 * 1024;
async fn server_admins(
orcid: Option<&str>,
oidc: Option<crate::oidc::OidcConfig>,
key_login: bool,
admins: &[&str],
) -> 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,
admin_orcids: admins.iter().map(|a| a.to_string()).collect(),
default_quota: QUOTA,
};
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(
ORCID,
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(
ORCID,
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);
s.app.control.set_profile_hidden(1, 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",
"Keycloak",
)
.unwrap();
(server_admins(orcid, Some(cfg), true, &[ORCID]).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(
ORCID,
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(
ORCID,
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 an_invited_account_gets_a_library_of_its_own_not_the_owners() {
let (issuer, idp) = mock_idp().await;
let cfg = crate::oidc::OidcConfig::new(&issuer, "defter", Some("s3cret".into()), "orcid", "K")
.unwrap();
let s = server_admins(None, Some(cfg), true, &[]).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_none());
assert!(s.app.control.invite(ORCID.into(), 1, None).await.unwrap());
let (state, cookie) = oidc_start(&s, &idp, "").await;
assert!(signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).is_some());
let user = s.app.control.user_by_orcid(ORCID).await.unwrap().unwrap();
assert_ne!(user.id, 1);
assert!(user.profile_hidden);
assert_eq!(s.app.control.user(1).await.unwrap().unwrap().orcid, None);
assert_eq!(
visit(&s, &format!("/{ORCID}")).await.status(),
StatusCode::NOT_FOUND
);
let invites = s.app.control.invites().await.unwrap();
assert_eq!(invites[0].used_by, Some(user.id));
}
#[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");
let key = done["apiKey"].as_str().unwrap().to_string();
assert_ne!(key, RW);
assert_eq!(done["userID"], 1);
let items = s
.call("GET", "/users/1/items", Some(&key), &[], Body::empty())
.await;
assert_eq!(items.status(), StatusCode::OK);
let current = body_json(
s.call("GET", "/keys/current", Some(&key), &[], Body::empty())
.await,
)
.await;
assert_eq!(current["userID"], 1);
assert_eq!(current["access"]["user"]["write"], true);
let dashboard = body_text(
s.call("GET", "/", None, &[("cookie", &session)], Body::empty())
.await,
)
.await;
assert!(dashboard.contains(&key[..8]), "{dashboard}");
assert!(!dashboard.contains(&key));
let unlinked = s
.call("DELETE", "/keys/current", Some(&key), &[], Body::empty())
.await;
assert_eq!(unlinked.status(), StatusCode::NO_CONTENT);
let after = s
.call("GET", "/users/1/items", Some(&key), &[], Body::empty())
.await;
assert_eq!(after.status(), StatusCode::FORBIDDEN);
let kept = s
.call("DELETE", "/keys/current", Some(RW), &[], Body::empty())
.await;
assert_eq!(kept.status(), StatusCode::NO_CONTENT);
assert_eq!(s.get("/users/1/items").await.status(), StatusCode::OK);
}
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",
"Keycloak",
)
.unwrap();
let s = server_admins(None, Some(cfg), false, &[ORCID]).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>"));
}
const ALICE: &str = "0000-0001-5109-3700";
const BOB: &str = "0000-0002-9079-593X";
async fn person(s: &Server, orcid: &str) -> (u64, String) {
assert!(
s.app
.control
.invite(orcid.to_string(), 1, None)
.await
.unwrap()
);
let crate::control::SignIn::User(user) = s.app.control.sign_in(orcid, false, 1).await.unwrap()
else {
panic!("{orcid} was invited");
};
let key = s
.app
.control
.mint_key(user.id, "Zotero".into(), true)
.await
.unwrap();
(user.id, key)
}
fn session_for(s: &Server, user_id: u64) -> String {
let (token, _) = s.app.ui_sessions.insert(crate::auth::UiSession {
user_id,
access: Access { write: true },
id_token: Some("id-token".into()),
});
format!("defter_session={token}")
}
async fn post_item(s: &Server, key: &str, user_id: u64, item: Value) -> StatusCode {
let resp = s
.call(
"POST",
&format!("/users/{user_id}/items"),
Some(key),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "0"),
],
Body::from(json!([item]).to_string()),
)
.await;
let status = resp.status();
if status == StatusCode::OK {
let body = body_json(resp).await;
assert_eq!(body["failed"], json!({}), "{body}");
}
status
}
#[tokio::test]
async fn every_route_refuses_what_is_not_the_callers() {
let s = server().await;
let (alice, alice_key) = person(&s, ALICE).await;
let (bob, bob_key) = person(&s, BOB).await;
let revoked = s
.app
.control
.mint_key(alice, "old".into(), true)
.await
.unwrap();
let revoked_id = s.app.control.keys(alice).await.unwrap()[0].id;
assert!(s.app.control.revoke_key(alice, revoked_id).await.unwrap());
for (key, user, name) in [
(RW, 1, "owner"),
(alice_key.as_str(), alice, "alice"),
(bob_key.as_str(), bob, "bob"),
] {
let item =
json!({"key": "MARKERAA", "itemType": "book", "title": format!("MARKER-{name}")});
assert_eq!(
post_item(&s, key, user, item).await,
StatusCode::OK,
"{name}"
);
}
let markers = ["MARKER-owner", "MARKER-alice", "MARKER-bob"];
let callers: [(Option<&str>, Option<u64>, bool, &str); 7] = [
(None, None, false, ""),
(Some("not-a-key"), None, false, ""),
(Some(revoked.as_str()), None, false, ""),
(Some(RW), Some(1), true, "MARKER-owner"),
(Some(RO), Some(1), false, "MARKER-owner"),
(Some(alice_key.as_str()), Some(alice), true, "MARKER-alice"),
(Some(bob_key.as_str()), Some(bob), true, "MARKER-bob"),
];
let mut checked = 0;
for (key, whose, write, own) in callers {
let other = match whose {
Some(1) => alice,
Some(_) => 1,
None => bob,
};
let mut targets = vec![(other.to_string(), false)];
if let Some(id) = whose {
targets.push((id.to_string(), true));
targets.push(("0".to_string(), true));
} else {
targets.push(("0".to_string(), false));
}
for (path, methods, _) in crate::zotero::library_routes() {
let path = path.replace("{key}", "MARKERAA");
for prefix in ["", "/api"] {
for (target, theirs) in &targets {
for method in ["GET", "POST", "PUT", "PATCH", "DELETE"] {
let uri = format!("{prefix}/users/{target}{path}");
let resp = s
.call(
method,
&uri,
key,
&[("content-type", "application/json")],
Body::from("[]"),
)
.await;
let status = resp.status();
let body = body_text(resp).await;
let mutating = method != "GET";
let what = format!("{method} {uri} with {key:?}");
if whose.is_none() || !theirs || (mutating && !write) {
assert_eq!(status, StatusCode::FORBIDDEN, "{what}: {body}");
} else if !methods.contains(&method) {
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "{what}");
} else {
assert!(
status != StatusCode::FORBIDDEN && status.as_u16() < 500,
"{what}: {status} {body}"
);
}
for marker in markers {
assert!(
marker == own || !body.contains(marker),
"{what} shows {marker}"
);
}
checked += 1;
}
}
}
}
}
assert!(checked > 3000, "{checked}");
let own = s
.call(
"GET",
"/api/users/0/items",
Some(&alice_key),
&[],
Body::empty(),
)
.await;
assert!(body_text(own).await.contains("MARKER-alice"));
}
#[tokio::test]
async fn people_have_their_own_libraries_keys_and_profiles() {
let s = server().await;
let (alice, alice_key) = person(&s, ALICE).await;
let (bob, bob_key) = person(&s, BOB).await;
assert!(alice != bob && alice != 1 && bob != 1);
assert!((1..=100_000).contains(&alice) && (1..=100_000).contains(&bob));
let work = |title: &str| {
json!({"key": "WRKAAAAA", "itemType": "journalArticle", "title": title,
"date": "2024", "inPublications": true})
};
assert_eq!(
post_item(&s, &alice_key, alice, work("Alice's work")).await,
StatusCode::OK
);
assert_eq!(
post_item(&s, &bob_key, bob, work("Bob's work")).await,
StatusCode::OK
);
let current = body_json(
s.call("GET", "/keys/current", Some(&alice_key), &[], Body::empty())
.await,
)
.await;
assert_eq!(current["userID"], alice);
assert_eq!(current["username"], ALICE);
s.app.person.set(
ALICE,
serde_json::from_value(json!({"family_name": "Alice"})).unwrap(),
);
assert_eq!(
visit(&s, &format!("/{ALICE}")).await.status(),
StatusCode::NOT_FOUND
);
let alice_session = session_for(&s, alice);
let shown = s
.call(
"POST",
"/ui/profile",
None,
&[
("cookie", &alice_session),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from("hidden=false"),
)
.await;
assert_eq!(shown.status(), StatusCode::SEE_OTHER);
let page = body_text(visit(&s, &format!("/{ALICE}")).await).await;
assert!(
page.contains("Alice's work") || page.contains("Alice's work"),
"{page}"
);
assert!(!page.contains("Bob"));
assert_eq!(
visit(&s, &format!("/{BOB}")).await.status(),
StatusCode::NOT_FOUND
);
let lookalike = format!("/{}", ALICE.replace('-', ""));
assert_eq!(visit(&s, &lookalike).await.status(), StatusCode::NOT_FOUND);
let dashboard = body_text(
s.call(
"GET",
"/",
None,
&[("cookie", &alice_session)],
Body::empty(),
)
.await,
)
.await;
assert!(dashboard.contains(&format!("user {alice}")), "{dashboard}");
assert!(dashboard.contains(&alice_key[..8]));
assert!(!dashboard.contains("/ui/admin"), "not an admin");
assert!(
!dashboard.contains("memory://"),
"nor where files are stored"
);
let key_id = s.app.control.keys(alice).await.unwrap()[0].id;
let revoke = |cookie: String, id: i64| {
let s = &s;
async move {
s.call(
"POST",
"/ui/keys/revoke",
None,
&[
("cookie", &cookie),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(format!("id={id}")),
)
.await
.status()
}
};
assert_eq!(
revoke(session_for(&s, bob), key_id).await,
StatusCode::NOT_FOUND
);
assert_eq!(
revoke(alice_session.clone(), key_id).await,
StatusCode::SEE_OTHER
);
let after = s
.call(
"GET",
&format!("/users/{alice}/items"),
Some(&alice_key),
&[],
Body::empty(),
)
.await;
assert_eq!(after.status(), StatusCode::FORBIDDEN);
s.app.control.set_profile_hidden(bob, false).await.unwrap();
s.app.person.set(
BOB,
serde_json::from_value(json!({"family_name": "Bob"})).unwrap(),
);
assert_eq!(visit(&s, &format!("/{BOB}")).await.status(), StatusCode::OK);
let bob_session = session_for(&s, bob);
s.app.control.set_disabled(bob, true).await.unwrap();
assert_eq!(
visit(&s, &format!("/{BOB}")).await.status(),
StatusCode::NOT_FOUND
);
let refused = s
.call(
"GET",
&format!("/users/{bob}/items"),
Some(&bob_key),
&[],
Body::empty(),
)
.await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
let page = s
.call("GET", "/", None, &[("cookie", &bob_session)], Body::empty())
.await;
assert_eq!(page.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
s.app.control.sign_in(BOB, false, 1).await.unwrap(),
crate::control::SignIn::Disabled
);
}
#[tokio::test]
async fn uploads_stop_at_the_quota_and_stay_in_their_library() {
let s = server().await;
let (alice, alice_key) = person(&s, ALICE).await;
let (bob, bob_key) = person(&s, BOB).await;
let attachment = json!({"key": "FILEAAAA", "itemType": "attachment",
"linkMode": "imported_file", "title": "a.pdf"});
for (key, user) in [
(RW, 1),
(alice_key.as_str(), alice),
(bob_key.as_str(), bob),
] {
assert_eq!(
post_item(&s, key, user, attachment.clone()).await,
StatusCode::OK
);
}
s.app.control.set_quota(alice, Some(1000)).await.unwrap();
let authorize = |key: String, user: u64, size: usize| {
let s = &s;
async move {
let form = serde_urlencoded::to_string([
("md5", "0123456789abcdef0123456789abcdef"),
("filename", "a.pdf"),
("filesize", &size.to_string()),
("mtime", "1"),
])
.unwrap();
s.call(
"POST",
&format!("/users/{user}/items/FILEAAAA/file"),
Some(&key),
&[
("content-type", "application/x-www-form-urlencoded"),
("if-none-match", "*"),
],
Body::from(form),
)
.await
}
};
let over = authorize(alice_key.clone(), alice, 2000).await;
assert_eq!(over.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(header_str(&over, "zotero-storage-quota"), "0");
assert_eq!(
header_str(&over, "zotero-storage-userid"),
alice.to_string()
);
assert_eq!(
authorize(alice_key.clone(), alice, 500).await.status(),
StatusCode::OK
);
assert_eq!(
authorize(RW.to_string(), 1, 2000).await.status(),
StatusCode::OK
);
let too_big = authorize(bob_key.clone(), bob, QUOTA as usize + 1).await;
assert!(matches!(too_big.status(), StatusCode::PAYLOAD_TOO_LARGE));
let authorized = body_json(authorize(alice_key.clone(), alice, 500).await).await;
let upload = authorized["uploadKey"].as_str().unwrap().to_string();
let registered = s
.call(
"POST",
&format!("/users/{bob}/items/FILEAAAA/file"),
Some(&bob_key),
&[("content-type", "application/x-www-form-urlencoded")],
Body::from(format!("upload={upload}")),
)
.await;
assert_eq!(registered.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admins_invite_people_and_manage_users() {
let s = server().await;
let owner = session_cookie(&sign_in(&s, RW, PUBLIC).await);
let admin_get = |cookie: String| {
let s = &s;
async move {
s.call(
"GET",
"/ui/admin",
None,
&[("cookie", &cookie)],
Body::empty(),
)
.await
}
};
let page = admin_get(owner.clone()).await;
assert_eq!(page.status(), StatusCode::OK);
assert!(body_text(page).await.contains("Invitations"));
let post = |cookie: String, path: &'static str, form: String| {
let s = &s;
async move {
s.call(
"POST",
path,
None,
&[
("cookie", &cookie),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(form),
)
.await
}
};
let invited = post(
owner.clone(),
"/ui/admin/invite",
format!("orcid=https://orcid.org/{ALICE}¬e=colleague"),
)
.await;
assert_eq!(header_str(&invited, "location"), "/ui/admin");
let wrong = post(owner.clone(), "/ui/admin/invite", "orcid=nobody".into()).await;
assert_eq!(header_str(&wrong, "location"), "/ui/admin?error=orcid");
let invites = s.app.control.invites().await.unwrap();
assert_eq!(invites.len(), 1);
assert_eq!(
(invites[0].orcid.as_str(), invites[0].note.as_deref()),
(ALICE, Some("colleague"))
);
let crate::control::SignIn::User(alice) = s.app.control.sign_in(ALICE, false, 1).await.unwrap()
else {
panic!("invited");
};
let alice_session = session_for(&s, alice.id);
assert_eq!(
admin_get(alice_session.clone()).await.status(),
StatusCode::NOT_FOUND
);
let refused = post(alice_session, "/ui/admin/invite", format!("orcid={BOB}")).await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
let foreign = s
.call(
"POST",
"/ui/admin/invite",
None,
&[
("cookie", &owner),
("origin", "https://evil.example"),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(format!("orcid={BOB}")),
)
.await;
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
let read_only = session_cookie(&sign_in(&s, RO, PUBLIC).await);
assert_eq!(admin_get(read_only.clone()).await.status(), StatusCode::OK);
let looked = post(read_only, "/ui/admin/invite", format!("orcid={BOB}")).await;
assert_eq!(looked.status(), StatusCode::FORBIDDEN);
let set = post(
owner.clone(),
"/ui/admin/user",
format!("id={}"a_mb=5", alice.id),
)
.await;
assert_eq!(set.status(), StatusCode::SEE_OTHER);
let alice_now = s.app.control.user(alice.id).await.unwrap().unwrap();
assert_eq!(alice_now.quota_bytes, Some(5 * 1024 * 1024));
let bad = post(
owner.clone(),
"/ui/admin/user",
format!("id={}"a_mb=lots", alice.id),
)
.await;
assert_eq!(header_str(&bad, "location"), "/ui/admin?error=quota");
post(
owner.clone(),
"/ui/admin/user",
format!("id={}&disabled=true", alice.id),
)
.await;
assert!(
s.app
.control
.user(alice.id)
.await
.unwrap()
.unwrap()
.disabled
);
let me = post(owner.clone(), "/ui/admin/user", "id=1&disabled=true".into()).await;
assert_eq!(me.status(), StatusCode::BAD_REQUEST);
assert!(!s.app.control.user(1).await.unwrap().unwrap().disabled);
post(owner.clone(), "/ui/admin/invite", format!("orcid={BOB}")).await;
post(
owner.clone(),
"/ui/admin/uninvite",
format!("orcid={ALICE}"),
)
.await;
post(owner, "/ui/admin/uninvite", format!("orcid={BOB}")).await;
let left: Vec<String> = s
.app
.control
.invites()
.await
.unwrap()
.into_iter()
.map(|i| i.orcid)
.collect();
assert_eq!(left, [ALICE]);
}
#[tokio::test]
async fn admin_orcids_sign_in_and_manage() {
let (issuer, idp) = mock_idp().await;
let cfg = crate::oidc::OidcConfig::new(&issuer, "defter", Some("s3cret".into()), "orcid", "K")
.unwrap();
let s = server_admins(Some(ORCID), Some(cfg), true, &[BOB]).await;
idp.lock().unwrap().id_claims = json!({"orcid": BOB});
let (state, cookie) = oidc_start(&s, &idp, "").await;
let session =
signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).expect("an admin signs in");
let bob = s.app.control.user_by_orcid(BOB).await.unwrap().unwrap();
assert_ne!(bob.id, 1, "a library of their own: the owner has an iD");
let page = s
.call(
"GET",
"/ui/admin",
None,
&[("cookie", &session)],
Body::empty(),
)
.await;
assert_eq!(page.status(), StatusCode::OK);
let owner = session_cookie(&sign_in(&s, RW, PUBLIC).await);
let page = s
.call(
"GET",
"/ui/admin",
None,
&[("cookie", &owner)],
Body::empty(),
)
.await;
assert_eq!(page.status(), StatusCode::NOT_FOUND);
idp.lock().unwrap().id_claims = json!({"orcid": ALICE});
let (state, cookie) = oidc_start(&s, &idp, "").await;
assert!(signed_in_cookie(&oidc_finish(&s, &state, &cookie).await).is_none());
assert!(s.app.control.user_by_orcid(ALICE).await.unwrap().is_none());
}
#[tokio::test]
async fn disabling_signs_out_and_stops_logins_and_the_owner_stays() {
let (issuer, _) = mock_idp().await;
let cfg = crate::oidc::OidcConfig::new(&issuer, "defter", Some("s3cret".into()), "orcid", "K")
.unwrap();
let s = server_admins(Some(ORCID), Some(cfg), true, &[BOB]).await;
let (alice, _) = person(&s, ALICE).await;
let alice_session = session_for(&s, alice);
s.app.control.set_disabled(alice, true).await.unwrap();
let created = body_json(
s.call("POST", "/keys/sessions", None, &[], Body::empty())
.await,
)
.await;
let token = created["sessionToken"].as_str().unwrap().to_string();
let approve = s
.call(
"POST",
"/login",
None,
&[
("cookie", &alice_session),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(format!("session={token}")),
)
.await;
assert_ne!(approve.status(), StatusCode::SEE_OTHER);
assert!(s.app.control.keys(alice).await.unwrap().is_empty());
s.app.control.set_disabled(alice, false).await.unwrap();
let alice_session = session_for(&s, alice);
let crate::control::SignIn::User(bob) = s.app.control.sign_in(BOB, true, 1).await.unwrap()
else {
panic!("an admin");
};
let admin = session_for(&s, bob.id);
let form = |body: String| {
let s = &s;
let admin = admin.clone();
async move {
s.call(
"POST",
"/ui/admin/user",
None,
&[
("cookie", &admin),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(body),
)
.await
.status()
}
};
assert_eq!(
form(format!("id={alice}&disabled=true")).await,
StatusCode::SEE_OTHER
);
s.app.control.set_disabled(alice, false).await.unwrap();
let page = s
.call(
"GET",
"/",
None,
&[("cookie", &alice_session)],
Body::empty(),
)
.await;
assert_eq!(
page.status(),
StatusCode::UNAUTHORIZED,
"signed out, though enabled again"
);
assert_eq!(
form("id=1&disabled=true".into()).await,
StatusCode::BAD_REQUEST
);
assert_eq!(s.get("/users/1/items").await.status(), StatusCode::OK);
}
#[tokio::test]
async fn uploads_authorized_at_once_cannot_add_up_past_the_quota() {
let s = server().await;
let (alice, key) = person(&s, ALICE).await;
s.app.control.set_quota(alice, Some(1000)).await.unwrap();
let attachments = json!([
{"key": "FILEAAAA", "itemType": "attachment", "linkMode": "imported_file", "title": "a.pdf"},
{"key": "FILEBBBB", "itemType": "attachment", "linkMode": "imported_file", "title": "b.pdf"},
]);
let posted = s
.call(
"POST",
&format!("/users/{alice}/items"),
Some(&key),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "0"),
],
Body::from(attachments.to_string()),
)
.await;
assert_eq!(body_json(posted).await["failed"], json!({}));
let bytes = vec![b'x'; 600];
let md5 = format!("{:x}", md5::compute(&bytes));
let mut uploads = Vec::new();
for item in ["FILEAAAA", "FILEBBBB"] {
let form = serde_urlencoded::to_string([
("md5", md5.as_str()),
("filename", "a.pdf"),
("filesize", "600"),
("mtime", "1"),
])
.unwrap();
let resp = s
.call(
"POST",
&format!("/users/{alice}/items/{item}/file"),
Some(&key),
&[
("content-type", "application/x-www-form-urlencoded"),
("if-none-match", "*"),
],
Body::from(form),
)
.await;
assert_eq!(resp.status(), StatusCode::OK, "{item}");
let upload = body_json(resp).await["uploadKey"]
.as_str()
.unwrap()
.to_string();
let sent = s
.call(
"POST",
&format!("/uploads/{upload}"),
None,
&[],
Body::from(bytes.clone()),
)
.await;
assert_eq!(sent.status(), StatusCode::CREATED);
uploads.push((item, upload));
}
let register = |item: &'static str, upload: String| {
let (s, key) = (&s, key.clone());
async move {
s.call(
"POST",
&format!("/users/{alice}/items/{item}/file"),
Some(&key),
&[("content-type", "application/x-www-form-urlencoded")],
Body::from(format!("upload={upload}")),
)
.await
.status()
}
};
let (first, second) = (uploads[0].clone(), uploads[1].clone());
assert_eq!(register(first.0, first.1).await, StatusCode::NO_CONTENT);
assert_eq!(
register(second.0, second.1).await,
StatusCode::PAYLOAD_TOO_LARGE
);
let users = s.app.control.users().await.unwrap();
assert_eq!(
users
.iter()
.find(|u| u.user.id == alice)
.unwrap()
.bytes_used,
600
);
}
const CAROL: &str = "0000-0003-0059-2205";
async fn group_server() -> (Server, u64, [(u64, String); 3]) {
let s = server().await;
let alice = person(&s, ALICE).await;
let bob = person(&s, BOB).await;
let carol = person(&s, CAROL).await;
let group = s.app.control.create_group(1, "Lab".into()).await.unwrap();
use crate::control::Role;
for (id, role) in [(alice.0, Role::Admin), (bob.0, Role::Member)] {
s.app
.control
.set_member(group.id, id, role)
.await
.unwrap()
.unwrap();
}
(s, group.id, [alice, bob, carol])
}
async fn post_group_item(s: &Server, key: &str, group: u64, item: Value) -> Response {
s.call(
"POST",
&format!("/groups/{group}/items"),
Some(key),
&[("content-type", "application/json")],
Body::from(json!([item]).to_string()),
)
.await
}
#[tokio::test]
async fn members_sync_a_group_library_that_others_cannot_see() {
let (s, group, [(alice, alice_key), (bob, bob_key), (carol, carol_key)]) = group_server().await;
for (user, key) in [
(1, RW.to_string()),
(alice, alice_key.clone()),
(bob, bob_key.clone()),
] {
let versions = body_json(
s.call(
"GET",
&format!("/users/{user}/groups?format=versions"),
Some(&key),
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(versions, json!({group.to_string(): 3}), "{user}");
}
let none = s
.call(
"GET",
&format!("/users/{carol}/groups?format=versions"),
Some(&carol_key),
&[],
Body::empty(),
)
.await;
assert_eq!(body_json(none).await, json!({}));
let resp = s
.call(
"GET",
&format!("/groups/{group}"),
Some(&bob_key),
&[],
Body::empty(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(header_str(&resp, "last-modified-version"), "3");
let json = body_json(resp).await;
assert_eq!(json["id"], group);
assert_eq!(json["version"], 3);
let data = &json["data"];
assert_eq!(data["name"], "Lab");
assert_eq!(data["owner"], 1);
assert_eq!(data["admins"], json!([alice]));
assert_eq!(data["members"], json!([bob]));
assert_eq!(data["libraryEditing"], "members");
assert_eq!(data["fileEditing"], "members");
assert_eq!(data["type"], "Private");
assert!(data["description"].is_string());
let list = body_json(
s.call(
"GET",
&format!("/users/{bob}/groups"),
Some(&bob_key),
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(list[0]["data"]["name"], "Lab");
let current = body_json(
s.call("GET", "/keys/current", Some(&bob_key), &[], Body::empty())
.await,
)
.await;
assert_eq!(current["access"]["groups"]["all"]["library"], true);
let item = json!({"key": "GRPAAAAA", "itemType": "book", "title": "Shared"});
let resp = s
.call(
"POST",
&format!("/groups/{group}/items"),
Some(&bob_key),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "0"),
],
Body::from(json!([item]).to_string()),
)
.await;
assert_eq!(body_json(resp).await["failed"], json!({}));
let read = s
.call(
"GET",
&format!("/api/groups/{group}/items"),
Some(&alice_key),
&[],
Body::empty(),
)
.await;
assert!(body_text(read).await.contains("Shared"));
for uri in [format!("/groups/{group}"), format!("/groups/{group}/items")] {
let refused = s
.call("GET", &uri, Some(&carol_key), &[], Body::empty())
.await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{uri}");
}
assert!(
!body_text(
s.call(
"GET",
&format!("/users/{bob}/items"),
Some(&bob_key),
&[],
Body::empty()
)
.await
)
.await
.contains("Shared"),
"the group's items are not Bob's own"
);
s.app
.control
.update_group(group, None, None, Some("admins".into()), None)
.await
.unwrap();
let refused = post_group_item(
&s,
&bob_key,
group,
json!({"key": "GRPBBBBB", "itemType": "book"}),
)
.await;
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
assert_eq!(
s.call(
"GET",
&format!("/groups/{group}/items"),
Some(&bob_key),
&[],
Body::empty()
)
.await
.status(),
StatusCode::OK
);
let version = s.app.control.group(group).await.unwrap().unwrap().version;
assert_eq!(version, 4);
s.app
.control
.remove_member(group, bob)
.await
.unwrap()
.unwrap();
let versions = body_json(
s.call(
"GET",
&format!("/users/{bob}/groups?format=versions"),
Some(&bob_key),
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(versions, json!({}));
assert_eq!(
s.call(
"GET",
&format!("/groups/{group}/items"),
Some(&bob_key),
&[],
Body::empty()
)
.await
.status(),
StatusCode::FORBIDDEN
);
s.app.control.delete_group(group).await.unwrap();
let versions = body_json(
s.call(
"GET",
&format!("/users/{alice}/groups?format=versions"),
Some(&alice_key),
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(versions, json!({}));
assert_eq!(
s.call(
"GET",
&format!("/groups/{group}/items"),
Some(&alice_key),
&[],
Body::empty()
)
.await
.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn every_group_route_allows_only_what_the_role_and_settings_do() {
use crate::control::Role;
let (s, group, [(_, alice_key), (_, bob_key), (_, carol_key)]) = group_server().await;
let item = json!({"key": "MARKERAA", "itemType": "book", "title": "MARKER-group"});
assert_eq!(
body_json(
s.call(
"POST",
&format!("/groups/{group}/items"),
Some(RW),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "0")
],
Body::from(json!([item]).to_string()),
)
.await
)
.await["failed"],
json!({})
);
let callers: [(&str, Option<Role>, bool); 5] = [
(RW, Some(Role::Owner), true),
(RO, Some(Role::Owner), false),
(alice_key.as_str(), Some(Role::Admin), true),
(bob_key.as_str(), Some(Role::Member), true),
(carol_key.as_str(), None, true),
];
let mut checked = 0;
for (library_editing, file_editing) in [
("members", "members"),
("admins", "admins"),
("members", "none"),
] {
s.app
.control
.update_group(
group,
None,
None,
Some(library_editing.into()),
Some(file_editing.into()),
)
.await
.unwrap();
let settings = s.app.control.group(group).await.unwrap().unwrap();
for (key, role, key_writes) in callers {
let access = role.map(|r| settings.access(r));
for (path, methods, _) in crate::zotero::library_routes() {
let path = path.replace("{key}", "MARKERAA");
for prefix in ["", "/api"] {
for method in ["GET", "POST", "PUT", "PATCH", "DELETE"] {
let uri = format!("{prefix}/groups/{group}{path}");
let resp = s
.call(
method,
&uri,
Some(key),
&[("content-type", "application/json")],
Body::from("[]"),
)
.await;
let status = resp.status();
let body = body_text(resp).await;
let what =
format!("{method} {uri} as {role:?}, {library_editing}/{file_editing}");
let mutating = method != "GET";
let file_upload = method == "POST" && path.ends_with("/file");
let refused = match access {
None => true,
Some(a) => {
(mutating && (!key_writes || !a.write)) || (file_upload && !a.files)
}
};
if refused {
assert_eq!(status, StatusCode::FORBIDDEN, "{what}: {body}");
assert!(!body.contains("MARKER-group"), "{what}");
} else if !methods.contains(&method) {
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "{what}");
} else {
assert!(
status != StatusCode::FORBIDDEN && status.as_u16() < 500,
"{what}: {status} {body}"
);
}
checked += 1;
}
}
}
}
}
assert!(checked > 2500, "{checked}");
}
#[tokio::test]
async fn a_groups_files_count_against_its_owners_quota() {
let (s, group, [(alice, alice_key), (bob, bob_key), _]) = group_server().await;
let hers = s
.app
.control
.create_group(alice, "Alice's".into())
.await
.unwrap()
.id;
s.app
.control
.set_member(hers, bob, crate::control::Role::Member)
.await
.unwrap()
.unwrap();
s.app.control.set_quota(alice, Some(1000)).await.unwrap();
let authorize = |key: String, library: String| {
let s = &s;
async move {
let attachment = json!({"key": "FILEAAAA", "itemType": "attachment",
"linkMode": "imported_file", "title": "a.pdf"});
let posted = s
.call(
"POST",
&format!("/{library}/items"),
Some(&key),
&[("content-type", "application/json")],
Body::from(json!([attachment]).to_string()),
)
.await;
assert_eq!(body_json(posted).await["failed"], json!({}), "{library}");
let form = serde_urlencoded::to_string([
("md5", "0123456789abcdef0123456789abcdef"),
("filename", "a.pdf"),
("filesize", "600"),
("mtime", "1"),
])
.unwrap();
s.call(
"POST",
&format!("/{library}/items/FILEAAAA/file"),
Some(&key),
&[
("content-type", "application/x-www-form-urlencoded"),
("if-none-match", "*"),
],
Body::from(form),
)
.await
}
};
s.app
.control
.note_usage(crate::control::LibraryRef::User(alice), 600)
.await
.unwrap();
let over = authorize(bob_key.clone(), format!("groups/{hers}")).await;
assert_eq!(over.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(
header_str(&over, "zotero-storage-userid"),
alice.to_string()
);
assert_eq!(
authorize(alice_key.clone(), format!("groups/{group}"))
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn members_manage_a_group_on_its_page_as_their_role_allows() {
let s = server().await;
let (alice, _) = person(&s, ALICE).await;
let (bob, _) = person(&s, BOB).await;
let (carol, _) = person(&s, CAROL).await;
let post = |user: u64, path: String, form: String| {
let s = &s;
async move {
s.call(
"POST",
&path,
None,
&[
("cookie", &session_for(s, user)),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(form),
)
.await
}
};
let created = post(alice, "/ui/groups".into(), "name=Reading+club".into()).await;
assert_eq!(created.status(), StatusCode::SEE_OTHER);
let page = header_str(&created, "location").to_string();
let id: u64 = page.rsplit('/').next().unwrap().parse().unwrap();
let dashboard = body_text(
s.call(
"GET",
"/",
None,
&[("cookie", &session_for(&s, alice))],
Body::empty(),
)
.await,
)
.await;
assert!(dashboard.contains("Reading club"));
let members = format!("/ui/groups/{id}/members");
assert_eq!(
post(alice, members.clone(), format!("orcid={BOB}&role=admin"))
.await
.status(),
StatusCode::SEE_OTHER
);
assert_eq!(
post(alice, members.clone(), format!("orcid={CAROL}"))
.await
.status(),
StatusCode::SEE_OTHER
);
let unknown = post(alice, members.clone(), "orcid=0000-0002-1694-233X".into()).await;
assert_eq!(
header_str(&unknown, "location"),
format!("/ui/groups/{id}?error=user")
);
let roles = |members: Vec<crate::control::Member>| -> Vec<(u64, &'static str)> {
members
.iter()
.map(|m| (m.user.id, m.role.as_str()))
.collect()
};
let (_, now) = s.app.control.group_with_members(id).await.unwrap().unwrap();
assert_eq!(
roles(now),
[(alice, "owner"), (bob, "admin"), (carol, "member")]
);
let settings = format!("/ui/groups/{id}/settings");
assert_eq!(
post(
bob,
settings.clone(),
"name=Club&library_editing=admins&file_editing=none".into()
)
.await
.status(),
StatusCode::SEE_OTHER
);
let group = s.app.control.group(id).await.unwrap().unwrap();
assert_eq!(
(
group.name.as_str(),
group.library_editing.as_str(),
group.file_editing.as_str()
),
("Club", "admins", "none")
);
assert_eq!(
post(bob, members.clone(), format!("user={carol}&role=admin"))
.await
.status(),
StatusCode::FORBIDDEN
);
let remove = format!("/ui/groups/{id}/remove");
assert_eq!(
post(bob, remove.clone(), format!("user={alice}"))
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
post(carol, settings, "name=Mine".into()).await.status(),
StatusCode::FORBIDDEN
);
let seen = s
.call(
"GET",
&page,
None,
&[("cookie", &session_for(&s, carol))],
Body::empty(),
)
.await;
assert_eq!(seen.status(), StatusCode::OK);
assert_eq!(
post(carol, remove.clone(), String::new()).await.status(),
StatusCode::SEE_OTHER
);
let outsider = s
.call(
"GET",
&page,
None,
&[("cookie", &session_for(&s, carol))],
Body::empty(),
)
.await;
assert_eq!(outsider.status(), StatusCode::NOT_FOUND);
assert_eq!(
post(alice, remove, String::new()).await.status(),
StatusCode::FORBIDDEN
);
let delete = format!("/ui/groups/{id}/delete");
assert_eq!(
post(bob, delete.clone(), String::new()).await.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
post(alice, delete, String::new()).await.status(),
StatusCode::SEE_OTHER
);
assert!(s.app.control.group(id).await.unwrap().unwrap().deleted);
let foreign = s
.call(
"POST",
"/ui/groups",
None,
&[
("cookie", &session_for(&s, alice)),
("origin", "https://evil.example"),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from("name=X"),
)
.await;
assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn group_items_say_who_added_and_last_changed_them() {
let (s, group, [(alice, alice_key), (bob, bob_key), _]) = group_server().await;
s.app
.control
.set_display_name(bob, "Bob Example".into())
.await
.unwrap();
let created = s
.call(
"POST",
&format!("/groups/{group}/items"),
Some(&bob_key),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "0"),
],
Body::from(
json!([{"key": "SHAREDAA", "itemType": "book", "title": "One"}]).to_string(),
),
)
.await;
let created = body_json(created).await;
assert_eq!(created["failed"], json!({}));
let changed = s
.call(
"PATCH",
&format!("/groups/{group}/items/SHAREDAA"),
Some(&alice_key),
&[
("content-type", "application/json"),
("if-unmodified-since-version", "1"),
],
Body::from(json!({"title": "Two"}).to_string()),
)
.await;
assert_eq!(changed.status(), StatusCode::NO_CONTENT);
let item = body_json(
s.call(
"GET",
&format!("/groups/{group}/items/SHAREDAA"),
Some(&bob_key),
&[],
Body::empty(),
)
.await,
)
.await;
let meta = &item["meta"];
assert_eq!(meta["createdByUser"]["id"], bob);
assert_eq!(meta["createdByUser"]["username"], BOB);
assert_eq!(meta["createdByUser"]["name"], "Bob Example");
assert_eq!(meta["lastModifiedByUser"]["id"], alice);
assert_eq!(item["data"]["title"], "Two");
let listed = body_json(
s.call(
"GET",
&format!("/groups/{group}/items?itemKey=SHAREDAA"),
Some(&alice_key),
&[],
Body::empty(),
)
.await,
)
.await;
assert_eq!(listed[0]["meta"]["createdByUser"]["id"], bob);
assert_eq!(
post_item(
&s,
&bob_key,
bob,
json!({"key": "MINEAAAA", "itemType": "book"})
)
.await,
StatusCode::OK
);
let mine = body_json(
s.call(
"GET",
&format!("/users/{bob}/items/MINEAAAA"),
Some(&bob_key),
&[],
Body::empty(),
)
.await,
)
.await;
assert!(mine["meta"].get("createdByUser").is_none(), "{mine}");
}
#[tokio::test]
async fn a_deleted_group_frees_its_owners_quota_and_its_files() {
let (s, group, [(alice, _), (bob, bob_key), _]) = group_server().await;
use crate::control::{LibraryRef, Role};
let hers = s
.app
.control
.create_group(alice, "Hers".into())
.await
.unwrap()
.id;
s.app
.control
.set_member(hers, bob, Role::Member)
.await
.unwrap()
.unwrap();
s.app.control.set_quota(alice, Some(1000)).await.unwrap();
s.app
.control
.note_usage(LibraryRef::Group(hers), 900)
.await
.unwrap();
assert_eq!(
s.app
.control
.usage_of(alice, LibraryRef::User(alice))
.await
.unwrap(),
900
);
let admin_view = s.app.control.users().await.unwrap();
assert_eq!(
admin_view
.iter()
.find(|u| u.user.id == alice)
.unwrap()
.bytes_used,
900
);
let attachment = json!({"key": "FILEAAAA", "itemType": "attachment",
"linkMode": "imported_file", "title": "a.pdf"});
let posted = s
.call(
"POST",
&format!("/groups/{group}/items"),
Some(&bob_key),
&[("content-type", "application/json")],
Body::from(json!([attachment]).to_string()),
)
.await;
assert_eq!(body_json(posted).await["failed"], json!({}));
s.app
.storage
.put_bytes(&format!("groups/{group}/files/FILEAAAA"), b"pdf".to_vec())
.await
.unwrap();
let lib = s.app.libraries.get(LibraryRef::Group(group)).await.unwrap();
lib.db
.write(|c| {
c.execute(
"INSERT INTO file (item_key, md5, filename, filesize, mtime, version)
VALUES ('FILEAAAA', 'm', 'a.pdf', 3, 0, 1)",
[],
)
})
.await
.unwrap();
drop(lib);
let redirect = s
.call(
"GET",
&format!("/groups/{group}/items/FILEAAAA/file"),
Some(&bob_key),
&[],
Body::empty(),
)
.await;
assert_eq!(redirect.status(), StatusCode::FOUND);
let link = header_str(&redirect, "location").replace(PUBLIC, "");
s.app
.control
.remove_member(group, bob)
.await
.unwrap()
.unwrap();
let late = s.call("GET", &link, None, &[], Body::empty()).await;
assert_eq!(late.status(), StatusCode::NOT_FOUND);
s.app.control.delete_group(hers).await.unwrap();
assert_eq!(
s.app
.control
.usage_of(alice, LibraryRef::User(alice))
.await
.unwrap(),
0
);
}
#[tokio::test]
async fn only_members_can_be_named_by_id_on_a_group_page() {
let (s, group, [(alice, _), (_, _), (carol, _)]) = group_server().await;
let add = s
.call(
"POST",
&format!("/ui/groups/{group}/members"),
None,
&[
("cookie", &session_for(&s, alice)),
("origin", PUBLIC),
("content-type", "application/x-www-form-urlencoded"),
],
Body::from(format!("user={carol}&role=member")),
)
.await;
assert_eq!(
header_str(&add, "location"),
format!("/ui/groups/{group}?error=user")
);
let (_, members) = s
.app
.control
.group_with_members(group)
.await
.unwrap()
.unwrap();
assert!(members.iter().all(|m| m.user.id != carol));
}
#[tokio::test]
async fn the_public_profile_narrows_works_by_language_and_license() {
let s = server_with_orcid(Some(ORCID)).await;
s.app.person.set(
ORCID,
serde_json::from_value(json!({"family_name": "Example"})).unwrap(),
);
let work = |key: &str, title: &str, language: &str, rights: &str| {
json!({"key": key, "itemType": "journalArticle", "title": title, "date": "2024",
"inPublications": true, "language": language, "rights": rights})
};
let resp = s
.json(
"POST",
"/users/1/items",
&[("if-unmodified-since-version", "0")],
json!([
work("WRKAAAAA", "English open", "en-US", "CC BY 4.0"),
work(
"WRKBBBBB",
"English closed",
"English",
"All rights reserved"
),
work(
"WRKCCCCC",
"German open",
"Deutsch",
"https://creativecommons.org/licenses/by/4.0/"
),
work("WRKDDDDD", "German NC", "ger", "CC BY-NC 4.0"),
work("WRKEEEEE", "Unknown", "Klingon", "©"),
]),
)
.await;
assert_eq!(body_json(resp).await["failed"], json!({}));
let uri = format!("/{ORCID}");
let html = body_text(visit(&s, &format!("{uri}?featured=0")).await).await;
let at = |text: &str| {
html.find(text)
.unwrap_or_else(|| panic!("{text} in {html}"))
};
assert!(at("Publication year") < at(">Language<"));
assert!(at(">Language<") < at("Content type"));
assert!(at("Content type") < at(">License<"));
assert!(html.contains(&format!("{uri}?language=en")), "{html}");
assert!(html.contains(&format!("{uri}?language=de")));
assert!(html.contains(&format!("{uri}?license=CC-BY-4.0")));
assert!(html.contains(&format!("{uri}?license=CC-BY-NC-4.0")));
assert!(html.contains("English") && html.contains("German"));
assert!(!html.contains("language=tlh") && !html.contains("Klingon</"));
let html = body_text(visit(&s, &format!("{uri}?language=de")).await).await;
assert!(html.contains("German open") && html.contains("German NC"));
assert!(!html.contains("English open"));
let html = body_text(visit(&s, &format!("{uri}?language=de&license=CC-BY-4.0")).await).await;
assert!(
html.contains("German open") && !html.contains("German NC"),
"{html}"
);
let html =
body_text(visit(&s, &format!("{uri}?license=CC-BY-4.0&license=CC-BY-NC-4.0")).await).await;
assert!(html.contains("English open") && html.contains("German NC"));
assert!(!html.contains("English closed"));
assert!(
html.contains(r#"name="license" value="CC-BY-NC-4.0""#),
"{html}"
);
let german = body_text(visit(&s, &format!("{uri}?featured=0&lang=de")).await).await;
assert!(
german.contains("Sprache") && german.contains("Deutsch") && german.contains("Englisch")
);
let ris = body_text(visit(&s, &format!("{uri}/export?format=ris&language=en")).await).await;
assert_eq!(ris.matches("TY - ").count(), 2, "{ris}");
}