//! Drops (oxygenhub#279): small snippets, each its own Oxygen repository //! named `OWNER/drops/ID`. Made and edited in the browser and through the //! API as signed changes by the hub as the owner's delegate; their files //! rendered and raw; cloned and pushed with oxy; secret ones readable by //! anyone with the address and listed nowhere; never among repositories; //! changed and deleted only by their owner (or, deleting, a site admin); //! push protection on their pushes and on what the hub commits for them. //! //! The fake credential is put together at run time, so this file is not //! itself a finding. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::{IdentityScope, Repo}; use oxygenhub::auth::random_hex; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::repos::RepoName; use oxygenhub::Shared; use std::path::Path; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } struct Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub() -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.registration.setup_code = "test-setup".into(); config.registration.mode = "open".into(); // Flow on for everything of andy's: a drop still never runs it. config.flow.repos = vec!["andy/*".into()]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, location: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } if let Some(t) = token { req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); } let body = match body { Some((ct, b)) => { req = req.header(header::CONTENT_TYPE, ct); Body::from(b) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(';').next()) .and_then(|kv| kv.split_once('=')) .map(|(_, v)| v.to_string()); let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } } async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { send(app, "GET", path, cookie, None, None).await } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await } async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option) -> Res { send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await } async fn register(app: &Shared, user: &str) -> String { let r = post( app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.cookie.unwrap() } async fn token(app: &Shared, user: &str) -> String { let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); let (secret, hash) = oxygenhub::auth::new_token(); db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap(); secret } /// `user`'s identity, delegating to the hub's key and linked to the /// account, as web edits need; the client checkout that holds it. async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo { let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id; let mut client = Repo::init(work).unwrap(); client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap(); client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); let key = client.config.author_key.unwrap(); let ident = client.identity(&key).unwrap(); db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap(); client } fn csrf_of(page: &str) -> String { let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); page[i..].split('"').next().unwrap().to_string() } fn enc(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } fn drop_id(location: &str) -> String { location.rsplit('/').next().unwrap().to_string() } const README: &str = "# Hello drops\n\nA *small* snippet.\n"; const MAIN_RS: &str = "fn main() {\n println!(\"hi\");\n}\n"; #[tokio::test(flavor = "multi_thread")] async fn drops_in_the_browser_the_api_and_oxy() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let bob = register(app, "bob").await; let andy_tok = token(app, "andy").await; let bob_tok = token(app, "bob").await; let work = tempfile::tempdir().unwrap(); // `/drops` is your own Drops page; signed out, it asks you to sign in. let r = get(app, "/drops", Some(&andy)).await; assert_eq!(r.location.as_deref(), Some("/andy/drops")); assert!(get(app, "/drops", None).await.location.unwrap().starts_with("/login")); let page = get(app, "/andy/drops", Some(&andy)).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("No drops yet") && page.body.contains("/drops/new"), "{}", page.body); // Before delegating to the hub's key, the form says so and nothing is made. let form = get(app, "/drops/new", Some(&andy)).await; assert!(form.body.contains("not delegated"), "{}", form.body); assert!(form.body.contains("data-drop-add") && form.body.contains("data-drop-files")); let csrf = csrf_of(&form.body); let fields = format!( "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&name=&content=", enc("Two files"), enc(README), enc(MAIN_RS) ); let r = post(app, "/drops/new", Some(&andy), &fields).await; assert_eq!(r.status, StatusCode::OK); assert!(r.body.contains("not delegated"), "{}", r.body); assert!(db::repos::drops_of(&app.db, "andy").await.unwrap().is_empty()); // Created in the browser: a repository of its own, the files its first // change, authored by andy and signed by the hub as his delegate. let mut client = delegate(app, "andy", work.path()).await; let r = post(app, "/drops/new", Some(&andy), &fields).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let url = r.location.unwrap(); let id = drop_id(&url); assert!(url.starts_with("/andy/drops/") && id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit()), "{url}"); let row = db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap(); assert!(row.is_drop() && row.title == "Hello" && row.description == "Two files" && row.visibility == "public"); let name = RepoName::drop("andy", &id).unwrap(); assert!(app.repos.exists(&name)); // Its page renders the files: Markdown as a document, code highlighted. let page = get(app, &url, None).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("

Hello drops

") || page.body.contains(">Hello drops"), "{}", page.body); assert!(page.body.contains("main.rs") && page.body.contains("println"), "{}", page.body); assert!(page.body.contains(&format!("oxy clone {}{url}", h.base))); assert!(!page.body.contains(">EDIT<"), "no edit for the signed out"); assert!(get(app, &url, Some(&andy)).await.body.contains(">EDIT<")); // The raw file, and a change page's tree link. let raw = get(app, &format!("{url}/raw/main/main.rs"), None).await; assert_eq!(raw.status, StatusCode::OK); assert_eq!(raw.body, MAIN_RS); assert_eq!(get(app, &format!("{url}/tree/main"), None).await.status, StatusCode::OK); // An edit in the browser: a second signed change; the history shows two. let edit = get(app, &format!("{url}/edit"), Some(&andy)).await; assert_eq!(edit.status, StatusCode::OK); assert!(edit.body.contains("README.md") && edit.body.contains("println"), "{}", edit.body); let csrf = csrf_of(&edit.body); let changed = "fn main() {\n println!(\"hello, drops\");\n}\n"; let r = post( app, &format!("{url}/edit"), Some(&andy), &format!( "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&message={}", enc("Two files"), enc(README), enc(changed), enc("Say hello") ), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let hist = get(app, &format!("{url}/changes/main"), None).await; assert_eq!(hist.status, StatusCode::OK); assert!(hist.body.contains("Say hello") && hist.body.contains("Create Hello"), "{}", hist.body); assert!(hist.body.contains("aria-label=\"Drop\""), "the drop's own bar"); assert!(!hist.body.contains("/proposals\"") && !hist.body.contains("swimlanes"), "no repository tabs"); let v = app.repos.read(&name, oxygenhub::repos::view::verification).await.unwrap(); assert_eq!((v.total, v.signed, v.invalid.len()), (2, 2, 0), "{v:?}"); // A change page with its diff, linked from the history. let change = hist.body.split(&format!("{url}/change/")).nth(1).unwrap().split('"').next().unwrap().to_string(); let cp = get(app, &format!("{url}/change/{change}"), None).await; assert_eq!(cp.status, StatusCode::OK); assert!(cp.body.contains("hello, drops"), "{}", cp.body); assert_eq!(get(app, &format!("{url}/raw/main/main.rs"), None).await.body, changed); // The push log has both, as web edits; Flow never ran. let pushes = db::repos::pushes(&app.db, row.id, 10).await.unwrap(); assert_eq!(pushes.iter().filter(|p| p.ok && p.reason == "web edit").count(), 2, "{pushes:?}"); assert!(db::flow::runs(&app.db, row.id, 10, None).await.unwrap().is_empty()); // A secret drop through the API. let r = api( app, "POST", "/drops", Some(&andy_tok), Some(serde_json::json!({ "title": "Hidden", "secret": true, "files": [{ "name": "notes.txt", "content": "secret plans\n" }] })), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); let secret: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); assert_eq!(secret.visibility, "secret"); assert_eq!(secret.files[0].content.as_deref(), Some("secret plans\n")); let secret_url = format!("/andy/drops/{}", secret.id); assert_eq!(secret.url, format!("{}{secret_url}", h.base)); // Anyone with the address reads it, signed in or not, on the web and the API. let page = get(app, &secret_url, None).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("secret plans") && page.body.contains("listed nowhere"), "{}", page.body); assert_eq!(get(app, &format!("{secret_url}/raw/main/notes.txt"), Some(&bob)).await.body, "secret plans\n"); let r = api(app, "GET", &format!("/drops/{}", secret.id), None, None).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); // But it is listed to andy only; the public one to everyone. let mine = get(app, "/andy/drops", Some(&andy)).await.body; assert!(mine.contains(&secret.id) && mine.contains(&id), "{mine}"); for viewer in [Some(bob.as_str()), None] { let theirs = get(app, "/andy/drops", viewer).await.body; assert!(theirs.contains(&id) && theirs.contains("Hello"), "{theirs}"); assert!(!theirs.contains(&secret.id) && !theirs.contains("Hidden"), "{theirs}"); } let listed: Vec = serde_json::from_str(&api(app, "GET", "/drops", Some(&andy_tok), None).await.body).unwrap(); assert_eq!(listed.len(), 2); let listed: Vec = serde_json::from_str(&api(app, "GET", "/users/andy/drops", Some(&bob_tok), None).await.body).unwrap(); assert_eq!(listed.iter().map(|d| d.id.as_str()).collect::>(), vec![id.as_str()]); assert!(listed[0].files.iter().all(|f| f.content.is_none()), "a list carries no content"); let listed: Vec = serde_json::from_str(&api(app, "GET", "/users/andy/drops", None, None).await.body).unwrap(); assert_eq!(listed.len(), 1); // Drops are never repositories: not on the profile, the dashboard, // explore, search, the API's repository list or under /repos. for (path, who) in [ ("/andy", Some(&andy)), ("/andy", None), ("/", Some(&andy)), ("/explore", None), ("/explore", Some(&andy)), ("/search?q=drops", Some(&andy)), ] { let body = get(app, path, who.map(String::as_str)).await.body; assert!(!body.contains(&id) && !body.contains(&secret.id), "{path} lists a drop: {body}"); } for q in ["Hello", "Hidden", id.as_str()] { let body = get(app, &format!("/search?q={q}"), Some(&andy)).await.body; assert!(!body.contains(&format!("/andy/drops/{id}")) && !body.contains(&secret_url), "search {q}: {body}"); } for tok in [Some(andy_tok.as_str()), None] { let repos = api(app, "GET", "/repos", tok, None).await.body; assert!(!repos.contains("drops/"), "{repos}"); } assert_eq!(api(app, "GET", &format!("/repos/andy/drops%2F{id}"), Some(&andy_tok), None).await.status, StatusCode::NOT_FOUND); assert!(db::repos::list(&app.db).await.unwrap().iter().all(|r| !r.is_drop())); assert!(db::repos::list_by_owner(&app.db, "andy").await.unwrap().is_empty()); assert_eq!(db::repos::list_with_drops(&app.db).await.unwrap().len(), 2, "upkeep sees them"); // `drops` is no repository's name. let csrf = csrf_of(&get(app, "/new", Some(&andy)).await.body); let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=drops&visibility=public")).await; assert!(r.status == StatusCode::OK && r.body.contains("class=\"error\""), "{}", r.body); assert!(!app.repos.exists(&RepoName { owner: "andy".into(), name: "drops".into() })); // `oxy clone` of a drop, anonymously, even a secret one; the owner pushes back. let anon = tempfile::tempdir().unwrap(); let (u, p) = (format!("{}{secret_url}", h.base), anon.path().join("c")); let cloned = tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().unwrap(); assert_eq!(std::fs::read_to_string(cloned.root().join("notes.txt")).unwrap(), "secret plans\n"); client.config.set("remote.origin.url", &format!("{}{url}", h.base)).unwrap(); client.config.set("remote.origin.token", &andy_tok).unwrap(); client.save_config().unwrap(); let (u, p, t) = (format!("{}{url}", h.base), work.path().join("mine"), andy_tok.clone()); let mut mine = tokio::task::spawn_blocking(move || { let mut r = Repo::clone(&u, &p).unwrap(); r.config.set("remote.origin.token", &t).unwrap(); r.save_config().unwrap(); r }) .await .unwrap(); assert_eq!(std::fs::read_to_string(mine.root().join("main.rs")).unwrap(), changed); mine.create_identity("andy", "andy@example.com", IdentityScope::Repo).unwrap(); write(mine.root(), "extra.sh", b"echo pushed\n"); mine.snapshot("t").unwrap(); let pushed = mine.commit("Add a script", &[]).unwrap(); assert!(mine.push("origin", false).unwrap().iter().all(|r| r.ok)); assert!(app.repos.open(&name).unwrap().store().get_change(&pushed).is_ok()); let page = get(app, &url, None).await.body; assert!(page.contains("extra.sh") && page.contains("> pushed<"), "{page}"); // Someone else's token cannot push to it. mine.config.set("remote.origin.token", &bob_tok).unwrap(); mine.save_config().unwrap(); write(mine.root(), "bob.txt", b"bob\n"); mine.snapshot("t").unwrap(); let bobs = mine.commit("bob's", &[]).unwrap(); let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("bob may not push")).to_string(); assert!(err.contains("403") || err.contains("write access"), "{err}"); mine.abandon(&bobs).unwrap(); mine.config.set("remote.origin.token", &andy_tok).unwrap(); mine.save_config().unwrap(); // Push protection refuses a drop push that adds a credential. let ghp = format!("{}_{}", "ghp", random_hex(18)); write(mine.root(), "token.env", format!("GH_TOKEN={ghp}\n").as_bytes()); mine.snapshot("t").unwrap(); let leak = mine.commit("oops", &[]).unwrap(); let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("the push was accepted")).to_string(); assert!(err.contains("push protection refused this push") && err.contains("token.env:1"), "{err}"); assert!(err.contains("A drop cannot allow a finding") && !err.contains(&ghp), "{err}"); assert!(app.repos.open(&name).unwrap().store().get_change(&leak).is_err(), "nothing stored"); mine.abandon(&leak).unwrap(); // And on the web and the API, before the hub commits anything. let csrf = csrf_of(&get(app, &format!("{url}/edit"), Some(&andy)).await.body); let r = post( app, &format!("{url}/edit"), Some(&andy), &format!("_csrf={csrf}&title=Hello&visibility=public&name=README.md&content={}&name=t.env&content={}", enc(README), enc(&ghp)), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(r.body.contains("Push protection refused") && r.body.contains("t.env:1"), "{}", r.body); let r = api( app, "PATCH", &format!("/drops/{id}"), Some(&andy_tok), Some(serde_json::json!({ "files": [{ "name": "t.env", "content": format!("X={ghp}\n") }] })), ) .await; assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); assert!(!r.body.contains(&ghp)); // Someone else can neither edit nor delete it, on the web or the API. assert_eq!(get(app, &format!("{url}/edit"), Some(&bob)).await.status, StatusCode::NOT_FOUND); let bob_csrf = csrf_of(&get(app, "/drops/new", Some(&bob)).await.body); let r = post(app, &format!("{url}/edit"), Some(&bob), &format!("_csrf={bob_csrf}&title=Mine&name=x&content=y")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = post(app, &format!("{url}/delete"), Some(&bob), &format!("_csrf={bob_csrf}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = api(app, "PATCH", &format!("/drops/{id}"), Some(&bob_tok), Some(serde_json::json!({ "title": "Mine" }))).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body); assert_eq!(api(app, "DELETE", &format!("/drops/{id}"), Some(&bob_tok), None).await.status, StatusCode::FORBIDDEN); assert_eq!(db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap().title, "Hello"); // The owner edits through the API: a file, the title, public. let r = api( app, "PATCH", &format!("/drops/{}", secret.id), Some(&andy_tok), Some(serde_json::json!({ "title": "Not hidden", "secret": false, "files": [{ "name": "more.txt", "content": "more\n" }] })), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); let d: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); assert_eq!((d.title.as_str(), d.visibility.as_str(), d.files.len()), ("Not hidden", "public", 2)); assert!(get(app, "/andy/drops", None).await.body.contains(&secret.id)); // Deleting: the owner on the web, its directory and row gone. let csrf = csrf_of(&get(app, &url, Some(&andy)).await.body); let r = post(app, &format!("{url}/delete"), Some(&andy), &format!("_csrf={csrf}")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert!(db::repos::drop_by_id(&app.db, &id).await.unwrap().is_none()); assert!(!app.repos.exists(&name)); assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND); let (u, p) = (format!("{}{url}", h.base), anon.path().join("gone")); assert!(tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().is_err()); // And through the API. assert_eq!(api(app, "DELETE", &format!("/drops/{}", secret.id), Some(&andy_tok), None).await.status, StatusCode::NO_CONTENT); assert_eq!(api(app, "GET", &format!("/drops/{}", secret.id), None, None).await.status, StatusCode::NOT_FOUND); let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); assert!(audit.iter().filter(|a| a.action == "drop.deleted").count() == 2, "{audit:?}"); assert!(audit.iter().any(|a| a.action == "drop.created") && audit.iter().any(|a| a.action == "drop.edited")); } /// A site administrator may delete someone's drop, and sees their secret /// ones listed; the drop's owner alone may change it. /// oxygenhub#280: someone else forks a drop: a drop of theirs with the /// same files, history and secrecy, which says where it came from. #[tokio::test(flavor = "multi_thread")] async fn a_drop_is_forked_with_its_history() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let carol = register(app, "carol").await; let work = tempfile::tempdir().unwrap(); delegate(app, "carol", work.path()).await; let csrf = csrf_of(&get(app, "/drops/new", Some(&carol)).await.body); let r = post(app, "/drops/new", Some(&carol), &format!("_csrf={csrf}&title=Notes&visibility=secret&name=a.txt&content=a")).await; let source = r.location.unwrap(); assert!(!get(app, &source, Some(&carol)).await.body.contains(">FORK<"), "not one's own"); assert!(!get(app, &source, None).await.body.contains(">FORK<"), "not signed out"); let page = get(app, &source, Some(&andy)).await.body; assert!(page.contains(">FORK<"), "{page}"); let r = post(app, &format!("{source}/fork"), Some(&andy), &format!("_csrf={}", csrf_of(&page))).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let fork = r.location.unwrap(); assert!(fork.starts_with("/andy/drops/") && fork != source, "{fork}"); let (from, made) = ( db::repos::drop_by_id(&app.db, &drop_id(&source)).await.unwrap().unwrap(), db::repos::drop_by_id(&app.db, &drop_id(&fork)).await.unwrap().unwrap(), ); assert!(made.is_drop() && made.owner == "andy" && made.title == "Notes" && made.visibility == "private", "{made:?}"); assert_eq!(made.forked_from, Some(from.id)); let shown = get(app, &fork, Some(&andy)).await.body; assert!(shown.contains(&format!("forked from ")), "{shown}"); assert_eq!(get(app, &format!("{fork}/raw/main/a.txt"), None).await.body, "a"); let history = get(app, &format!("{fork}/changes/main"), Some(&andy)).await.body; assert!(history.contains("Create Notes"), "the source's change: {history}"); // Stars: one per account, taken back by a second press. let page = get(app, &source, Some(&andy)).await.body; assert!(page.contains("☆ STAR 0"), "{page}"); let star = |cookie: String| { let (app, source) = (app.clone(), source.clone()); async move { let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); let r = post(&app, &format!("{source}/star"), Some(&cookie), &format!("_csrf={csrf}")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); } }; star(andy.clone()).await; star(carol.clone()).await; assert!(get(app, &source, Some(&andy)).await.body.contains("★ STARRED 2")); star(andy.clone()).await; assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1")); assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count"); // Comments: in Markdown; its owner is told; the author or the owner // deletes one, nobody else. let comment = |cookie: String, body: &'static str| { let (app, source) = (app.clone(), source.clone()); async move { let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); let r = post(&app, &format!("{source}/comment"), Some(&cookie), &format!("_csrf={csrf}&body={}", enc(body))).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.location.unwrap().rsplit_once("#comment-").unwrap().1.to_string() } }; let from_andy = comment(andy.clone(), "Nice **notes**").await; let from_carol = comment(carol.clone(), "Thanks").await; let page = get(app, &source, None).await.body; assert!(page.contains("notes") && page.contains(&format!("id=\"comment-{from_andy}\"")), "{page}"); assert!(page.contains("Sign in to comment.")); let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap(); let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap(); assert!(told.iter().any(|n| n.kind == "drop.comment" && n.url.ends_with(&format!("#comment-{from_andy}"))), "{told:?}"); let delete = |cookie: String, n: String| { let (app, source) = (app.clone(), source.clone()); async move { let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body); post(&app, &format!("{source}/comment/{n}/delete"), Some(&cookie), &format!("_csrf={csrf}")).await.status } }; let dave = register(app, "dave").await; assert_eq!(delete(dave, from_andy.clone()).await, StatusCode::NOT_FOUND, "not someone else's"); assert_eq!(delete(carol.clone(), from_andy.clone()).await, StatusCode::SEE_OTHER, "the drop's owner may"); assert_eq!(delete(carol.clone(), from_carol.clone()).await, StatusCode::SEE_OTHER, "and the author"); assert!(get(app, &source, None).await.body.contains("No comments yet.")); // Embedding: the snippet on its page; the embed page frames anywhere, // runs no script and links back. let page = get(app, &source, None).await.body; let at = page.find("id=\"embed\"").expect("the embed field"); assert!(page[at..].contains("iframe src=") && page[at..at + 400].contains(&format!("{source}/embed")), "{}", &page[at..at + 400]); let res = oxygenhub::router(app.clone()).oneshot(Request::get(format!("{source}/embed")).body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string(); assert!(csp.contains("frame-ancestors *") && csp.contains("default-src 'none'") && !csp.contains("script-src"), "{csp}"); assert!(res.headers().get(header::X_FRAME_OPTIONS).is_none(), "framed by other sites"); let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); assert!(body.contains("a.txt") && body.contains(&format!("{}{source}", h.base)) && !body.contains("a.txt")); assert!(get(app, "/carol/drops", Some(&admin)).await.body.contains(&drop_id(&url)), "admins see secret drops listed"); // The admin may not change it: no Edit, and the form and the API refuse. let page = get(app, &url, Some(&admin)).await.body; assert!(!page.contains(">EDIT<") && page.contains(">DELETE<"), "{page}"); assert_eq!(get(app, &format!("{url}/edit"), Some(&admin)).await.status, StatusCode::NOT_FOUND); let admin_tok = token(app, "andy").await; let r = api(app, "PATCH", &format!("/drops/{}", drop_id(&url)), Some(&admin_tok), Some(serde_json::json!({ "title": "x" }))).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body); let csrf = csrf_of(&page); let r = post(app, &format!("{url}/delete"), Some(&admin), &format!("_csrf={csrf}")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND); } /// oxygenhub#349: forking, stars and comments over the API, by the web's /// rules, for the extensions. #[tokio::test(flavor = "multi_thread")] async fn drops_are_forked_starred_and_commented_over_the_api() { let h = hub().await; let app = &h.app; register(app, "andy").await; register(app, "carol").await; register(app, "dave").await; let (andy, carol, dave) = (token(app, "andy").await, token(app, "carol").await, token(app, "dave").await); let work = tempfile::tempdir().unwrap(); delegate(app, "carol", work.path()).await; let new = serde_json::json!({ "title": "Notes", "description": "", "secret": false, "files": [{ "name": "a.txt", "content": "a" }] }); let r = api(app, "POST", "/drops", Some(&carol), Some(new)).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); let source: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); assert!(source.embed_url.ends_with(&format!("/carol/drops/{}/embed", source.id)) && source.stars == 0); // A fork is the caller's, from the source. let r = api(app, "POST", &format!("/drops/{}/fork", source.id), Some(&andy), None).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); let fork: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); assert_eq!((fork.owner.as_str(), fork.title.as_str()), ("andy", "Notes")); assert_eq!(fork.forked_from.as_deref(), Some(source.url.as_str())); assert_eq!(fork.files[0].content.as_deref(), Some("a")); assert_eq!(api(app, "POST", &format!("/drops/{}/fork", source.id), None, None).await.status, StatusCode::UNAUTHORIZED); // Stars: PUT stars, again changes nothing, DELETE takes it back. let stars = |tok: String, method: &'static str| { let (app, id) = (app.clone(), source.id.clone()); async move { let r = api(&app, method, &format!("/drops/{id}/star"), Some(&tok), None).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); serde_json::from_str::(&r.body).unwrap() } }; assert_eq!(stars(andy.clone(), "PUT").await, hub_api::DropStars { stars: 1, starred: true }); assert_eq!(stars(andy.clone(), "PUT").await, hub_api::DropStars { stars: 1, starred: true }, "once per account"); assert_eq!(stars(dave.clone(), "PUT").await.stars, 2); assert_eq!(stars(andy.clone(), "DELETE").await, hub_api::DropStars { stars: 1, starred: false }); let seen: hub_api::Drop = serde_json::from_str(&api(app, "GET", &format!("/drops/{}", source.id), Some(&dave), None).await.body).unwrap(); assert!(seen.stars == 1 && seen.starred, "{seen:?}"); // Comments: listed oldest first; the owner is told; only the author, // the owner or an administrator deletes one. let say = |tok: String, body: &'static str| { let (app, id) = (app.clone(), source.id.clone()); async move { api(&app, "POST", &format!("/drops/{id}/comments"), Some(&tok), Some(serde_json::json!({ "body": body }))).await } }; let r = say(andy.clone(), "Nice **notes**").await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); let first: hub_api::DropComment = serde_json::from_str(&r.body).unwrap(); assert_eq!((first.author.as_str(), first.body.as_str()), ("andy", "Nice **notes**")); assert_eq!(say(dave.clone(), " ").await.status, StatusCode::UNPROCESSABLE_ENTITY, "says something"); say(dave.clone(), "Thanks").await; let listed: Vec = serde_json::from_str(&api(app, "GET", &format!("/drops/{}/comments", source.id), None, None).await.body).unwrap(); assert_eq!(listed.iter().map(|c| c.author.as_str()).collect::>(), ["andy", "dave"]); let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap(); let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap(); assert!(told.iter().any(|n| n.kind == "drop.comment"), "{told:?}"); let del = |tok: String, n: i64| { let (app, id) = (app.clone(), source.id.clone()); async move { api(&app, "DELETE", &format!("/drops/{id}/comments/{n}"), Some(&tok), None).await.status } }; assert_eq!(del(dave.clone(), first.id).await, StatusCode::FORBIDDEN, "not someone else's"); assert_eq!(del(carol.clone(), first.id).await, StatusCode::NO_CONTENT, "the drop's owner may"); assert_eq!(del(carol.clone(), first.id).await, StatusCode::NOT_FOUND); }